This commit is contained in:
2026-03-28 08:46:06 -03:00
parent acc99e691d
commit 0bd3888155
30 changed files with 390 additions and 1044 deletions

View File

@@ -0,0 +1,20 @@
"""
Detection API — aggregated router.
Combines all detect sub-routers into a single include for main.py.
"""
from fastapi import APIRouter
from .sources import router as sources_router
from .run import router as run_router
from .sse import router as sse_router
from .replay import router as replay_router
from .config import router as config_router
router = APIRouter()
router.include_router(sources_router)
router.include_router(run_router)
router.include_router(sse_router)
router.include_router(replay_router)
router.include_router(config_router)

105
core/api/detect/config.py Normal file
View File

@@ -0,0 +1,105 @@
"""
Runtime config endpoint for the detection pipeline.
GET /detect/config — read current config
PUT /detect/config — update config (takes effect on next run)
GET /detect/config/stages — list stage palette with config fields
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/detect", tags=["detect"])
# In-memory config — persists until server restart.
# Phase 12+ moves this to DB.
_runtime_config: dict = {}
class ConfigUpdate(BaseModel):
detection: dict | None = None
ocr: dict | None = None
resolver: dict | None = None
escalation: dict | None = None
preprocessing: dict | None = None
class StageConfigInfo(BaseModel):
name: str
label: str
description: str
category: str
config_fields: list[dict]
reads: list[str]
writes: list[str]
@router.get("/config")
def read_config():
return _runtime_config
@router.put("/config")
def write_config(update: ConfigUpdate):
changes = update.model_dump(exclude_none=True)
for section, values in changes.items():
if section not in _runtime_config:
_runtime_config[section] = {}
_runtime_config[section].update(values)
logger.info("Config updated: %s", list(changes.keys()))
return _runtime_config
@router.get("/config/stages", response_model=list[StageConfigInfo])
def list_stage_configs():
"""Return the stage palette with config field metadata for the editor."""
from detect.stages import list_stages
result = []
for stage in list_stages():
info = _stage_to_info(stage)
result.append(info)
return result
@router.get("/config/stages/{stage_name}", response_model=StageConfigInfo)
def get_stage_config(stage_name: str):
"""Return config field metadata for a single stage."""
from detect.stages import get_stage
try:
stage = get_stage(stage_name)
except KeyError:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Unknown stage: {stage_name}")
return _stage_to_info(stage)
def _stage_to_info(stage) -> StageConfigInfo:
return StageConfigInfo(
name=stage.name,
label=stage.label,
description=stage.description,
category=stage.category,
config_fields=[
{
"name": f.name,
"type": f.type,
"default": f.default,
"description": f.description,
"min": f.min,
"max": f.max,
"options": f.options,
}
for f in stage.config_fields
],
reads=stage.io.reads,
writes=stage.io.writes,
)

363
core/api/detect/replay.py Normal file
View File

@@ -0,0 +1,363 @@
"""
API endpoints for checkpoint inspection, replay, retry, and GPU proxy.
GET /detect/checkpoints/{timeline_id} — list available checkpoints
POST /detect/replay — replay from a stage with config overrides
POST /detect/retry — queue async retry with different provider
POST /detect/replay-stage — replay single stage (fast path)
POST /detect/gpu/detect_edges — proxy to GPU inference server
POST /detect/gpu/detect_edges/debug — proxy with debug overlays
"""
from __future__ import annotations
import logging
import os
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/detect", tags=["detect"])
# --- Request/Response models ---
class CheckpointInfo(BaseModel):
stage: str
is_scenario: bool = False
scenario_label: str = ""
class ScenarioInfo(BaseModel):
timeline_id: str
stage: str
scenario_label: str
profile_name: str
video_path: str
frame_count: int = 0
created_at: str = ""
class ReplayRequest(BaseModel):
timeline_id: str
start_stage: str
config_overrides: dict | None = None
class ReplayResponse(BaseModel):
status: str
timeline_id: str
start_stage: str
detections: int = 0
brands_found: int = 0
class RetryRequest(BaseModel):
timeline_id: str
config_overrides: dict | None = None
start_stage: str = "escalate_vlm"
schedule_seconds: float | None = None # delay before execution (off-peak)
class RetryResponse(BaseModel):
status: str
task_id: str
timeline_id: str
class ReplaySingleStageRequest(BaseModel):
timeline_id: str
stage: str
frame_refs: list[int] | None = None
config_overrides: dict | None = None
debug: bool = False
class ReplaySingleStageBox(BaseModel):
x: int
y: int
w: int
h: int
confidence: float
label: str
class FrameDebugOverlays(BaseModel):
edge_overlay_b64: str = ""
lines_overlay_b64: str = ""
horizontal_count: int = 0
pair_count: int = 0
class ReplaySingleStageResponse(BaseModel):
status: str
stage: str
frame_count: int = 0
region_count: int = 0
regions_by_frame: dict[str, list[ReplaySingleStageBox]] = {}
debug: dict[str, FrameDebugOverlays] = {} # keyed by frame seq
# --- Endpoints ---
@router.get("/checkpoints/{timeline_id}")
def list_checkpoints(timeline_id: str) -> list[CheckpointInfo]:
"""List available checkpoint stages for a job."""
from detect.checkpoint import list_checkpoints as _list
try:
stages = _list(timeline_id)
except Exception as e:
raise HTTPException(status_code=404, detail=f"No checkpoints for job {timeline_id}: {e}")
result = [CheckpointInfo(stage=s) for s in stages]
return result
class CheckpointFrameInfo(BaseModel):
seq: int
timestamp: float
jpeg_b64: str
class CheckpointData(BaseModel):
timeline_id: str
stage: str
profile_name: str
video_path: str
is_scenario: bool
scenario_label: str
frames: list[CheckpointFrameInfo]
stats: dict = {}
config_snapshot: dict = {}
stage_output_key: str = ""
@router.get("/checkpoints/{timeline_id}/{stage}", response_model=CheckpointData)
def get_checkpoint_data(timeline_id: str, stage: str):
"""Load checkpoint frames + metadata for the editor UI."""
from uuid import UUID
from core.db.tables import Timeline, Checkpoint
from core.db.connection import get_session
from core.db.checkpoint import list_checkpoints
from detect.checkpoint.frames import load_frames_b64
with get_session() as session:
timeline = session.get(Timeline, UUID(timeline_id))
if not timeline:
raise HTTPException(status_code=404, detail=f"Timeline not found: {timeline_id}")
checkpoints = list_checkpoints(session, UUID(timeline_id))
if not checkpoints:
raise HTTPException(status_code=404, detail=f"No checkpoints for timeline {timeline_id}")
# Prefer a checkpoint that has this stage's output; fall back to latest
checkpoint = next(
(c for c in reversed(checkpoints) if stage in (c.stage_outputs or {})),
checkpoints[-1],
)
raw_manifest = timeline.frames_manifest or {}
manifest = {int(k): v for k, v in raw_manifest.items()}
frames_b64 = load_frames_b64(manifest, timeline.frames_meta or [])
frame_list = [
CheckpointFrameInfo(seq=f["seq"], timestamp=f["timestamp"], jpeg_b64=f["jpeg_b64"])
for f in frames_b64
]
return CheckpointData(
timeline_id=timeline_id,
stage=stage,
profile_name=timeline.profile_name,
video_path=timeline.source_video,
is_scenario=checkpoint.is_scenario,
scenario_label=checkpoint.scenario_label,
frames=frame_list,
stats=checkpoint.stats or {},
config_snapshot=checkpoint.config_overrides or {},
stage_output_key=stage,
)
@router.get("/scenarios", response_model=list[ScenarioInfo])
def list_scenarios_endpoint():
"""List all available scenarios (bookmarked checkpoints)."""
from core.db.tables import Timeline
from core.db.connection import get_session
from core.db.checkpoint import list_scenarios
with get_session() as session:
scenarios = list_scenarios(session)
result = []
for s in scenarios:
timeline = session.get(Timeline, s.timeline_id)
if not timeline:
continue
last_stage = next(reversed(s.stage_outputs), "") if s.stage_outputs else ""
info = ScenarioInfo(
timeline_id=str(s.timeline_id),
stage=last_stage,
scenario_label=s.scenario_label,
profile_name=timeline.profile_name,
video_path=timeline.source_video,
frame_count=len(timeline.frames_manifest or {}),
created_at=str(s.created_at) if s.created_at else "",
)
result.append(info)
return result
@router.post("/replay", response_model=ReplayResponse)
def replay(req: ReplayRequest):
"""Replay pipeline from a specific stage with optional config overrides."""
from detect.checkpoint import replay_from
try:
result = replay_from(
timeline_id=req.timeline_id,
start_stage=req.start_stage,
config_overrides=req.config_overrides,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Replay failed: {e}")
detections = result.get("detections", [])
report = result.get("report")
brands_found = len(report.brands) if report else 0
response = ReplayResponse(
status="completed",
timeline_id=req.timeline_id,
start_stage=req.start_stage,
detections=len(detections),
brands_found=brands_found,
)
return response
@router.post("/retry", response_model=RetryResponse)
def retry(req: RetryRequest):
"""Queue an async retry of unresolved candidates with different config."""
from detect.checkpoint.tasks import retry_candidates
kwargs = {
"timeline_id": req.timeline_id,
"config_overrides": req.config_overrides,
"start_stage": req.start_stage,
}
if req.schedule_seconds:
task = retry_candidates.apply_async(kwargs=kwargs, countdown=req.schedule_seconds)
else:
task = retry_candidates.delay(**kwargs)
response = RetryResponse(
status="queued",
task_id=task.id,
timeline_id=req.timeline_id,
)
return response
@router.post("/replay-stage", response_model=ReplaySingleStageResponse)
def replay_single_stage(req: ReplaySingleStageRequest):
"""Replay a single stage on specific frames — fast path for interactive tuning."""
from detect.checkpoint.replay import replay_single_stage as _replay
try:
result = _replay(
timeline_id=req.timeline_id,
stage=req.stage,
frame_refs=req.frame_refs,
config_overrides=req.config_overrides,
debug=req.debug,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Single-stage replay failed: {e}")
# Convert result to response format
regions_by_frame = result.get("edge_regions_by_frame", {})
total_regions = 0
serialized = {}
for seq, boxes in regions_by_frame.items():
box_list = []
for b in boxes:
box = ReplaySingleStageBox(
x=b.x, y=b.y, w=b.w, h=b.h,
confidence=b.confidence, label=b.label,
)
box_list.append(box)
serialized[str(seq)] = box_list
total_regions += len(box_list)
# Serialize debug overlays if present
debug_out = {}
raw_debug = result.get("debug", {})
for seq, d in raw_debug.items():
debug_out[str(seq)] = FrameDebugOverlays(
edge_overlay_b64=d.get("edge_overlay_b64", ""),
lines_overlay_b64=d.get("lines_overlay_b64", ""),
horizontal_count=d.get("horizontal_count", 0),
pair_count=d.get("pair_count", 0),
)
return ReplaySingleStageResponse(
status="completed",
stage=req.stage,
frame_count=len(regions_by_frame),
region_count=total_regions,
regions_by_frame=serialized,
debug=debug_out,
)
# --- GPU proxy — thin passthrough to inference server for interactive editor ---
def _gpu_url() -> str:
url = os.environ.get("INFERENCE_URL", "http://localhost:8000")
return url.rstrip("/")
@router.post("/gpu/detect_edges")
async def gpu_detect_edges(request: Request):
"""Proxy to GPU inference server — browser can't reach it directly."""
import httpx
body = await request.body()
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{_gpu_url()}/detect_edges",
content=body,
headers={"Content-Type": "application/json"},
)
return Response(content=resp.content, status_code=resp.status_code,
media_type="application/json")
except Exception as e:
raise HTTPException(status_code=502, detail=f"GPU server unreachable: {e}")
@router.post("/gpu/detect_edges/debug")
async def gpu_detect_edges_debug(request: Request):
"""Proxy to GPU inference server debug endpoint."""
import httpx
body = await request.body()
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{_gpu_url()}/detect_edges/debug",
content=body,
headers={"Content-Type": "application/json"},
)
return Response(content=resp.content, status_code=resp.status_code,
media_type="application/json")
except Exception as e:
raise HTTPException(status_code=502, detail=f"GPU server unreachable: {e}")

