diff --git a/core/api/main.py b/core/api/main.py index fa307e1..4177145 100644 --- a/core/api/main.py +++ b/core/api/main.py @@ -40,7 +40,7 @@ app = FastAPI( # CORS app.add_middleware( CORSMiddleware, - allow_origins=["http://mpr.local.ar", "http://localhost:5173"], + allow_origins=["http://mpr.local.ar", "http://k8s.mpr.local.ar", "http://localhost:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -57,6 +57,11 @@ app.include_router(chunker_router) app.include_router(detect_router) +@app.get("/health") +def health(): + return {"status": "ok"} + + @app.get("/") def root(): """API root.""" diff --git a/ctrl/Dockerfile b/ctrl/Dockerfile index 247b054..de26256 100644 --- a/ctrl/Dockerfile +++ b/ctrl/Dockerfile @@ -1,11 +1,13 @@ FROM python:3.11-slim +RUN pip install --no-cache-dir uv + WORKDIR /app COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN uv pip install --system --no-cache -r requirements.txt -# No COPY . . — code is volume-mounted in dev (..:/app) -# This image only provides the Python runtime + dependencies +# Copy code into image (k8s uses this, docker-compose volume-mounts over it) +COPY . . CMD ["python", "admin/manage.py", "runserver", "0.0.0.0:8000"] diff --git a/ctrl/Dockerfile.worker b/ctrl/Dockerfile.worker index e69ff5b..9f62a11 100644 --- a/ctrl/Dockerfile.worker +++ b/ctrl/Dockerfile.worker @@ -1,5 +1,7 @@ FROM python:3.11-slim +RUN pip install --no-cache-dir uv + RUN apt-get update && apt-get install -y \ ffmpeg \ && rm -rf /var/lib/apt/lists/* @@ -7,9 +9,9 @@ RUN apt-get update && apt-get install -y \ WORKDIR /app COPY requirements.txt requirements-worker.txt ./ -RUN pip install --no-cache-dir -r requirements-worker.txt +RUN uv pip install --system --no-cache -r requirements-worker.txt -# No COPY . . — code is volume-mounted in dev (..:/app) -# This image only provides Python runtime + FFmpeg + dependencies +# Copy code into image (k8s uses this, docker-compose volume-mounts over it) +COPY . . CMD ["celery", "-A", "admin.mpr", "worker", "--loglevel=info"] diff --git a/ctrl/Tiltfile b/ctrl/Tiltfile new file mode 100644 index 0000000..63499fd --- /dev/null +++ b/ctrl/Tiltfile @@ -0,0 +1,43 @@ +# MPR — Tilt development environment +# Usage: cd ctrl && tilt up +# Cluster: kind (name: mpr) + +allow_k8s_contexts('kind-mpr') + +# Apply k8s manifests via kustomize (dev overlay) +k8s_yaml(kustomize('k8s/overlays/dev')) + +# --- Images — reuse existing Dockerfiles --- + +# FastAPI (Python backend) +docker_build( + 'mpr-fastapi', + context='..', + dockerfile='Dockerfile', + live_update=[ + sync('..', '/app'), + ], +) + +# Detection UI (Vue 3) +docker_build( + 'mpr-detection', + context='../ui/detection-app', + dockerfile='../ui/detection-app/Dockerfile', + live_update=[ + sync('../ui/detection-app/src', '/app/src'), + sync('../ui/detection-app/index.html', '/app/index.html'), + sync('../ui/detection-app/vite.config.ts', '/app/vite.config.ts'), + ], +) + +# Framework changes trigger a full rebuild (live_update can't reach outside context) +watch_file('../ui/framework/src') + +# --- Resources --- + +k8s_resource('redis') +k8s_resource('fastapi', resource_deps=['redis']) +k8s_resource('detection-ui') +k8s_resource('gateway', resource_deps=['fastapi', 'detection-ui'], + port_forwards=['8080:8080']) diff --git a/ctrl/k8s/base/configmap.yaml b/ctrl/k8s/base/configmap.yaml new file mode 100644 index 0000000..08b6304 --- /dev/null +++ b/ctrl/k8s/base/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: mpr-config + namespace: mpr +data: + REDIS_URL: redis://redis:6379/0 + DEBUG: "1" + FASTAPI_PORT: "8702" + DETECTION_UI_PORT: "5175" + GATEWAY_PORT: "8080" diff --git a/ctrl/k8s/base/detection-ui.yaml b/ctrl/k8s/base/detection-ui.yaml new file mode 100644 index 0000000..24fe285 --- /dev/null +++ b/ctrl/k8s/base/detection-ui.yaml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: detection-ui + namespace: mpr +spec: + replicas: 1 + selector: + matchLabels: + app: detection-ui + template: + metadata: + labels: + app: detection-ui + spec: + containers: + - name: detection-ui + image: mpr-detection + ports: + - containerPort: 5175 + envFrom: + - configMapRef: + name: mpr-config + env: + - name: VITE_ALLOWED_HOSTS + value: "k8s.mpr.local.ar" + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: detection-ui + namespace: mpr +spec: + selector: + app: detection-ui + ports: + - port: 5175 + targetPort: 5175 diff --git a/ctrl/k8s/base/fastapi.yaml b/ctrl/k8s/base/fastapi.yaml new file mode 100644 index 0000000..90d1833 --- /dev/null +++ b/ctrl/k8s/base/fastapi.yaml @@ -0,0 +1,48 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fastapi + namespace: mpr +spec: + replicas: 1 + selector: + matchLabels: + app: fastapi + template: + metadata: + labels: + app: fastapi + spec: + containers: + - name: fastapi + image: mpr-fastapi + command: ["sh", "-c", "uvicorn core.api.main:app --host 0.0.0.0 --port $FASTAPI_PORT --reload"] + ports: + - containerPort: 8702 + envFrom: + - configMapRef: + name: mpr-config + readinessProbe: + httpGet: + path: /health + port: 8702 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: fastapi + namespace: mpr +spec: + selector: + app: fastapi + ports: + - port: 8702 + targetPort: 8702 diff --git a/ctrl/k8s/base/gateway.yaml b/ctrl/k8s/base/gateway.yaml new file mode 100644 index 0000000..3122984 --- /dev/null +++ b/ctrl/k8s/base/gateway.yaml @@ -0,0 +1,128 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: envoy-gateway-config + namespace: mpr +data: + envoy.yaml: | + static_resources: + listeners: + - name: http + address: + socket_address: + address: 0.0.0.0 + port_value: 8080 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress + codec_type: AUTO + route_config: + name: local_routes + virtual_hosts: + - name: mpr + domains: ["k8s.mpr.local.ar", "*"] + routes: + # SSE — long timeout, no buffering + - match: + prefix: "/api/detect/stream/" + route: + cluster: fastapi + timeout: 3600s + idle_timeout: 3600s + # FastAPI — strip /api/ prefix + - match: + prefix: "/api/" + route: + cluster: fastapi + prefix_rewrite: "/" + # Detection UI + - match: + prefix: "/detection/" + route: + cluster: detection-ui + # Default + - match: + prefix: "/" + route: + cluster: detection-ui + prefix_rewrite: "/detection/" + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + clusters: + - name: fastapi + connect_timeout: 5s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: fastapi + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: fastapi + port_value: 8702 + - name: detection-ui + connect_timeout: 5s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: detection-ui + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: detection-ui + port_value: 5175 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gateway + namespace: mpr +spec: + replicas: 1 + selector: + matchLabels: + app: gateway + template: + metadata: + labels: + app: gateway + spec: + containers: + - name: envoy + image: envoyproxy/envoy:v1.28-latest + ports: + - containerPort: 8080 + volumeMounts: + - name: config + mountPath: /etc/envoy + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi + volumes: + - name: config + configMap: + name: envoy-gateway-config +--- +apiVersion: v1 +kind: Service +metadata: + name: gateway + namespace: mpr +spec: + selector: + app: gateway + ports: + - port: 80 + targetPort: 8080 diff --git a/ctrl/k8s/base/kustomization.yaml b/ctrl/k8s/base/kustomization.yaml new file mode 100644 index 0000000..cc501ab --- /dev/null +++ b/ctrl/k8s/base/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mpr + +resources: + - namespace.yaml + - configmap.yaml + - redis.yaml + - fastapi.yaml + - detection-ui.yaml + - gateway.yaml diff --git a/ctrl/k8s/base/namespace.yaml b/ctrl/k8s/base/namespace.yaml new file mode 100644 index 0000000..2b8be1a --- /dev/null +++ b/ctrl/k8s/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mpr diff --git a/ctrl/k8s/base/redis.yaml b/ctrl/k8s/base/redis.yaml new file mode 100644 index 0000000..09fdb5d --- /dev/null +++ b/ctrl/k8s/base/redis.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: mpr +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 2 + periodSeconds: 5 + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: mpr +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 diff --git a/ctrl/k8s/k8s/base/detection-ui.yaml b/ctrl/k8s/k8s/base/detection-ui.yaml new file mode 100644 index 0000000..3005735 --- /dev/null +++ b/ctrl/k8s/k8s/base/detection-ui.yaml @@ -0,0 +1,38 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: detection-ui + namespace: mpr +spec: + replicas: 1 + selector: + matchLabels: + app: detection-ui + template: + metadata: + labels: + app: detection-ui + spec: + containers: + - name: detection-ui + image: mpr-detection + ports: + - containerPort: 5175 + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: detection-ui + namespace: mpr +spec: + selector: + app: detection-ui + ports: + - port: 5175 + targetPort: 5175 diff --git a/ctrl/k8s/k8s/base/fastapi.yaml b/ctrl/k8s/k8s/base/fastapi.yaml new file mode 100644 index 0000000..1178684 --- /dev/null +++ b/ctrl/k8s/k8s/base/fastapi.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fastapi + namespace: mpr +spec: + replicas: 1 + selector: + matchLabels: + app: fastapi + template: + metadata: + labels: + app: fastapi + spec: + containers: + - name: fastapi + image: mpr-fastapi + command: ["uvicorn", "core.api.main:app", "--host", "0.0.0.0", "--port", "8702", "--reload"] + ports: + - containerPort: 8702 + env: + - name: REDIS_URL + value: redis://redis:6379/0 + - name: DJANGO_ALLOW_ASYNC_UNSAFE + value: "true" + - name: DEBUG + value: "1" + readinessProbe: + httpGet: + path: /health + port: 8702 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 512Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: fastapi + namespace: mpr +spec: + selector: + app: fastapi + ports: + - port: 8702 + targetPort: 8702 diff --git a/ctrl/k8s/k8s/base/gateway.yaml b/ctrl/k8s/k8s/base/gateway.yaml new file mode 100644 index 0000000..82254e5 --- /dev/null +++ b/ctrl/k8s/k8s/base/gateway.yaml @@ -0,0 +1,128 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: envoy-gateway-config + namespace: mpr +data: + envoy.yaml: | + static_resources: + listeners: + - name: http + address: + socket_address: + address: 0.0.0.0 + port_value: 8080 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress + codec_type: AUTO + route_config: + name: local_routes + virtual_hosts: + - name: mpr + domains: ["k8s.mpr.local.ar", "*"] + routes: + # SSE — disable buffering + - match: + prefix: "/api/detect/stream/" + route: + cluster: fastapi + timeout: 3600s + idle_timeout: 3600s + # FastAPI — strip /api/ prefix + - match: + prefix: "/api/" + route: + cluster: fastapi + prefix_rewrite: "/" + # Detection UI + - match: + prefix: "/detection/" + route: + cluster: detection-ui + # Default — detection UI + - match: + prefix: "/" + route: + cluster: detection-ui + prefix_rewrite: "/detection/" + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + clusters: + - name: fastapi + connect_timeout: 5s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: fastapi + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: fastapi + port_value: 8702 + - name: detection-ui + connect_timeout: 5s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: detection-ui + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: detection-ui + port_value: 5175 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gateway + namespace: mpr +spec: + replicas: 1 + selector: + matchLabels: + app: gateway + template: + metadata: + labels: + app: gateway + spec: + containers: + - name: envoy + image: envoyproxy/envoy:v1.28-latest + ports: + - containerPort: 8080 + volumeMounts: + - name: config + mountPath: /etc/envoy + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi + volumes: + - name: config + configMap: + name: envoy-gateway-config +--- +apiVersion: v1 +kind: Service +metadata: + name: gateway + namespace: mpr +spec: + selector: + app: gateway + ports: + - port: 80 + targetPort: 8080 diff --git a/ctrl/k8s/k8s/base/kustomization.yaml b/ctrl/k8s/k8s/base/kustomization.yaml new file mode 100644 index 0000000..1f851ad --- /dev/null +++ b/ctrl/k8s/k8s/base/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mpr + +resources: + - namespace.yaml + - redis.yaml + - fastapi.yaml + - detection-ui.yaml + - gateway.yaml diff --git a/ctrl/k8s/k8s/base/namespace.yaml b/ctrl/k8s/k8s/base/namespace.yaml new file mode 100644 index 0000000..2b8be1a --- /dev/null +++ b/ctrl/k8s/k8s/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mpr diff --git a/ctrl/k8s/k8s/base/redis.yaml b/ctrl/k8s/k8s/base/redis.yaml new file mode 100644 index 0000000..09fdb5d --- /dev/null +++ b/ctrl/k8s/k8s/base/redis.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: mpr +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 2 + periodSeconds: 5 + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: mpr +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 diff --git a/ctrl/k8s/k8s/overlays/cloud/kustomization.yaml b/ctrl/k8s/k8s/overlays/cloud/kustomization.yaml new file mode 100644 index 0000000..7291ef7 --- /dev/null +++ b/ctrl/k8s/k8s/overlays/cloud/kustomization.yaml @@ -0,0 +1,20 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + # Gateway as cloud LoadBalancer + - target: + kind: Service + name: gateway + patch: | + - op: replace + path: /spec/type + value: LoadBalancer + - op: add + path: /metadata/annotations + value: + service.beta.kubernetes.io/aws-load-balancer-type: nlb + service.beta.kubernetes.io/aws-load-balancer-scheme: internal diff --git a/ctrl/k8s/k8s/overlays/dev/kustomization.yaml b/ctrl/k8s/k8s/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..6dddd62 --- /dev/null +++ b/ctrl/k8s/k8s/overlays/dev/kustomization.yaml @@ -0,0 +1,30 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + # Gateway as NodePort for local access + - target: + kind: Service + name: gateway + patch: | + - op: replace + path: /spec/type + value: NodePort + - op: add + path: /spec/ports/0/nodePort + value: 30080 + + # Redis as NodePort for redis-cli access from host + - target: + kind: Service + name: redis + patch: | + - op: replace + path: /spec/type + value: NodePort + - op: add + path: /spec/ports/0/nodePort + value: 30379 diff --git a/ctrl/k8s/k8s/overlays/onprem/kustomization.yaml b/ctrl/k8s/k8s/overlays/onprem/kustomization.yaml new file mode 100644 index 0000000..f3668a1 --- /dev/null +++ b/ctrl/k8s/k8s/overlays/onprem/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + # Gateway as LoadBalancer — MetalLB assigns a LAN IP + - target: + kind: Service + name: gateway + patch: | + - op: replace + path: /spec/type + value: LoadBalancer diff --git a/ctrl/k8s/kind-config.yaml b/ctrl/k8s/kind-config.yaml new file mode 100644 index 0000000..e05ad0f --- /dev/null +++ b/ctrl/k8s/kind-config.yaml @@ -0,0 +1,15 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: mpr +nodes: + - role: control-plane + extraPortMappings: + # Gateway → http://k8s.mpr.local.ar (bind to 127.0.0.2 to avoid conflict with docker-compose on 127.0.0.1:80) + - containerPort: 30080 + hostPort: 80 + listenAddress: "127.0.0.2" + protocol: TCP + # Redis + - containerPort: 30379 + hostPort: 6382 + protocol: TCP diff --git a/ctrl/k8s/overlays/cloud/kustomization.yaml b/ctrl/k8s/overlays/cloud/kustomization.yaml new file mode 100644 index 0000000..7291ef7 --- /dev/null +++ b/ctrl/k8s/overlays/cloud/kustomization.yaml @@ -0,0 +1,20 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + # Gateway as cloud LoadBalancer + - target: + kind: Service + name: gateway + patch: | + - op: replace + path: /spec/type + value: LoadBalancer + - op: add + path: /metadata/annotations + value: + service.beta.kubernetes.io/aws-load-balancer-type: nlb + service.beta.kubernetes.io/aws-load-balancer-scheme: internal diff --git a/ctrl/k8s/overlays/dev/kustomization.yaml b/ctrl/k8s/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..6dddd62 --- /dev/null +++ b/ctrl/k8s/overlays/dev/kustomization.yaml @@ -0,0 +1,30 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + # Gateway as NodePort for local access + - target: + kind: Service + name: gateway + patch: | + - op: replace + path: /spec/type + value: NodePort + - op: add + path: /spec/ports/0/nodePort + value: 30080 + + # Redis as NodePort for redis-cli access from host + - target: + kind: Service + name: redis + patch: | + - op: replace + path: /spec/type + value: NodePort + - op: add + path: /spec/ports/0/nodePort + value: 30379 diff --git a/ctrl/k8s/overlays/onprem/kustomization.yaml b/ctrl/k8s/overlays/onprem/kustomization.yaml new file mode 100644 index 0000000..f3668a1 --- /dev/null +++ b/ctrl/k8s/overlays/onprem/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + # Gateway as LoadBalancer — MetalLB assigns a LAN IP + - target: + kind: Service + name: gateway + patch: | + - op: replace + path: /spec/type + value: LoadBalancer diff --git a/detect/profiles/__init__.py b/detect/profiles/__init__.py new file mode 100644 index 0000000..e4664dd --- /dev/null +++ b/detect/profiles/__init__.py @@ -0,0 +1,23 @@ +from .base import ( + ContentTypeProfile, + BrandDictionary, + CropContext, + DetectionConfig, + FrameExtractionConfig, + OCRConfig, + ResolverConfig, + SceneFilterConfig, +) +from .soccer import SoccerBroadcastProfile + +__all__ = [ + "ContentTypeProfile", + "BrandDictionary", + "CropContext", + "DetectionConfig", + "FrameExtractionConfig", + "OCRConfig", + "ResolverConfig", + "SceneFilterConfig", + "SoccerBroadcastProfile", +] diff --git a/detect/profiles/base.py b/detect/profiles/base.py new file mode 100644 index 0000000..81c386d --- /dev/null +++ b/detect/profiles/base.py @@ -0,0 +1,71 @@ +""" +ContentTypeProfile protocol and config dataclasses. + +The pipeline graph is fixed — what varies per content type is configuration +and hooks. Each profile provides stage configs, a brand dictionary, +VLM prompt templates, and an aggregation strategy. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + +from detect.models import BrandDetection, DetectionReport + + +@dataclass +class FrameExtractionConfig: + fps: float = 2.0 + max_frames: int = 500 + + +@dataclass +class SceneFilterConfig: + hamming_threshold: int = 8 + enabled: bool = True + + +@dataclass +class DetectionConfig: + model_name: str = "yolov8n.pt" + confidence_threshold: float = 0.3 + target_classes: list[str] = field(default_factory=lambda: ["logo", "text"]) + + +@dataclass +class OCRConfig: + languages: list[str] = field(default_factory=lambda: ["en"]) + min_confidence: float = 0.5 + + +@dataclass +class ResolverConfig: + fuzzy_threshold: int = 75 + + +@dataclass +class BrandDictionary: + """Maps canonical brand name → list of known aliases/spellings.""" + brands: dict[str, list[str]] = field(default_factory=dict) + + +@dataclass +class CropContext: + image: bytes + surrounding_text: str = "" + position_hint: str = "" + + +class ContentTypeProfile(Protocol): + name: str + + def frame_extraction_config(self) -> FrameExtractionConfig: ... + def scene_filter_config(self) -> SceneFilterConfig: ... + def detection_config(self) -> DetectionConfig: ... + def ocr_config(self) -> OCRConfig: ... + def brand_dictionary(self) -> BrandDictionary: ... + def resolver_config(self) -> ResolverConfig: ... + def vlm_prompt(self, crop_context: CropContext) -> str: ... + def aggregate(self, detections: list[BrandDetection]) -> DetectionReport: ... + def auxiliary_detections(self, source: str) -> list[BrandDetection]: ... diff --git a/detect/profiles/soccer.py b/detect/profiles/soccer.py new file mode 100644 index 0000000..66e2704 --- /dev/null +++ b/detect/profiles/soccer.py @@ -0,0 +1,92 @@ +"""Soccer broadcast profile — pitch hoardings, kits, scoreboards.""" + +from __future__ import annotations + +from detect.models import BrandDetection, BrandStats, DetectionReport, PipelineStats + +from .base import ( + BrandDictionary, + CropContext, + DetectionConfig, + FrameExtractionConfig, + OCRConfig, + ResolverConfig, + SceneFilterConfig, +) + + +class SoccerBroadcastProfile: + name = "soccer_broadcast" + + def frame_extraction_config(self) -> FrameExtractionConfig: + return FrameExtractionConfig(fps=2.0, max_frames=500) + + def scene_filter_config(self) -> SceneFilterConfig: + return SceneFilterConfig(hamming_threshold=8, enabled=True) + + def detection_config(self) -> DetectionConfig: + return DetectionConfig( + model_name="yolov8n.pt", + confidence_threshold=0.3, + target_classes=["logo", "text", "banner", "scoreboard"], + ) + + def ocr_config(self) -> OCRConfig: + return OCRConfig(languages=["en", "es"], min_confidence=0.5) + + def brand_dictionary(self) -> BrandDictionary: + return BrandDictionary(brands={ + "Nike": ["nike", "NIKE", "swoosh"], + "Adidas": ["adidas", "ADIDAS", "adi"], + "Puma": ["puma", "PUMA"], + "Emirates": ["emirates", "fly emirates", "EMIRATES"], + "Coca-Cola": ["coca-cola", "coca cola", "coke", "COCA-COLA"], + "Pepsi": ["pepsi", "PEPSI"], + "Mastercard": ["mastercard", "MASTERCARD"], + "Heineken": ["heineken", "HEINEKEN"], + "Santander": ["santander", "SANTANDER"], + "Gazprom": ["gazprom", "GAZPROM"], + "Qatar Airways": ["qatar airways", "QATAR AIRWAYS"], + "Lay's": ["lays", "lay's", "LAYS", "LAY'S"], + }) + + def resolver_config(self) -> ResolverConfig: + return ResolverConfig(fuzzy_threshold=75) + + def vlm_prompt(self, crop_context: CropContext) -> str: + hint = f" Position: {crop_context.position_hint}." if crop_context.position_hint else "" + text = f" Nearby text: '{crop_context.surrounding_text}'." if crop_context.surrounding_text else "" + return ( + f"Identify the brand or sponsor visible in this cropped region " + f"from a soccer broadcast.{hint}{text} " + f"Respond with: brand, confidence (0-1), reasoning." + ) + + def aggregate(self, detections: list[BrandDetection]) -> DetectionReport: + brands: dict[str, BrandStats] = {} + for d in detections: + if d.brand not in brands: + brands[d.brand] = BrandStats() + s = brands[d.brand] + s.total_appearances += 1 + s.total_screen_time += d.duration + s.avg_confidence = ( + (s.avg_confidence * (s.total_appearances - 1) + d.confidence) + / s.total_appearances + ) + if s.first_seen == 0.0 or d.timestamp < s.first_seen: + s.first_seen = d.timestamp + if d.timestamp > s.last_seen: + s.last_seen = d.timestamp + + return DetectionReport( + video_source="", + content_type=self.name, + duration_seconds=0.0, + brands=brands, + timeline=sorted(detections, key=lambda d: d.timestamp), + pipeline_stats=PipelineStats(), + ) + + def auxiliary_detections(self, source: str) -> list[BrandDetection]: + return [] diff --git a/detect/profiles/stubs.py b/detect/profiles/stubs.py new file mode 100644 index 0000000..9ccd489 --- /dev/null +++ b/detect/profiles/stubs.py @@ -0,0 +1,108 @@ +"""Stub profiles — interfaces defined, not yet implemented.""" + +from __future__ import annotations + +from detect.models import BrandDetection, DetectionReport + +from .base import ( + BrandDictionary, + CropContext, + DetectionConfig, + FrameExtractionConfig, + OCRConfig, + ResolverConfig, + SceneFilterConfig, +) + + +class NewsBroadcastProfile: + name = "news_broadcast" + + def frame_extraction_config(self) -> FrameExtractionConfig: + raise NotImplementedError + + def scene_filter_config(self) -> SceneFilterConfig: + raise NotImplementedError + + def detection_config(self) -> DetectionConfig: + raise NotImplementedError + + def ocr_config(self) -> OCRConfig: + raise NotImplementedError + + def brand_dictionary(self) -> BrandDictionary: + raise NotImplementedError + + def resolver_config(self) -> ResolverConfig: + raise NotImplementedError + + def vlm_prompt(self, crop_context: CropContext) -> str: + raise NotImplementedError + + def aggregate(self, detections: list[BrandDetection]) -> DetectionReport: + raise NotImplementedError + + def auxiliary_detections(self, source: str) -> list[BrandDetection]: + raise NotImplementedError + + +class AdvertisingProfile: + name = "advertising" + + def frame_extraction_config(self) -> FrameExtractionConfig: + raise NotImplementedError + + def scene_filter_config(self) -> SceneFilterConfig: + raise NotImplementedError + + def detection_config(self) -> DetectionConfig: + raise NotImplementedError + + def ocr_config(self) -> OCRConfig: + raise NotImplementedError + + def brand_dictionary(self) -> BrandDictionary: + raise NotImplementedError + + def resolver_config(self) -> ResolverConfig: + raise NotImplementedError + + def vlm_prompt(self, crop_context: CropContext) -> str: + raise NotImplementedError + + def aggregate(self, detections: list[BrandDetection]) -> DetectionReport: + raise NotImplementedError + + def auxiliary_detections(self, source: str) -> list[BrandDetection]: + raise NotImplementedError + + +class TranscriptProfile: + name = "transcript" + + def frame_extraction_config(self) -> FrameExtractionConfig: + raise NotImplementedError + + def scene_filter_config(self) -> SceneFilterConfig: + raise NotImplementedError + + def detection_config(self) -> DetectionConfig: + raise NotImplementedError + + def ocr_config(self) -> OCRConfig: + raise NotImplementedError + + def brand_dictionary(self) -> BrandDictionary: + raise NotImplementedError + + def resolver_config(self) -> ResolverConfig: + raise NotImplementedError + + def vlm_prompt(self, crop_context: CropContext) -> str: + raise NotImplementedError + + def aggregate(self, detections: list[BrandDetection]) -> DetectionReport: + raise NotImplementedError + + def auxiliary_detections(self, source: str) -> list[BrandDetection]: + raise NotImplementedError diff --git a/tests/detect/test_profiles.py b/tests/detect/test_profiles.py new file mode 100644 index 0000000..c1817e5 --- /dev/null +++ b/tests/detect/test_profiles.py @@ -0,0 +1,73 @@ +"""Tests for ContentTypeProfile implementations.""" + +import pytest + +from detect.models import BrandDetection +from detect.profiles.base import ContentTypeProfile, CropContext +from detect.profiles.soccer import SoccerBroadcastProfile +from detect.profiles.stubs import AdvertisingProfile, NewsBroadcastProfile, TranscriptProfile + + +def test_soccer_satisfies_protocol(): + profile: ContentTypeProfile = SoccerBroadcastProfile() + assert profile.name == "soccer_broadcast" + + +def test_soccer_frame_extraction_config(): + cfg = SoccerBroadcastProfile().frame_extraction_config() + assert cfg.fps > 0 + assert cfg.max_frames > 0 + + +def test_soccer_detection_config(): + cfg = SoccerBroadcastProfile().detection_config() + assert 0 < cfg.confidence_threshold < 1 + assert len(cfg.target_classes) > 0 + + +def test_soccer_brand_dictionary_non_empty(): + bd = SoccerBroadcastProfile().brand_dictionary() + assert len(bd.brands) > 0 + for canonical, aliases in bd.brands.items(): + assert len(aliases) > 0 + + +def test_soccer_vlm_prompt(): + ctx = CropContext(image=b"fake", surrounding_text="Emirates", position_hint="top-center") + prompt = SoccerBroadcastProfile().vlm_prompt(ctx) + assert "brand" in prompt.lower() + assert "Emirates" in prompt + + +def test_soccer_aggregate_empty(): + report = SoccerBroadcastProfile().aggregate([]) + assert len(report.brands) == 0 + assert len(report.timeline) == 0 + + +def test_soccer_aggregate_groups(): + detections = [ + BrandDetection(brand="Nike", timestamp=1.0, duration=0.5, confidence=0.9, source="ocr"), + BrandDetection(brand="Nike", timestamp=2.0, duration=0.5, confidence=0.8, source="ocr"), + BrandDetection(brand="Adidas", timestamp=3.0, duration=0.5, confidence=0.7, source="logo_match"), + ] + report = SoccerBroadcastProfile().aggregate(detections) + assert "Nike" in report.brands + assert "Adidas" in report.brands + assert report.brands["Nike"].total_appearances == 2 + assert report.brands["Adidas"].total_appearances == 1 + assert report.timeline == sorted(report.timeline, key=lambda d: d.timestamp) + + +def test_soccer_auxiliary_returns_empty(): + assert SoccerBroadcastProfile().auxiliary_detections("test.mp4") == [] + + +@pytest.mark.parametrize("stub_cls", [NewsBroadcastProfile, AdvertisingProfile, TranscriptProfile]) +def test_stubs_raise(stub_cls): + stub = stub_cls() + assert isinstance(stub.name, str) + with pytest.raises(NotImplementedError): + stub.frame_extraction_config() + with pytest.raises(NotImplementedError): + stub.brand_dictionary() diff --git a/ui/detection-app/src/App.vue b/ui/detection-app/src/App.vue index b487462..bf64bc3 100644 --- a/ui/detection-app/src/App.vue +++ b/ui/detection-app/src/App.vue @@ -1,12 +1,13 @@ + + + + diff --git a/ui/framework/src/components/Panel.vue b/ui/framework/src/components/Panel.vue new file mode 100644 index 0000000..17a2609 --- /dev/null +++ b/ui/framework/src/components/Panel.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/ui/framework/src/index.ts b/ui/framework/src/index.ts index b221ea6..1906aff 100644 --- a/ui/framework/src/index.ts +++ b/ui/framework/src/index.ts @@ -3,3 +3,7 @@ export { DataSource, type DataSourceStatus } from './datasources/DataSource' export { SSEDataSource } from './datasources/SSEDataSource' export { StaticDataSource } from './datasources/StaticDataSource' export { useDataSource } from './composables/useDataSource' + +// Components +export { default as Panel } from './components/Panel.vue' +export { default as LayoutGrid } from './components/LayoutGrid.vue' diff --git a/ui/framework/src/tokens.css b/ui/framework/src/tokens.css new file mode 100644 index 0000000..d719c20 --- /dev/null +++ b/ui/framework/src/tokens.css @@ -0,0 +1,45 @@ +/* Framework design tokens — retheme by replacing this file */ +:root { + /* spacing scale (4px base) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + + /* color — dark theme (observability UIs are always dark) */ + --surface-0: #0d0d0f; + --surface-1: #16161a; + --surface-2: #1e1e24; + --surface-3: #26262f; + --border: #2e2e38; + + --text-primary: #e8e8f0; + --text-secondary: #8888a0; + --text-dim: #555568; + + /* status colors */ + --status-idle: #555568; + --status-live: #3ecf8e; + --status-processing: #4f9cf9; + --status-escalating: #f5a623; + --status-error: #f06565; + + /* confidence color scale (low → high) */ + --conf-low: #f06565; + --conf-mid: #f5a623; + --conf-high: #3ecf8e; + + /* typography */ + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --font-ui: 'Inter', system-ui, sans-serif; + --font-size-sm: 11px; + --font-size-base: 13px; + --font-size-lg: 15px; + + /* panel chrome */ + --panel-radius: 6px; + --panel-border: 1px solid var(--border); + --panel-header-height: 36px; +}