phase 1
This commit is contained in:
20
core/api/detect/__init__.py
Normal file
20
core/api/detect/__init__.py
Normal 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
105
core/api/detect/config.py
Normal 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
363
core/api/detect/replay.py
Normal 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
156
core/api/detect/run.py
Normal 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
108
core/api/detect/sources.py
Normal 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
79
core/api/detect/sse.py
Normal 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",
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user