156
core/api/detect/run.py Normal file
View File

@@ -0,0 +1,156 @@
"""
Pipeline run endpoints.
POST /detect/run — launch pipeline on selected source
POST /detect/stop/{job_id} — cancel a running pipeline
POST /detect/clear/{job_id} — clear events from Redis
"""
from __future__ import annotations
import logging
import os
import threading
import uuid
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/detect", tags=["detect"])
# In-process pipeline tracking
_running_jobs: dict[str, threading.Thread] = {}
_cancelled_jobs: set[str] = set()
class RunRequest(BaseModel):
video_path: str # storage key
profile_name: str = "soccer_broadcast"
source_asset_id: str = ""
checkpoint: bool = True
skip_vlm: bool = False
skip_cloud: bool = False
log_level: str = "INFO" # INFO | DEBUG
class RunResponse(BaseModel):
status: str
job_id: str
video_path: str
def _resolve_video_path(video_path: str) -> str:
"""Download a chunk from blob storage to a temp file."""
from core.storage.blob import get_store
store = get_store("out")
try:
return store.download_to_temp(video_path)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to download chunk: {e}")
@router.post("/run", response_model=RunResponse)
def run_pipeline(req: RunRequest):
"""Launch a detection pipeline run on a source chunk."""
from detect import emit
from detect.graph import get_pipeline
from detect.state import DetectState
local_path = _resolve_video_path(req.video_path)
job_id = str(uuid.uuid4())
if req.skip_vlm:
os.environ["SKIP_VLM"] = "1"
elif "SKIP_VLM" in os.environ:
del os.environ["SKIP_VLM"]
if req.skip_cloud:
os.environ["SKIP_CLOUD"] = "1"
elif "SKIP_CLOUD" in os.environ:
del os.environ["SKIP_CLOUD"]
# Clear any stale events from a previous run with same job_id
from core.events import _get_redis
from detect.events import DETECT_EVENTS_PREFIX
r = _get_redis()
r.delete(f"{DETECT_EVENTS_PREFIX}:{job_id}")
emit.set_run_context(
run_id=job_id, parent_job_id=job_id, run_type="initial",
log_level=req.log_level,
)
pipeline = get_pipeline(checkpoint=req.checkpoint)
initial_state = DetectState(
video_path=local_path,
job_id=job_id,
profile_name=req.profile_name,
source_asset_id=req.source_asset_id,
)
from detect.graph import PipelineCancelled, set_cancel_check, clear_cancel_check
set_cancel_check(job_id, lambda: job_id in _cancelled_jobs)
def _run():
try:
emit.log(job_id, "Pipeline", "INFO",
f"Starting pipeline: {req.video_path} (profile={req.profile_name})")
pipeline.invoke(initial_state)
emit.log(job_id, "Pipeline", "INFO", "Pipeline completed successfully")
emit.job_complete(job_id, {"status": "completed"})
except PipelineCancelled:
emit.log(job_id, "Pipeline", "INFO", "Pipeline cancelled")
emit.job_complete(job_id, {"status": "cancelled"})
except Exception as e:
logger.exception("Pipeline run %s failed: %s", job_id, e)
from detect.graph import _node_states, NODES
if job_id in _node_states:
states = _node_states[job_id]
for node in reversed(NODES):
if states.get(node) in ("running", "done"):
states[node] = "error"
break
nodes = [{"id": n, "status": states[n]} for n in NODES]
emit.graph_update(job_id, nodes)
emit.log(job_id, "Pipeline", "ERROR", str(e))
emit.job_complete(job_id, {"status": "failed", "error": str(e)})
finally:
_running_jobs.pop(job_id, None)
_cancelled_jobs.discard(job_id)
clear_cancel_check(job_id)
emit.clear_run_context()
thread = threading.Thread(target=_run, daemon=True, name=f"pipeline-{job_id}")
_running_jobs[job_id] = thread
thread.start()
return RunResponse(status="started", job_id=job_id, video_path=req.video_path)
@router.post("/stop/{job_id}")
def stop_pipeline(job_id: str):
"""Stop a running pipeline. Signals cancellation; the thread checks on next stage."""
from detect import emit
if job_id not in _running_jobs:
raise HTTPException(status_code=404, detail=f"No running pipeline: {job_id}")
_cancelled_jobs.add(job_id)
emit.log(job_id, "Pipeline", "INFO", "Stop requested — cancelling after current stage")
return {"status": "stopping", "job_id": job_id}
@router.post("/clear/{job_id}")
def clear_pipeline(job_id: str):
"""Clear events for a job from Redis."""
from core.events import _get_redis
from detect.events import DETECT_EVENTS_PREFIX
r = _get_redis()
r.delete(f"{DETECT_EVENTS_PREFIX}:{job_id}")
return {"status": "cleared", "job_id": job_id}

