#!/usr/bin/env python3 """Optional local CV worker for the Spring visual-analysis provider. Run: pip install -r tools/local_cv_requirements.txt uvicorn tools.local_cv_worker:app --host 127.0.0.1 --port 8091 """ from __future__ import annotations import os from pathlib import Path from typing import Any try: import cv2 # type: ignore except ImportError: # pragma: no cover - optional runtime dependency cv2 = None try: from fastapi import FastAPI except ImportError as exc: # pragma: no cover - fail clearly at worker startup raise SystemExit("Install FastAPI first: pip install fastapi uvicorn") from exc try: from ultralytics import YOLO # type: ignore except ImportError: # pragma: no cover - optional runtime dependency YOLO = None app = FastAPI(title="Local CV Visual Analysis Worker") _yolo_model: Any | None = None @app.get("/health") def health() -> dict[str, Any]: return { "status": "ok", "opencvAvailable": cv2 is not None, "yoloAvailable": YOLO is not None, "yoloModel": configured_yolo_model(), } @app.post("/v1/analyze-visuals") def analyze_visuals(payload: dict[str, Any]) -> dict[str, Any]: source = payload.get("source", {}) thumbnails = [str(item) for item in payload.get("thumbnails", [])] readable_thumbnails = [path for path in thumbnails if Path(path).is_file()] quality = quality_scores(readable_thumbnails) labels = object_labels(readable_thumbnails) return { "clipId": source.get("clipId", "source"), "blurScore": quality["blurScore"], "exposureScore": quality["exposureScore"], "motionScore": motion_score(payload.get("shotSegments", []), source.get("durationSeconds", 0)), "compositionScore": composition_score(source, readable_thumbnails), "facePresence": face_presence(readable_thumbnails), "objectLabels": labels or [{"label": "unknown", "confidence": 0.2, "source": "local_cv_worker"}], "representativeThumbnails": representative_thumbnails(readable_thumbnails or thumbnails), "analysisMethod": "local_cv_worker_opencv_yolo" if labels else "local_cv_worker_opencv", } def quality_scores(thumbnails: list[str]) -> dict[str, float]: if cv2 is None or not thumbnails: return {"blurScore": 0.5, "exposureScore": 0.5} blur_scores: list[float] = [] exposure_scores: list[float] = [] for thumbnail in thumbnails: image = cv2.imread(thumbnail) if image is None: continue gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) laplacian_variance = float(cv2.Laplacian(gray, cv2.CV_64F).var()) blur_scores.append(clamp(laplacian_variance / 500.0)) mean_brightness = float(gray.mean()) / 255.0 exposure_scores.append(clamp(1.0 - abs(mean_brightness - 0.5) * 2.0)) return { "blurScore": average_or_default(blur_scores, 0.5), "exposureScore": average_or_default(exposure_scores, 0.5), } def object_labels(thumbnails: list[str]) -> list[dict[str, Any]]: model = yolo_model() if model is None or not thumbnails: return [] labels: dict[str, float] = {} for result in model(thumbnails, verbose=False): names = getattr(result, "names", {}) boxes = getattr(result, "boxes", None) if boxes is None: continue for box in boxes: label = str(names.get(int(box.cls[0]), "object")) confidence = float(box.conf[0]) labels[label] = max(labels.get(label, 0.0), confidence) return [ {"label": label, "confidence": round(clamp(confidence), 3), "source": "yolo"} for label, confidence in sorted(labels.items(), key=lambda item: item[1], reverse=True)[:10] ] def yolo_model() -> Any | None: global _yolo_model model_path = configured_yolo_model() if YOLO is None or not model_path: return None if _yolo_model is None: _yolo_model = YOLO(model_path) return _yolo_model def configured_yolo_model() -> str: if os.getenv("LOCAL_CV_DISABLE_YOLO", "false").lower() == "true": return "" return os.getenv("LOCAL_CV_YOLO_MODEL", "yolov8n.pt") def face_presence(thumbnails: list[str]) -> str: if cv2 is None or not thumbnails: return "unknown_without_face_detector" cascade_path = getattr(cv2.data, "haarcascades", "") + "haarcascade_frontalface_default.xml" if not Path(cascade_path).is_file(): return "unknown_without_face_detector" cascade = cv2.CascadeClassifier(cascade_path) for thumbnail in thumbnails: image = cv2.imread(thumbnail) if image is None: continue gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4) if len(faces) > 0: return "faces_detected" return "no_faces_detected" def motion_score(shot_segments: list[dict[str, Any]], duration_seconds: float) -> float: try: duration = float(duration_seconds) except (TypeError, ValueError): duration = 0.0 if duration <= 0: return 0.3 cuts_per_minute = max(0, len(shot_segments) - 1) / duration * 60.0 return round(clamp(0.25 + cuts_per_minute / 20.0), 3) def composition_score(source: dict[str, Any], thumbnails: list[str]) -> float: width = float(source.get("width", 0) or 0) height = float(source.get("height", 0) or 0) aspect_ratio = width / height if height else 0 aspect_score = 0.8 if 1.70 <= aspect_ratio <= 1.90 else 0.55 thumbnail_score = 0.7 if thumbnails else 0.45 return round((aspect_score + thumbnail_score) / 2.0, 3) def representative_thumbnails(thumbnails: list[str]) -> list[str]: if len(thumbnails) <= 3: return thumbnails return [thumbnails[0], thumbnails[len(thumbnails) // 2], thumbnails[-1]] def average_or_default(values: list[float], default: float) -> float: if not values: return default return round(sum(values) / len(values), 3) def clamp(value: float) -> float: return max(0.0, min(1.0, value))