CoolFace
Apppublic

fabrix13/aivala

sourceHugging Facemitupdated 3mo agoView on Hugging Face
0likes
ai_inference_server.py509 linesDownload Raw Back to root
1from __future__ import annotations2 3import os4import base645import io6import json7import logging8import time9import uuid10from pathlib import Path11from typing import Any12 13from pydantic import BaseModel14 15logging.basicConfig(level=logging.INFO)16logger = logging.getLogger("aivala-inference")17 18try:19    from fastapi import FastAPI, File, UploadFile, Request20    from fastapi.middleware.cors import CORSMiddleware21    from fastapi.responses import JSONResponse22    from fastapi.staticfiles import StaticFiles23except Exception as exc:  # pragma: no cover24    raise RuntimeError(25        "FastAPI is required to run this server. Install fastapi and uvicorn."26    ) from exc27 28 29ROOT = Path(__file__).resolve().parent30MODEL_PATH = ROOT / "best.pt"31ULTRALYTICS_DIR = ROOT / ".ultralytics"32ULTRALYTICS_DIR.mkdir(exist_ok=True)33os.environ.setdefault("YOLO_CONFIG_DIR", str(ULTRALYTICS_DIR))34os.environ.setdefault("ULTRALYTICS_SETTINGS_DIR", str(ULTRALYTICS_DIR))35 36STATIC_DIR = ROOT / "static"37STATIC_DIR.mkdir(exist_ok=True)38COLAB_SEVERITY_URL = os.getenv("COLAB_SEVERITY_URL", "").strip()39print("COLAB_SEVERITY_URL =", COLAB_SEVERITY_URL)40SEVERITY_LEVELS = {"none", "minor", "moderate", "severe"}41 42app = FastAPI(title="YOLO Damage Inference API")43app.add_middleware(44    CORSMiddleware,45    allow_origins=["*"],46    allow_credentials=True,47    allow_methods=["*"],48    allow_headers=["*"],49)50 51# Mount the static directory to serve static assets52app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")53 54MODEL = None55 56 57class Detection(BaseModel):58    class_id: int59    label: str60    confidence: float61    bbox: list[float]62    severity: str | None = None63    severity_note: str | None = None64    severity_source: str | None = None65 66 67class FaceVerifyRequest(BaseModel):68    frame: str69 70 71def _normalize_severity(value: Any) -> str:72    severity = str(value or "").strip().lower()73    if any(word in severity for word in ["none", "nope", "low clarity", "unclear", "no damage", "false", "nothing", "invalid", "not visible"]):74        return "none"75    if severity in SEVERITY_LEVELS:76        return severity77    return "moderate"78 79 80def _heuristic_severity(label: str, confidence: float) -> tuple[str, str]:81    label_lower = label.lower()82    if any(term in label_lower for term in ["frame", "windshield", "window", "headlight"]):83        return "severe", "Major component damage detected; replacement may be required."84    if any(term in label_lower for term in ["crack", "puncture"]) or confidence >= 0.82:85        return "moderate", "Visible damage detected; repair is likely required."86    return "minor", "Small visible damage area; likely cosmetic repair."87 88 89async def _request_colab_severity(items: list[dict[str, Any]]) -> list[dict[str, str]] | None:90    if not COLAB_SEVERITY_URL:91        logger.warning("Colab severity skipped: COLAB_SEVERITY_URL is not configured.")92        return None93 94    if not items:95        logger.warning("Colab severity skipped: no detection crops were prepared.")96        return None97    try:98        import httpx99    except Exception as exc:100        logger.warning("Colab severity skipped: httpx import failed: %s", exc)101        return None102    payload = {103        "model": "Qwen-3.5-VL-4B",104        "severity_schema": {105            "none": ["No damage visible", "False detection"],106            "minor": ["Cosmetic damage only", "Small area affected"],107            "moderate": ["Visible deformation", "Repair likely required"],108            "severe": ["Major deformation", "Part replacement likely"],109        },110        "detections": items,111    }112    try:113        logger.info("Calling Colab severity endpoint for %d detection crop(s).", len(items))114        115        import socket116 117        logger.info(118            "DNS lookup: %s",119            socket.gethostbyname("curfew-stump-tripod.ngrok-free.dev")120        )        121        async with httpx.AsyncClient(122            timeout=60.0,123            verify=False,124            ) as client:125            health_url = COLAB_SEVERITY_URL.replace("/severity", "/health")126            health = await client.get(health_url)127            logger.info("HEALTH STATUS: %s", health.status_code)128            response = await client.post(COLAB_SEVERITY_URL, json=payload)129            response.raise_for_status()130            data = response.json()131    except Exception as exc:132        import traceback133 134        logger.error("COLAB REQUEST EXCEPTION TYPE: %s", type(exc).__name__)135        logger.error("COLAB REQUEST EXCEPTION REPR: %r", exc)136        logger.exception("Full traceback")137 138        return None139    raw_results = data.get("results") if isinstance(data, dict) else data140    if not isinstance(raw_results, list):141        logger.warning(142            "Colab severity returned unexpected response shape; falling back to heuristic severity. Response type: %s",143            type(data).__name__,144        )145        return None146    results: list[dict[str, str]] = []147    for index, result in enumerate(raw_results):148        if not isinstance(result, dict):149            continue150        results.append({151            "detection_id": str(result.get("detection_id", items[index].get("detection_id", index))),152            "severity": _normalize_severity(result.get("severity")),153            "note": str(result.get("note") or result.get("severity_note") or "Severity estimated from the detection frame."),154        })155    if not results:156        logger.warning("Colab severity returned no usable result items; falling back to heuristic severity.")157        return None158 159    logger.info("Colab severity succeeded for %d detection crop(s).", len(results))160    return results161 162 163def _encode_detection_crop(frame: Any, bbox: list[float]) -> str | None:164    import cv2165 166    h, w = frame.shape[:2]167    x1, y1, x2, y2 = [int(round(v)) for v in bbox]168    pad_x = max(8, int((x2 - x1) * 0.18))169    pad_y = max(8, int((y2 - y1) * 0.18))170    x1 = max(0, x1 - pad_x)171    y1 = max(0, y1 - pad_y)172    x2 = min(w, x2 + pad_x)173    y2 = min(h, y2 + pad_y)174    if x2 <= x1 or y2 <= y1:175        return None176    crop = frame[y1:y2, x1:x2]177    ch, cw = crop.shape[:2]178    if cw > 768:179        scale = 768.0 / cw180        crop = cv2.resize(crop, (768, int(ch * scale)), interpolation=cv2.INTER_AREA)181    ok, buffer = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 82])182    if not ok:183        return None184    return "data:image/jpeg;base64," + base64.b64encode(buffer.tobytes()).decode("utf-8")185 186 187async def _annotate_and_predict(video_path: Path, base_url: str) -> dict[str, Any]:188    try:189        from ultralytics import YOLO190        import cv2191    except Exception as exc:  # pragma: no cover192        raise RuntimeError(193            "ultralytics and opencv-python are required for inference."194        ) from exc195 196    if not MODEL_PATH.exists():197        raise FileNotFoundError(f"Missing model file: {MODEL_PATH}")198 199    global MODEL200    model = MODEL or YOLO(str(MODEL_PATH))201    capture = cv2.VideoCapture(str(video_path))202    if not capture.isOpened():203        raise RuntimeError("Could not open uploaded video.")204 205    # Retrieve video dimensions and properties206    width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))207    height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))208    fps = float(capture.get(cv2.CAP_PROP_FPS))209 210    logger.info(211        "Video properties: width=%s height=%s fps=%s",212        width,213        height,214        fps,215    )216 217    if width <= 0 or height <= 0:218        capture.release()219        raise RuntimeError(f"Invalid video dimensions: {width}x{height}")220 221    if fps <= 0 or fps > 240:222        logger.warning("Invalid FPS reported (%s). Falling back to 30 FPS.", fps)223        fps = 30.0224 225    sample_interval = max(1, int(fps / 10.0))226 227    # Video generation removed; returning images only.228 229    frame_index = 0230    all_detections: list[dict[str, Any]] = []231    severity_items_by_key: dict[str, dict[str, Any]] = {}232    best_frame = None233    max_conf_sum = -1.0234    best_frame_index = 0235 236    while True:237        ok, frame = capture.read()238        if not ok:239            break240        frame_index += 1241 242        # Only process frames that match the sample interval243        if sample_interval > 1 and frame_index % sample_interval != 0:244            continue245 246        # Run inference on a resized frame for speed247        frame_small = frame248        fh, fw = frame.shape[:2]249        scale = min(1280 / max(fh, fw), 1.0)250 251        if scale < 1.0:252            frame_small = cv2.resize(253                frame,254                (int(fw * scale), int(fh * scale)),255                interpolation=cv2.INTER_AREA,256            )257 258        results = model.predict(frame_small, conf=0.25, verbose=False)259        annotated = frame.copy()260 261        scale_x = fw / frame_small.shape[1]262        scale_y = fh / frame_small.shape[0]263 264        if results and len(results[0].boxes) > 0:265            result = results[0]266            annotated = frame.copy()267            names = result.names268            conf_sum = 0.0269 270            for box in result.boxes:271                cls_id = int(box.cls[0])272                confidence = float(box.conf[0])273                label = names.get(cls_id, str(cls_id))274                x1, y1, x2, y2 = box.xyxy[0].tolist()275 276                xyxy = [277                    float(x1 * scale_x),278                    float(y1 * scale_y),279                    float(x2 * scale_x),280                    float(y2 * scale_y),281                ]282 283                x1i, y1i, x2i, y2i = map(int, xyxy)284 285                cv2.rectangle(286                    annotated,287                    (x1i, y1i),288                    (x2i, y2i),289                    (0, 255, 0),290                    2,291                )292 293                cv2.putText(294                    annotated,295                    f"{label} {confidence:.2f}",296                    (x1i, max(25, y1i - 10)),297                    cv2.FONT_HERSHEY_SIMPLEX,298                    0.7,299                    (0, 255, 0),300                    2,301                    cv2.LINE_AA,302                )303 304                conf_sum += confidence305 306                all_detections.append({307                    "class_id": cls_id,308                    "label": label,309                    "confidence": confidence,310                    "bbox": xyxy311                })312 313                crop = _encode_detection_crop(frame, xyxy)314                if crop and (label not in severity_items_by_key or confidence > severity_items_by_key[label]["confidence"]):315                    severity_items_by_key[label] = {316                        "detection_id": label,317                        "label": label,318                        "confidence": confidence,319                        "bbox": [float(v) for v in xyxy],320                        "image": crop,321                    }322 323            # Select the frame with the highest confidence sum as the best frame (the thumbnail image)324            if conf_sum > max_conf_sum:325                max_conf_sum = conf_sum326                best_frame = annotated.copy()327                best_frame_index = frame_index328 329 330        # Fallback to the first frame if no detections occurred331        if best_frame is None:332            best_frame = annotated.copy()333            best_frame_index = frame_index334 335    capture.release()336 337    if best_frame is None:338        raise RuntimeError("No frames could be read or written.")339 340    # Create a mobile-friendly preview image.341    preview = best_frame342    ph, pw = preview.shape[:2]343 344    max_dim = 1280345    scale = min(max_dim / max(ph, pw), 1.0)346 347    if scale < 1.0:348        preview = cv2.resize(349            preview,350            (int(pw * scale), int(ph * scale)),351            interpolation=cv2.INTER_AREA,352        )353 354    ok, buffer = cv2.imencode(355        ".jpg",356        preview,357        [cv2.IMWRITE_JPEG_QUALITY, 80],358    )359    if not ok:360        raise RuntimeError("Could not encode annotated frame.")361 362    annotated_image = base64.b64encode(buffer.tobytes()).decode("utf-8")363 364    logger.info(365        "annotated_image length=%s bytes",366        len(annotated_image),367    )368 369    # Deduplicate detections: group by label and keep the highest confidence detection370    best_detections: dict[str, dict[str, Any]] = {}371    for det in all_detections:372        label = det["label"]373        if label not in best_detections or det["confidence"] > best_detections[label]["confidence"]:374            best_detections[label] = det375 376    detections = list(best_detections.values())377 378    severity_by_id: dict[str, dict[str, str]] = {}379    colab_results = await _request_colab_severity(list(severity_items_by_key.values()))380    if colab_results:381        severity_by_id = {item["detection_id"]: item for item in colab_results}382    elif detections:383        logger.warning(384            "Using heuristic severity fallback for %d detection(s). Check COLAB_SEVERITY_URL and Colab server logs.",385            len(detections),386        )387 388    for det in detections:389        result = severity_by_id.get(det["label"])390        if result:391            det["severity"] = result["severity"]392            det["severity_note"] = result["note"]393            det["severity_source"] = "Qwen-3.5-VL-4B"394        else:395            severity, note = _heuristic_severity(det["label"], det["confidence"])396            det["severity"] = severity397            det["severity_note"] = note398            det["severity_source"] = "fallback"399 400    # Build summary label401    if detections:402        summary_parts = []403        for det in detections:404            summary_parts.append(f"{det['label']} ({int(det['confidence']*100)}%)")405        summary = "Detected: " + ", ".join(summary_parts)406    else:407        summary = "No damages detected"408 409    return {410        "annotated_image": f"data:image/jpeg;base64,{annotated_image}",411        "detection_frames": list(severity_items_by_key.values()),412        "detections": detections,413        "source_frame": best_frame_index,414        "summary": summary,415    }416 417 418@app.post("/analyze-video")419async def analyze_video(request: Request, file: UploadFile = File(...)):420    suffix = Path(file.filename).suffix.lower() or ".mp4"421    temp_path = ROOT / f"upload_{uuid.uuid4().hex}{suffix}"422    content = await file.read()423    temp_path.write_bytes(content)424    try:425        base_url = str(request.base_url).rstrip('/')426        result = await _annotate_and_predict(temp_path, base_url)427        return JSONResponse(result)428    finally:429        if temp_path.exists():430            temp_path.unlink()431 432 433@app.post("/verify-face")434async def verify_face(payload: FaceVerifyRequest):435    import cv2436    import numpy as np437    438    try:439        raw = payload.frame.split(",")[-1]440        img_bytes = base64.b64decode(raw)441        nparr = np.frombuffer(img_bytes, np.uint8)442        frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)443        if frame is None:444            return JSONResponse({"verified": False, "face_count": 0, "confidence": 0.0, "face_crop": None})445 446        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)447        gray = cv2.equalizeHist(gray)448        449        frontal_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")450        profile_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_profileface.xml")451 452        faces = frontal_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60))453        if len(faces) == 0:454            faces = profile_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(60, 60))455 456        verified = len(faces) > 0457        face_count = len(faces)458        confidence = 0.0459        face_crop_b64 = None460 461        if verified:462            x, y, w, h = max(faces, key=lambda f: f[2] * f[3])463            pad = int(min(w, h) * 0.20)464            x1 = max(0, x - pad)465            y1 = max(0, y - pad)466            x2 = min(frame.shape[1], x + w + pad)467            y2 = min(frame.shape[0], y + h + pad)468            face_crop = frame[y1:y2, x1:x2]469 470            ok, buf = cv2.imencode(".jpg", face_crop, [cv2.IMWRITE_JPEG_QUALITY, 85])471            if ok:472                face_crop_b64 = "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode()473 474            confidence = round((w * h) / (frame.shape[0] * frame.shape[1]) * 10, 2)475            confidence = min(confidence, 0.99)476 477        return JSONResponse({478            "verified": verified,479            "face_count": face_count,480            "confidence": confidence,481            "face_crop": face_crop_b64482        })483    except Exception as e:484        return JSONResponse({"verified": False, "face_count": 0, "confidence": 0.0, "face_crop": None, "error": str(e)}, status_code=500)485 486 487@app.on_event("startup")488async def startup_load_model():489    global MODEL490    try:491        from ultralytics import YOLO492        if MODEL is None and MODEL_PATH.exists():493            MODEL = YOLO(str(MODEL_PATH))494            logger.info("YOLO model loaded once at startup.")495    except Exception:496        logger.exception("Failed loading YOLO model at startup")497 498 499@app.get("/health")500def health():501    return {"status": "ok", "model": str(MODEL_PATH)}502 503 504if __name__ == "__main__":  # pragma: no cover505    import uvicorn506 507    port = int(os.getenv("AI_SERVER_PORT", "8001"))508    uvicorn.run(app, host="0.0.0.0", port=port)509