108
core/api/detect/sources.py Normal file
View File

@@ -0,0 +1,108 @@
"""
Source browser for detection pipeline.
Lists available media sources from blob storage (MinIO).
GET /detect/sources — list chunk jobs
GET /detect/sources/{job_id}/chunks — list chunks for a job
GET /detect/sources/{job_id}/chunks/{name}/url — presigned preview URL
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/detect", tags=["detect"])
class ChunkInfoResponse(BaseModel):
filename: str
key: str
size_bytes: int
class SourceInfoResponse(BaseModel):
job_id: str
source_type: str = "chunk_job"
chunk_count: int
total_bytes: int = 0
def _list_sources() -> list[SourceInfoResponse]:
"""List chunk jobs from blob storage."""
from core.storage.blob import get_store
store = get_store("out")
try:
objects = store.list(prefix="chunks/")
except Exception as e:
logger.warning("Failed to list blob sources: %s", e)
return []
jobs: dict[str, int] = {}
job_bytes: dict[str, int] = {}
for obj in objects:
rel_key = obj.key.removeprefix(store.prefix)
parts = rel_key.split("/")
if len(parts) >= 3 and parts[0] == "chunks":
job_id = parts[1]
jobs[job_id] = jobs.get(job_id, 0) + 1
job_bytes[job_id] = job_bytes.get(job_id, 0) + obj.size_bytes
sources = []
for job_id, count in sorted(jobs.items()):
source = SourceInfoResponse(
job_id=job_id,
source_type="chunk_job",
chunk_count=count,
total_bytes=job_bytes.get(job_id, 0),
)
sources.append(source)
return sources
@router.get("/sources", response_model=list[SourceInfoResponse])
def list_sources():
"""List available chunk jobs from blob storage."""
return _list_sources()
@router.get("/sources/{source_job_id}/chunks", response_model=list[ChunkInfoResponse])
def list_chunks(source_job_id: str):
"""List chunks for a specific source job."""
from core.storage.blob import get_store
store = get_store("out")
try:
objects = store.list(prefix=f"chunks/{source_job_id}/", extensions={".mp4"})
except Exception as e:
logger.warning("Failed to list chunks for %s: %s", source_job_id, e)
raise HTTPException(status_code=503, detail=f"Blob storage unavailable: {e}")
if not objects:
raise HTTPException(status_code=404, detail=f"Source not found: {source_job_id}")
chunks = []
for obj in objects:
info = ChunkInfoResponse(filename=obj.filename, key=obj.key, size_bytes=obj.size_bytes)
chunks.append(info)
return sorted(chunks, key=lambda c: c.filename)
@router.get("/sources/{source_job_id}/chunks/{filename}/url")
def get_chunk_url(source_job_id: str, filename: str):
"""Return a presigned URL for previewing a chunk in the browser."""
from core.storage.blob import get_store
store = get_store("out")
key = f"chunks/{source_job_id}/{filename}"
try:
url = store.get_url(key, expires=3600)
except Exception as e:
raise HTTPException(status_code=503, detail=f"Could not generate URL: {e}")
return {"url": url}

79
core/api/detect/sse.py Normal file
View File

@@ -0,0 +1,79 @@
"""
SSE endpoint for detection pipeline events.
Uses Redis as the event bus between pipeline workers and the SSE stream.
Mirrors chunker_sse.py but polls detect_events:{job_id}.
GET /detect/stream/{job_id} → text/event-stream
"""
import asyncio
import json
import logging
import time
from typing import AsyncGenerator
from fastapi import APIRouter
from starlette.responses import StreamingResponse
from core.events import poll_events
from detect.events import DETECT_EVENTS_PREFIX, TERMINAL_EVENTS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/detect", tags=["detect"])
async def _event_generator(job_id: str) -> AsyncGenerator[str, None]:
cursor = 0
timeout = time.monotonic() + 3600 # 1 hour max
while time.monotonic() < timeout:
events, cursor = poll_events(job_id, cursor, prefix=DETECT_EVENTS_PREFIX)
if not events:
await asyncio.sleep(0.2)
continue
is_terminal = False
for data in events:
event_type = data.pop("event", "update")
payload = {**data, "job_id": job_id}
yield f"event: {event_type}\ndata: {json.dumps(payload)}\n\n"
if event_type in TERMINAL_EVENTS:
is_terminal = True
if is_terminal:
yield f"event: done\ndata: {json.dumps({'job_id': job_id})}\n\n"
# Don't return — keep connection alive so EventSource doesn't reconnect.
# Just idle until the client disconnects or timeout.
while time.monotonic() < timeout:
await asyncio.sleep(5)
return
await asyncio.sleep(0.05)
yield f"event: timeout\ndata: {json.dumps({'job_id': job_id})}\n\n"
@router.get("/stream/{job_id}")
async def stream_detect_job(job_id: str):
"""
SSE stream for a detection pipeline job.
The UI connects via native EventSource:
const es = new EventSource('/api/detect/stream/<job_id>');
es.addEventListener('graph_update', (e) => { ... });
es.addEventListener('detection', (e) => { ... });
"""
return StreamingResponse(
_event_generator(job_id),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)