phase cv 0
This commit is contained in:
@@ -64,7 +64,26 @@ def list_stage_configs():
|
|||||||
|
|
||||||
result = []
|
result = []
|
||||||
for stage in list_stages():
|
for stage in list_stages():
|
||||||
info = StageConfigInfo(
|
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,
|
name=stage.name,
|
||||||
label=stage.label,
|
label=stage.label,
|
||||||
description=stage.description,
|
description=stage.description,
|
||||||
@@ -84,5 +103,3 @@ def list_stage_configs():
|
|||||||
reads=stage.io.reads,
|
reads=stage.io.reads,
|
||||||
writes=stage.io.writes,
|
writes=stage.io.writes,
|
||||||
)
|
)
|
||||||
result.append(info)
|
|
||||||
return result
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
API endpoints for checkpoint inspection, replay, and retry.
|
API endpoints for checkpoint inspection, replay, retry, and GPU proxy.
|
||||||
|
|
||||||
GET /detect/checkpoints/{job_id} — list available checkpoints
|
GET /detect/checkpoints/{job_id} — list available checkpoints
|
||||||
POST /detect/replay — replay from a stage with config overrides
|
POST /detect/replay — replay from a stage with config overrides
|
||||||
POST /detect/retry — queue async retry with different provider
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -22,6 +26,18 @@ router = APIRouter(prefix="/detect", tags=["detect"])
|
|||||||
|
|
||||||
class CheckpointInfo(BaseModel):
|
class CheckpointInfo(BaseModel):
|
||||||
stage: str
|
stage: str
|
||||||
|
is_scenario: bool = False
|
||||||
|
scenario_label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioInfo(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
stage: str
|
||||||
|
scenario_label: str
|
||||||
|
profile_name: str
|
||||||
|
video_path: str
|
||||||
|
frame_count: int = 0
|
||||||
|
created_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ReplayRequest(BaseModel):
|
class ReplayRequest(BaseModel):
|
||||||
@@ -51,6 +67,39 @@ class RetryResponse(BaseModel):
|
|||||||
job_id: str
|
job_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReplaySingleStageRequest(BaseModel):
|
||||||
|
job_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 ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.get("/checkpoints/{job_id}")
|
@router.get("/checkpoints/{job_id}")
|
||||||
@@ -67,6 +116,28 @@ def list_checkpoints(job_id: str) -> list[CheckpointInfo]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scenarios", response_model=list[ScenarioInfo])
|
||||||
|
def list_scenarios_endpoint():
|
||||||
|
"""List all available scenarios (bookmarked checkpoints)."""
|
||||||
|
from core.db.detect import list_scenarios
|
||||||
|
|
||||||
|
scenarios = list_scenarios()
|
||||||
|
result = []
|
||||||
|
for s in scenarios:
|
||||||
|
manifest = s.frames_manifest or {}
|
||||||
|
info = ScenarioInfo(
|
||||||
|
job_id=str(s.job_id),
|
||||||
|
stage=s.stage,
|
||||||
|
scenario_label=s.scenario_label,
|
||||||
|
profile_name=s.profile_name,
|
||||||
|
video_path=s.video_path,
|
||||||
|
frame_count=len(manifest),
|
||||||
|
created_at=str(s.created_at) if s.created_at else "",
|
||||||
|
)
|
||||||
|
result.append(info)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/replay", response_model=ReplayResponse)
|
@router.post("/replay", response_model=ReplayResponse)
|
||||||
def replay(req: ReplayRequest):
|
def replay(req: ReplayRequest):
|
||||||
"""Replay pipeline from a specific stage with optional config overrides."""
|
"""Replay pipeline from a specific stage with optional config overrides."""
|
||||||
@@ -119,3 +190,103 @@ def retry(req: RetryRequest):
|
|||||||
job_id=req.job_id,
|
job_id=req.job_id,
|
||||||
)
|
)
|
||||||
return response
|
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(
|
||||||
|
job_id=req.job_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}")
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ def list_stage_checkpoints(job_id: UUID) -> list[str]:
|
|||||||
return list(session.exec(stmt).all())
|
return list(session.exec(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
|
def list_scenarios() -> list[StageCheckpoint]:
|
||||||
|
"""List all checkpoints marked as scenarios."""
|
||||||
|
with get_session() as session:
|
||||||
|
stmt = (
|
||||||
|
select(StageCheckpoint)
|
||||||
|
.where(StageCheckpoint.is_scenario == True)
|
||||||
|
.order_by(StageCheckpoint.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(session.exec(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
def delete_stage_checkpoints(job_id: UUID) -> None:
|
def delete_stage_checkpoints(job_id: UUID) -> None:
|
||||||
with get_session() as session:
|
with get_session() as session:
|
||||||
stmt = select(StageCheckpoint).where(StageCheckpoint.job_id == job_id)
|
stmt = select(StageCheckpoint).where(StageCheckpoint.job_id == job_id)
|
||||||
|
|||||||
@@ -193,15 +193,14 @@ class StageCheckpoint(SQLModel, table=True):
|
|||||||
frames_manifest: Dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=False, server_default='{}'))
|
frames_manifest: Dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=False, server_default='{}'))
|
||||||
frames_meta: List[str] = Field(default_factory=list, sa_column=Column(JSON, nullable=False, server_default='[]'))
|
frames_meta: List[str] = Field(default_factory=list, sa_column=Column(JSON, nullable=False, server_default='[]'))
|
||||||
filtered_frame_sequences: List[int] = Field(default_factory=list, sa_column=Column(JSON, nullable=False, server_default='[]'))
|
filtered_frame_sequences: List[int] = Field(default_factory=list, sa_column=Column(JSON, nullable=False, server_default='[]'))
|
||||||
boxes_by_frame: Dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=False, server_default='{}'))
|
stage_output_key: str = "" # s3 key: checkpoints/{job_id}/stages/{stage}.bson
|
||||||
text_candidates: List[str] = Field(default_factory=list, sa_column=Column(JSON, nullable=False, server_default='[]'))
|
|
||||||
unresolved_candidates: List[str] = Field(default_factory=list, sa_column=Column(JSON, nullable=False, server_default='[]'))
|
|
||||||
detections: List[str] = Field(default_factory=list, sa_column=Column(JSON, nullable=False, server_default='[]'))
|
|
||||||
stats: Dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=False, server_default='{}'))
|
stats: Dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=False, server_default='{}'))
|
||||||
config_snapshot: Dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=False, server_default='{}'))
|
config_snapshot: Dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=False, server_default='{}'))
|
||||||
config_overrides: Dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=False, server_default='{}'))
|
config_overrides: Dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON, nullable=False, server_default='{}'))
|
||||||
video_path: str = ""
|
video_path: str = ""
|
||||||
profile_name: str = ""
|
profile_name: str = ""
|
||||||
|
is_scenario: bool = False
|
||||||
|
scenario_label: str = ""
|
||||||
created_at: Optional[datetime] = Field(default_factory=datetime.utcnow)
|
created_at: Optional[datetime] = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
class KnownBrand(SQLModel, table=True):
|
class KnownBrand(SQLModel, table=True):
|
||||||
|
|||||||
@@ -40,6 +40,11 @@
|
|||||||
"target": "typescript",
|
"target": "typescript",
|
||||||
"output": "ui/detection-app/src/types/store-state.ts",
|
"output": "ui/detection-app/src/types/store-state.ts",
|
||||||
"include": ["ui_state_views"]
|
"include": ["ui_state_views"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "pydantic",
|
||||||
|
"output": "gpu/models/inference_contract.py",
|
||||||
|
"include": ["inference_views"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from .detect_jobs import (
|
|||||||
from .media import AssetStatus, MediaAsset
|
from .media import AssetStatus, MediaAsset
|
||||||
from .presets import BUILTIN_PRESETS, TranscodePreset
|
from .presets import BUILTIN_PRESETS, TranscodePreset
|
||||||
from .detect import DETECT_VIEWS # noqa: F401 — discovered by modelgen generic loader
|
from .detect import DETECT_VIEWS # noqa: F401 — discovered by modelgen generic loader
|
||||||
|
from .inference import INFERENCE_VIEWS # noqa: F401 — GPU inference server API types
|
||||||
from .ui_state import UI_STATE_VIEWS # noqa: F401 — UI store state types
|
from .ui_state import UI_STATE_VIEWS # noqa: F401 — UI store state types
|
||||||
from .views import ChunkEvent, ChunkOutputFile, PipelineStats, WorkerEvent
|
from .views import ChunkEvent, ChunkOutputFile, PipelineStats, WorkerEvent
|
||||||
from .sources import ChunkInfo, SourceJob, SourceType
|
from .sources import ChunkInfo, SourceJob, SourceType
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class BoundingBoxEvent:
|
|||||||
label: str
|
label: str
|
||||||
resolved_brand: Optional[str] = None
|
resolved_brand: Optional[str] = None
|
||||||
source: Optional[str] = None
|
source: Optional[str] = None
|
||||||
|
stage: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -85,6 +86,7 @@ class StatsUpdate:
|
|||||||
|
|
||||||
frames_extracted: int = 0
|
frames_extracted: int = 0
|
||||||
frames_after_scene_filter: int = 0
|
frames_after_scene_filter: int = 0
|
||||||
|
cv_regions_detected: int = 0
|
||||||
regions_detected: int = 0
|
regions_detected: int = 0
|
||||||
regions_resolved_by_ocr: int = 0
|
regions_resolved_by_ocr: int = 0
|
||||||
regions_escalated_to_local_vlm: int = 0
|
regions_escalated_to_local_vlm: int = 0
|
||||||
@@ -166,6 +168,8 @@ class CheckpointInfo:
|
|||||||
"""Available checkpoint for a stage."""
|
"""Available checkpoint for a stage."""
|
||||||
|
|
||||||
stage: str
|
stage: str
|
||||||
|
is_scenario: bool = False
|
||||||
|
scenario_label: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -93,13 +93,12 @@ class StageCheckpoint:
|
|||||||
frames_meta: List[Dict[str, Any]] = field(default_factory=list) # sequence, chunk_id, timestamp, hash
|
frames_meta: List[Dict[str, Any]] = field(default_factory=list) # sequence, chunk_id, timestamp, hash
|
||||||
filtered_frame_sequences: List[int] = field(default_factory=list)
|
filtered_frame_sequences: List[int] = field(default_factory=list)
|
||||||
|
|
||||||
# Detection state (full structured data, not just summaries)
|
# Stage output — stored as blob in MinIO: checkpoints/{job_id}/stages/{stage}.bson
|
||||||
boxes_by_frame: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict)
|
# Each stage's serialize_fn/deserialize_fn owns the format.
|
||||||
text_candidates: List[Dict[str, Any]] = field(default_factory=list)
|
# Postgres only stores the S3 key, not the data itself.
|
||||||
unresolved_candidates: List[Dict[str, Any]] = field(default_factory=list)
|
stage_output_key: str = "" # s3 key to the serialized stage output
|
||||||
detections: List[Dict[str, Any]] = field(default_factory=list)
|
|
||||||
|
|
||||||
# Pipeline state
|
# Pipeline state (small, stays in Postgres)
|
||||||
stats: Dict[str, Any] = field(default_factory=dict)
|
stats: Dict[str, Any] = field(default_factory=dict)
|
||||||
config_snapshot: Dict[str, Any] = field(default_factory=dict)
|
config_snapshot: Dict[str, Any] = field(default_factory=dict)
|
||||||
config_overrides: Dict[str, Any] = field(default_factory=dict)
|
config_overrides: Dict[str, Any] = field(default_factory=dict)
|
||||||
@@ -108,6 +107,13 @@ class StageCheckpoint:
|
|||||||
video_path: str = ""
|
video_path: str = ""
|
||||||
profile_name: str = ""
|
profile_name: str = ""
|
||||||
|
|
||||||
|
# Scenario — a checkpoint bookmarked for the editor workflow.
|
||||||
|
# Created by seeders (manual scripts that populate state from real footage)
|
||||||
|
# or captured from a running pipeline. Loaded via URL:
|
||||||
|
# /detection/?job=<job_id>&stage=<stage>&editor=true
|
||||||
|
is_scenario: bool = False
|
||||||
|
scenario_label: str = "" # human-readable name, e.g. "chelsea_edges_lowcanny"
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class BrandStats:
|
|||||||
class PipelineStats:
|
class PipelineStats:
|
||||||
frames_extracted: int = 0
|
frames_extracted: int = 0
|
||||||
frames_after_scene_filter: int = 0
|
frames_after_scene_filter: int = 0
|
||||||
|
cv_regions_detected: int = 0
|
||||||
regions_detected: int = 0
|
regions_detected: int = 0
|
||||||
regions_resolved_by_ocr: int = 0
|
regions_resolved_by_ocr: int = 0
|
||||||
regions_escalated_to_local_vlm: int = 0
|
regions_escalated_to_local_vlm: int = 0
|
||||||
|
|||||||
197
core/schema/models/inference.py
Normal file
197
core/schema/models/inference.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""
|
||||||
|
Inference Server API Schema Definitions
|
||||||
|
|
||||||
|
Source of truth for GPU inference server request/response types.
|
||||||
|
Generates: Pydantic (gpu/models/inference_contract.py)
|
||||||
|
|
||||||
|
These are the wire-format types for the HTTP API between the
|
||||||
|
pipeline (detect/) and the inference server (gpu/).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# --- Object Detection (YOLO) ---
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DetectRequest:
|
||||||
|
"""Request body for object detection."""
|
||||||
|
|
||||||
|
image: str # base64 JPEG
|
||||||
|
model: Optional[str] = None
|
||||||
|
confidence: Optional[float] = None
|
||||||
|
target_classes: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BBox:
|
||||||
|
"""A detected bounding box."""
|
||||||
|
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
w: int
|
||||||
|
h: int
|
||||||
|
confidence: float
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DetectResponse:
|
||||||
|
"""Response from object detection."""
|
||||||
|
|
||||||
|
detections: List[BBox] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# --- OCR ---
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OCRRequest:
|
||||||
|
"""Request body for OCR."""
|
||||||
|
|
||||||
|
image: str # base64 JPEG
|
||||||
|
languages: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OCRTextResult:
|
||||||
|
"""A single OCR text extraction result."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
confidence: float
|
||||||
|
bbox: List[int] = field(default_factory=list) # [x, y, w, h]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OCRResponse:
|
||||||
|
"""Response from OCR."""
|
||||||
|
|
||||||
|
results: List[OCRTextResult] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Preprocessing ---
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PreprocessRequest:
|
||||||
|
"""Request body for image preprocessing."""
|
||||||
|
|
||||||
|
image: str # base64 JPEG
|
||||||
|
binarize: bool = False
|
||||||
|
deskew: bool = False
|
||||||
|
contrast: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PreprocessResponse:
|
||||||
|
"""Response from preprocessing."""
|
||||||
|
|
||||||
|
image: str # base64 JPEG of processed image
|
||||||
|
|
||||||
|
|
||||||
|
# --- VLM ---
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VLMRequest:
|
||||||
|
"""Request body for visual language model query."""
|
||||||
|
|
||||||
|
image: str # base64 JPEG
|
||||||
|
prompt: str
|
||||||
|
model: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VLMResponse:
|
||||||
|
"""Response from VLM."""
|
||||||
|
|
||||||
|
brand: str
|
||||||
|
confidence: float
|
||||||
|
reasoning: str
|
||||||
|
|
||||||
|
|
||||||
|
# --- CV Region Analysis ---
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnalyzeRegionsRequest:
|
||||||
|
"""Request body for CV region analysis."""
|
||||||
|
|
||||||
|
image: str # base64 JPEG
|
||||||
|
# Edge detection (Canny + HoughLinesP)
|
||||||
|
edge_canny_low: int = 50
|
||||||
|
edge_canny_high: int = 150
|
||||||
|
edge_hough_threshold: int = 80
|
||||||
|
edge_hough_min_length: int = 100
|
||||||
|
edge_hough_max_gap: int = 10
|
||||||
|
edge_pair_max_distance: int = 200
|
||||||
|
edge_pair_min_distance: int = 15
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegionBox:
|
||||||
|
"""A candidate region from CV analysis."""
|
||||||
|
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
w: int
|
||||||
|
h: int
|
||||||
|
confidence: float
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnalyzeRegionsResponse:
|
||||||
|
"""Response from CV region analysis."""
|
||||||
|
|
||||||
|
regions: List[RegionBox] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnalyzeRegionsDebugResponse:
|
||||||
|
"""Response from CV region analysis with debug overlays."""
|
||||||
|
|
||||||
|
regions: List[RegionBox] = field(default_factory=list)
|
||||||
|
edge_overlay_b64: str = "" # Canny edge image as base64 JPEG
|
||||||
|
lines_overlay_b64: str = "" # frame with Hough lines drawn
|
||||||
|
horizontal_count: int = 0
|
||||||
|
pair_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Server Config ---
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConfigUpdate:
|
||||||
|
"""Request body for updating server configuration."""
|
||||||
|
|
||||||
|
device: Optional[str] = None
|
||||||
|
yolo_model: Optional[str] = None
|
||||||
|
yolo_confidence: Optional[float] = None
|
||||||
|
vram_budget_mb: Optional[int] = None
|
||||||
|
strategy: Optional[str] = None
|
||||||
|
ocr_languages: Optional[List[str]] = None
|
||||||
|
ocr_min_confidence: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Export list for modelgen ---
|
||||||
|
|
||||||
|
INFERENCE_VIEWS = [
|
||||||
|
DetectRequest,
|
||||||
|
BBox,
|
||||||
|
DetectResponse,
|
||||||
|
OCRRequest,
|
||||||
|
OCRTextResult,
|
||||||
|
OCRResponse,
|
||||||
|
PreprocessRequest,
|
||||||
|
PreprocessResponse,
|
||||||
|
VLMRequest,
|
||||||
|
VLMResponse,
|
||||||
|
AnalyzeRegionsRequest,
|
||||||
|
RegionBox,
|
||||||
|
AnalyzeRegionsResponse,
|
||||||
|
AnalyzeRegionsDebugResponse,
|
||||||
|
ConfigUpdate,
|
||||||
|
]
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
# MPR Environment Configuration
|
# MPR Environment Configuration
|
||||||
# Copy to .env and adjust values as needed
|
# Copy to .env and adjust values as needed
|
||||||
|
|
||||||
# Database
|
# Database (must match ctrl/k8s/base/postgres.yaml configmap)
|
||||||
POSTGRES_DB=mpr
|
POSTGRES_DB=mpr
|
||||||
POSTGRES_USER=mpr_user
|
POSTGRES_USER=mpr
|
||||||
POSTGRES_PASSWORD=mpr_pass
|
POSTGRES_PASSWORD=mpr
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
DATABASE_URL=postgresql://mpr_user:mpr_pass@postgres:5432/mpr
|
DATABASE_URL=postgresql://mpr:mpr@postgres:5432/mpr
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
@@ -27,6 +27,9 @@ GRPC_HOST=grpc
|
|||||||
GRPC_PORT=50051
|
GRPC_PORT=50051
|
||||||
GRPC_MAX_WORKERS=10
|
GRPC_MAX_WORKERS=10
|
||||||
|
|
||||||
|
# Media — host path for kind cluster mount
|
||||||
|
MEDIA_HOST_PATH=/home/you/wdir/mpr/media
|
||||||
|
|
||||||
# S3 Storage (MinIO locally, real S3 on AWS)
|
# S3 Storage (MinIO locally, real S3 on AWS)
|
||||||
# In k8s/docker: http://minio:9000
|
# In k8s/docker: http://minio:9000
|
||||||
# On dev machine (port-forward): http://localhost:9000
|
# On dev machine (port-forward): http://localhost:9000
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
allow_k8s_contexts('kind-mpr')
|
allow_k8s_contexts('kind-mpr')
|
||||||
|
|
||||||
|
# Create namespace first — kustomize includes it but Tilt may apply
|
||||||
|
# all resources in parallel, causing "namespace not found" races
|
||||||
|
local('kubectl create namespace mpr --dry-run=client -o yaml | kubectl apply -f -')
|
||||||
|
|
||||||
# Apply k8s manifests via kustomize (dev overlay)
|
# Apply k8s manifests via kustomize (dev overlay)
|
||||||
k8s_yaml(kustomize('k8s/overlays/dev'))
|
k8s_yaml(kustomize('k8s/overlays/dev'))
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Create the kind cluster with host media mount.
|
# Create the kind cluster with host media mount.
|
||||||
# Usage: MEDIA_HOST_PATH=/home/you/mpr/media ./kind-create.sh
|
# Reads MEDIA_HOST_PATH from ctrl/.env or environment.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
: "${MEDIA_HOST_PATH:?Set MEDIA_HOST_PATH to your local media directory (e.g. /home/you/mpr/media)}"
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# Source .env if MEDIA_HOST_PATH not already set
|
||||||
|
if [[ -z "${MEDIA_HOST_PATH:-}" ]] && [[ -f "$SCRIPT_DIR/.env" ]]; then
|
||||||
|
export $(grep -E '^MEDIA_HOST_PATH=' "$SCRIPT_DIR/.env" | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${MEDIA_HOST_PATH:?Set MEDIA_HOST_PATH in ctrl/.env or environment}"
|
||||||
|
|
||||||
CONFIG_TPL="$SCRIPT_DIR/k8s/kind-config.yaml.tpl"
|
CONFIG_TPL="$SCRIPT_DIR/k8s/kind-config.yaml.tpl"
|
||||||
|
|
||||||
envsubst < "$CONFIG_TPL" | kind create cluster --config -
|
envsubst < "$CONFIG_TPL" | kind create cluster --config -
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class OverrideProfile:
|
|||||||
{
|
{
|
||||||
"frame_extraction": {"fps": 1.0},
|
"frame_extraction": {"fps": 1.0},
|
||||||
"scene_filter": {"hamming_threshold": 12},
|
"scene_filter": {"hamming_threshold": 12},
|
||||||
|
"region_analysis": {"edge_canny_low": 30, "edge_canny_high": 120},
|
||||||
"detection": {"confidence_threshold": 0.5},
|
"detection": {"confidence_threshold": 0.5},
|
||||||
"ocr": {"languages": ["en", "es"], "min_confidence": 0.3},
|
"ocr": {"languages": ["en", "es"], "min_confidence": 0.3},
|
||||||
"resolver": {"fuzzy_threshold": 60},
|
"resolver": {"fuzzy_threshold": 60},
|
||||||
@@ -52,6 +53,9 @@ class OverrideProfile:
|
|||||||
def scene_filter_config(self):
|
def scene_filter_config(self):
|
||||||
return self._patch(self._base.scene_filter_config(), "scene_filter")
|
return self._patch(self._base.scene_filter_config(), "scene_filter")
|
||||||
|
|
||||||
|
def region_analysis_config(self):
|
||||||
|
return self._patch(self._base.region_analysis_config(), "region_analysis")
|
||||||
|
|
||||||
def detection_config(self):
|
def detection_config(self):
|
||||||
return self._patch(self._base.detection_config(), "detection")
|
return self._patch(self._base.detection_config(), "detection")
|
||||||
|
|
||||||
@@ -130,3 +134,137 @@ def replay_from(
|
|||||||
emit.clear_run_context()
|
emit.clear_run_context()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def replay_single_stage(
|
||||||
|
job_id: str,
|
||||||
|
stage: str,
|
||||||
|
frame_refs: list[int] | None = None,
|
||||||
|
config_overrides: dict | None = None,
|
||||||
|
debug: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Replay a single stage on specific frames (or all frames from checkpoint).
|
||||||
|
|
||||||
|
Fast path for interactive parameter tuning — runs only the target stage
|
||||||
|
function, not the full pipeline tail. Returns the stage output directly.
|
||||||
|
|
||||||
|
When debug=True and stage is detect_edges, returns additional overlay
|
||||||
|
data (Canny edges, Hough lines) for visual feedback in the editor.
|
||||||
|
|
||||||
|
For detect_edges: returns {"edge_regions_by_frame": {seq: [box, ...]}}
|
||||||
|
With debug=True, also returns {"debug": {seq: {edge_overlay_b64, lines_overlay_b64, ...}}}
|
||||||
|
"""
|
||||||
|
if stage not in NODES:
|
||||||
|
raise ValueError(f"Unknown stage: {stage!r}. Options: {NODES}")
|
||||||
|
|
||||||
|
stage_idx = NODES.index(stage)
|
||||||
|
if stage_idx == 0:
|
||||||
|
raise ValueError("Cannot replay the first stage — just run the full pipeline")
|
||||||
|
|
||||||
|
previous_stage = NODES[stage_idx - 1]
|
||||||
|
|
||||||
|
available = list_checkpoints(job_id)
|
||||||
|
if previous_stage not in available:
|
||||||
|
raise ValueError(
|
||||||
|
f"No checkpoint for stage {previous_stage!r} (job {job_id}). "
|
||||||
|
f"Available: {available}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Single-stage replay: job %s, stage %s (loading checkpoint: %s, debug=%s)",
|
||||||
|
job_id, stage, previous_stage, debug)
|
||||||
|
|
||||||
|
state = load_checkpoint(job_id, previous_stage)
|
||||||
|
|
||||||
|
# Build profile with overrides
|
||||||
|
from detect.profiles import get_profile
|
||||||
|
profile = get_profile(state.get("profile_name", "soccer_broadcast"))
|
||||||
|
if config_overrides:
|
||||||
|
profile = OverrideProfile(profile, config_overrides)
|
||||||
|
|
||||||
|
# Run the stage function directly (not through the graph)
|
||||||
|
if stage == "detect_edges":
|
||||||
|
return _replay_detect_edges(state, profile, frame_refs, job_id, debug)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Single-stage replay not yet implemented for {stage!r}. "
|
||||||
|
f"Use replay_from() for full pipeline replay."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_detect_edges(
|
||||||
|
state: dict,
|
||||||
|
profile,
|
||||||
|
frame_refs: list[int] | None,
|
||||||
|
job_id: str,
|
||||||
|
debug: bool,
|
||||||
|
) -> dict:
|
||||||
|
"""Run edge detection on checkpoint frames, optionally with debug overlays."""
|
||||||
|
import os
|
||||||
|
from detect.stages.edge_detector import detect_edge_regions
|
||||||
|
|
||||||
|
config = profile.region_analysis_config()
|
||||||
|
frames = state.get("filtered_frames", [])
|
||||||
|
|
||||||
|
if frame_refs:
|
||||||
|
ref_set = set(frame_refs)
|
||||||
|
frames = [f for f in frames if f.sequence in ref_set]
|
||||||
|
|
||||||
|
inference_url = os.environ.get("INFERENCE_URL")
|
||||||
|
|
||||||
|
# Normal run — always needed for the boxes
|
||||||
|
result = detect_edge_regions(
|
||||||
|
frames=frames,
|
||||||
|
config=config,
|
||||||
|
inference_url=inference_url,
|
||||||
|
job_id=job_id,
|
||||||
|
)
|
||||||
|
output = {"edge_regions_by_frame": result}
|
||||||
|
|
||||||
|
# Debug overlays — call debug endpoint (remote) or local debug function
|
||||||
|
if debug and frames:
|
||||||
|
debug_data = {}
|
||||||
|
if inference_url:
|
||||||
|
from detect.inference import InferenceClient
|
||||||
|
client = InferenceClient(base_url=inference_url, job_id=job_id)
|
||||||
|
for frame in frames:
|
||||||
|
dr = client.detect_edges_debug(
|
||||||
|
image=frame.image,
|
||||||
|
edge_canny_low=config.edge_canny_low,
|
||||||
|
edge_canny_high=config.edge_canny_high,
|
||||||
|
edge_hough_threshold=config.edge_hough_threshold,
|
||||||
|
edge_hough_min_length=config.edge_hough_min_length,
|
||||||
|
edge_hough_max_gap=config.edge_hough_max_gap,
|
||||||
|
edge_pair_max_distance=config.edge_pair_max_distance,
|
||||||
|
edge_pair_min_distance=config.edge_pair_min_distance,
|
||||||
|
)
|
||||||
|
debug_data[frame.sequence] = {
|
||||||
|
"edge_overlay_b64": dr.edge_overlay_b64,
|
||||||
|
"lines_overlay_b64": dr.lines_overlay_b64,
|
||||||
|
"horizontal_count": dr.horizontal_count,
|
||||||
|
"pair_count": dr.pair_count,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Local mode — import GPU module directly
|
||||||
|
from detect.stages.edge_detector import _load_cv_edges
|
||||||
|
edges_mod = _load_cv_edges()
|
||||||
|
for frame in frames:
|
||||||
|
dr = edges_mod.detect_edges_debug(
|
||||||
|
frame.image,
|
||||||
|
canny_low=config.edge_canny_low,
|
||||||
|
canny_high=config.edge_canny_high,
|
||||||
|
hough_threshold=config.edge_hough_threshold,
|
||||||
|
hough_min_length=config.edge_hough_min_length,
|
||||||
|
hough_max_gap=config.edge_hough_max_gap,
|
||||||
|
pair_max_distance=config.edge_pair_max_distance,
|
||||||
|
pair_min_distance=config.edge_pair_min_distance,
|
||||||
|
)
|
||||||
|
debug_data[frame.sequence] = {
|
||||||
|
"edge_overlay_b64": dr["edge_overlay_b64"],
|
||||||
|
"lines_overlay_b64": dr["lines_overlay_b64"],
|
||||||
|
"horizontal_count": dr["horizontal_count"],
|
||||||
|
"pair_count": dr["pair_count"],
|
||||||
|
}
|
||||||
|
output["debug"] = debug_data
|
||||||
|
|
||||||
|
return output
|
||||||
|
|||||||
@@ -2,39 +2,19 @@
|
|||||||
Checkpoint storage — save/load stage state.
|
Checkpoint storage — save/load stage state.
|
||||||
|
|
||||||
Binary data (frame images) → S3/MinIO via frames.py
|
Binary data (frame images) → S3/MinIO via frames.py
|
||||||
Structured data (boxes, detections, stats, config) → Postgres via Django ORM
|
Structured data (stage output, stats, config) → Postgres
|
||||||
|
|
||||||
Until the Django model is generated by modelgen, checkpoint data is stored
|
|
||||||
as JSON in S3 as a fallback. Once DetectJob/StageCheckpoint models exist,
|
|
||||||
this module switches to Postgres.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .frames import save_frames, load_frames, BUCKET, CHECKPOINT_PREFIX
|
from .frames import save_frames, load_frames, CHECKPOINT_PREFIX
|
||||||
from .serializer import serialize_state, deserialize_state
|
from .serializer import serialize_state, deserialize_state
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _has_db() -> bool:
|
|
||||||
"""Check if Postgres is reachable."""
|
|
||||||
try:
|
|
||||||
from core.db.connection import get_session
|
|
||||||
from sqlmodel import text
|
|
||||||
with get_session() as session:
|
|
||||||
session.exec(text("SELECT 1"))
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Save
|
# Save
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -45,34 +25,24 @@ def save_checkpoint(
|
|||||||
stage_index: int,
|
stage_index: int,
|
||||||
state: dict,
|
state: dict,
|
||||||
frames_manifest: dict[int, str] | None = None,
|
frames_manifest: dict[int, str] | None = None,
|
||||||
|
is_scenario: bool = False,
|
||||||
|
scenario_label: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Save a stage checkpoint.
|
Save a stage checkpoint.
|
||||||
|
|
||||||
Saves frame images to S3 (if not already saved), then persists
|
Saves frame images to S3 (if not already saved), then persists
|
||||||
structured state to Postgres (or S3 JSON fallback).
|
structured state to Postgres.
|
||||||
|
|
||||||
Returns the checkpoint identifier (DB id or S3 key).
|
Returns the checkpoint DB id.
|
||||||
"""
|
"""
|
||||||
# Save frames to S3 if no manifest provided
|
from core.db.detect import save_stage_checkpoint
|
||||||
|
|
||||||
if frames_manifest is None:
|
if frames_manifest is None:
|
||||||
all_frames = state.get("frames", [])
|
all_frames = state.get("frames", [])
|
||||||
frames_manifest = save_frames(job_id, all_frames)
|
frames_manifest = save_frames(job_id, all_frames)
|
||||||
|
|
||||||
checkpoint_data = serialize_state(state, frames_manifest)
|
checkpoint_data = serialize_state(state, frames_manifest)
|
||||||
|
|
||||||
if _has_db():
|
|
||||||
checkpoint_id = _save_to_db(job_id, stage, stage_index, checkpoint_data)
|
|
||||||
else:
|
|
||||||
checkpoint_id = _save_to_s3(job_id, stage, checkpoint_data)
|
|
||||||
|
|
||||||
return checkpoint_id
|
|
||||||
|
|
||||||
|
|
||||||
def _save_to_db(job_id: str, stage: str, stage_index: int, data: dict) -> str:
|
|
||||||
"""Save checkpoint structured data to Postgres."""
|
|
||||||
from core.db.detect import save_stage_checkpoint
|
|
||||||
|
|
||||||
frames_prefix = f"{CHECKPOINT_PREFIX}/{job_id}/frames/"
|
frames_prefix = f"{CHECKPOINT_PREFIX}/{job_id}/frames/"
|
||||||
|
|
||||||
checkpoint = save_stage_checkpoint(
|
checkpoint = save_stage_checkpoint(
|
||||||
@@ -80,44 +50,24 @@ def _save_to_db(job_id: str, stage: str, stage_index: int, data: dict) -> str:
|
|||||||
stage=stage,
|
stage=stage,
|
||||||
stage_index=stage_index,
|
stage_index=stage_index,
|
||||||
frames_prefix=frames_prefix,
|
frames_prefix=frames_prefix,
|
||||||
frames_manifest=data.get("frames_manifest", {}),
|
frames_manifest=checkpoint_data.get("frames_manifest", {}),
|
||||||
frames_meta=data.get("frames_meta", []),
|
frames_meta=checkpoint_data.get("frames_meta", []),
|
||||||
filtered_frame_sequences=data.get("filtered_frame_sequences", []),
|
filtered_frame_sequences=checkpoint_data.get("filtered_frame_sequences", []),
|
||||||
boxes_by_frame=data.get("boxes_by_frame", {}),
|
stage_output_key=checkpoint_data.get("stage_output_key", ""),
|
||||||
text_candidates=data.get("text_candidates", []),
|
stats=checkpoint_data.get("stats", {}),
|
||||||
unresolved_candidates=data.get("unresolved_candidates", []),
|
config_snapshot=checkpoint_data.get("config_overrides", {}),
|
||||||
detections=data.get("detections", []),
|
config_overrides=checkpoint_data.get("config_overrides", {}),
|
||||||
stats=data.get("stats", {}),
|
video_path=checkpoint_data.get("video_path", ""),
|
||||||
config_snapshot=data.get("config_overrides", {}),
|
profile_name=checkpoint_data.get("profile_name", ""),
|
||||||
config_overrides=data.get("config_overrides", {}),
|
is_scenario=is_scenario,
|
||||||
video_path=data.get("video_path", ""),
|
scenario_label=scenario_label,
|
||||||
profile_name=data.get("profile_name", ""),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Checkpoint saved to DB: %s/%s (id=%s)", job_id, stage, checkpoint.id)
|
logger.info("Checkpoint saved: %s/%s (id=%s, scenario=%s)",
|
||||||
|
job_id, stage, checkpoint.id, is_scenario)
|
||||||
return str(checkpoint.id)
|
return str(checkpoint.id)
|
||||||
|
|
||||||
|
|
||||||
def _save_to_s3(job_id: str, stage: str, data: dict) -> str:
|
|
||||||
"""Fallback: save checkpoint as JSON to S3 (before modelgen generates DB models)."""
|
|
||||||
from core.storage.s3 import upload_file
|
|
||||||
|
|
||||||
checkpoint_json = json.dumps(data, default=str)
|
|
||||||
key = f"{CHECKPOINT_PREFIX}/{job_id}/stages/{stage}.json"
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
|
||||||
tmp.write(checkpoint_json)
|
|
||||||
tmp_path = tmp.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
upload_file(tmp_path, BUCKET, key)
|
|
||||||
finally:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
logger.info("Checkpoint saved to S3: s3://%s/%s", BUCKET, key)
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Load
|
# Load
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -125,30 +75,12 @@ def _save_to_s3(job_id: str, stage: str, data: dict) -> str:
|
|||||||
def load_checkpoint(job_id: str, stage: str) -> dict:
|
def load_checkpoint(job_id: str, stage: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Load a stage checkpoint and reconstitute full DetectState.
|
Load a stage checkpoint and reconstitute full DetectState.
|
||||||
|
|
||||||
Tries Postgres first, falls back to S3 JSON.
|
|
||||||
"""
|
"""
|
||||||
if _has_db():
|
|
||||||
data = _load_from_db(job_id, stage)
|
|
||||||
else:
|
|
||||||
data = _load_from_s3(job_id, stage)
|
|
||||||
|
|
||||||
raw_manifest = data.get("frames_manifest", {})
|
|
||||||
manifest = {int(k): v for k, v in raw_manifest.items()}
|
|
||||||
frame_metadata = data.get("frames_meta", [])
|
|
||||||
frames = load_frames(manifest, frame_metadata)
|
|
||||||
|
|
||||||
state = deserialize_state(data, frames)
|
|
||||||
|
|
||||||
logger.info("Checkpoint loaded: %s/%s (%d frames)", job_id, stage, len(frames))
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def _load_from_db(job_id: str, stage: str) -> dict:
|
|
||||||
"""Load checkpoint data from Postgres via core/db."""
|
|
||||||
from core.db.detect import get_stage_checkpoint
|
from core.db.detect import get_stage_checkpoint
|
||||||
|
|
||||||
checkpoint = get_stage_checkpoint(job_id, stage)
|
checkpoint = get_stage_checkpoint(job_id, stage)
|
||||||
|
if not checkpoint:
|
||||||
|
raise ValueError(f"No checkpoint for {job_id}/{stage}")
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"job_id": str(checkpoint.job_id),
|
"job_id": str(checkpoint.job_id),
|
||||||
@@ -158,28 +90,20 @@ def _load_from_db(job_id: str, stage: str) -> dict:
|
|||||||
"frames_manifest": checkpoint.frames_manifest,
|
"frames_manifest": checkpoint.frames_manifest,
|
||||||
"frames_meta": checkpoint.frames_meta,
|
"frames_meta": checkpoint.frames_meta,
|
||||||
"filtered_frame_sequences": checkpoint.filtered_frame_sequences,
|
"filtered_frame_sequences": checkpoint.filtered_frame_sequences,
|
||||||
"boxes_by_frame": checkpoint.boxes_by_frame,
|
"stage_output_key": checkpoint.stage_output_key,
|
||||||
"text_candidates": checkpoint.text_candidates,
|
|
||||||
"unresolved_candidates": checkpoint.unresolved_candidates,
|
|
||||||
"detections": checkpoint.detections,
|
|
||||||
"stats": checkpoint.stats,
|
"stats": checkpoint.stats,
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
|
|
||||||
|
raw_manifest = data.get("frames_manifest", {})
|
||||||
|
manifest = {int(k): v for k, v in raw_manifest.items()}
|
||||||
|
frame_metadata = data.get("frames_meta", [])
|
||||||
|
frames = load_frames(manifest, frame_metadata)
|
||||||
|
|
||||||
def _load_from_s3(job_id: str, stage: str) -> dict:
|
state = deserialize_state(data, frames)
|
||||||
"""Fallback: load checkpoint JSON from S3."""
|
|
||||||
from core.storage.s3 import download_to_temp
|
|
||||||
|
|
||||||
key = f"{CHECKPOINT_PREFIX}/{job_id}/stages/{stage}.json"
|
logger.info("Checkpoint loaded: %s/%s (%d frames, scenario=%s)",
|
||||||
tmp_path = download_to_temp(BUCKET, key)
|
job_id, stage, len(frames), checkpoint.is_scenario)
|
||||||
try:
|
return state
|
||||||
with open(tmp_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
finally:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -188,25 +112,5 @@ def _load_from_s3(job_id: str, stage: str) -> dict:
|
|||||||
|
|
||||||
def list_checkpoints(job_id: str) -> list[str]:
|
def list_checkpoints(job_id: str) -> list[str]:
|
||||||
"""List available checkpoint stages for a job."""
|
"""List available checkpoint stages for a job."""
|
||||||
if _has_db():
|
|
||||||
return _list_from_db(job_id)
|
|
||||||
return _list_from_s3(job_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _list_from_db(job_id: str) -> list[str]:
|
|
||||||
from core.db.detect import list_stage_checkpoints
|
from core.db.detect import list_stage_checkpoints
|
||||||
return list_stage_checkpoints(job_id)
|
return list_stage_checkpoints(job_id)
|
||||||
|
|
||||||
|
|
||||||
def _list_from_s3(job_id: str) -> list[str]:
|
|
||||||
from core.storage.s3 import list_objects
|
|
||||||
|
|
||||||
prefix = f"{CHECKPOINT_PREFIX}/{job_id}/stages/"
|
|
||||||
objects = list_objects(BUCKET, prefix)
|
|
||||||
|
|
||||||
stages = []
|
|
||||||
for obj in objects:
|
|
||||||
name = Path(obj["key"]).stem
|
|
||||||
stages.append(name)
|
|
||||||
|
|
||||||
return stages
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from detect.profiles import SoccerBroadcastProfile
|
|||||||
from detect.state import DetectState
|
from detect.state import DetectState
|
||||||
from detect.stages.frame_extractor import extract_frames
|
from detect.stages.frame_extractor import extract_frames
|
||||||
from detect.stages.scene_filter import scene_filter
|
from detect.stages.scene_filter import scene_filter
|
||||||
|
from detect.stages.edge_detector import detect_edge_regions
|
||||||
from detect.stages.yolo_detector import detect_objects
|
from detect.stages.yolo_detector import detect_objects
|
||||||
from detect.stages.preprocess import preprocess_regions
|
from detect.stages.preprocess import preprocess_regions
|
||||||
from detect.stages.ocr_stage import run_ocr
|
from detect.stages.ocr_stage import run_ocr
|
||||||
@@ -31,6 +32,7 @@ INFERENCE_URL = os.environ.get("INFERENCE_URL") # None = local mode
|
|||||||
NODES = [
|
NODES = [
|
||||||
"extract_frames",
|
"extract_frames",
|
||||||
"filter_scenes",
|
"filter_scenes",
|
||||||
|
"detect_edges",
|
||||||
"detect_objects",
|
"detect_objects",
|
||||||
"preprocess",
|
"preprocess",
|
||||||
"run_ocr",
|
"run_ocr",
|
||||||
@@ -119,6 +121,28 @@ def node_filter_scenes(state: DetectState) -> dict:
|
|||||||
return {"filtered_frames": kept, "stats": stats}
|
return {"filtered_frames": kept, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
def node_detect_edges(state: DetectState) -> dict:
|
||||||
|
_emit_transition(state, "detect_edges", "running")
|
||||||
|
|
||||||
|
with trace_node(state, "detect_edges") as span:
|
||||||
|
profile = _get_profile(state)
|
||||||
|
config = profile.region_analysis_config()
|
||||||
|
frames = state.get("filtered_frames", [])
|
||||||
|
job_id = state.get("job_id")
|
||||||
|
|
||||||
|
regions = detect_edge_regions(
|
||||||
|
frames, config, inference_url=INFERENCE_URL, job_id=job_id,
|
||||||
|
)
|
||||||
|
total = sum(len(r) for r in regions.values())
|
||||||
|
span.set_output({"frames": len(frames), "edge_regions": total})
|
||||||
|
|
||||||
|
stats = state.get("stats", PipelineStats())
|
||||||
|
stats.cv_regions_detected = total
|
||||||
|
|
||||||
|
_emit_transition(state, "detect_edges", "done")
|
||||||
|
return {"edge_regions_by_frame": regions, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
def node_detect_objects(state: DetectState) -> dict:
|
def node_detect_objects(state: DetectState) -> dict:
|
||||||
_emit_transition(state, "detect_objects", "running")
|
_emit_transition(state, "detect_objects", "running")
|
||||||
|
|
||||||
@@ -359,6 +383,7 @@ def _checkpointing_node(node_name: str, node_fn):
|
|||||||
NODE_FUNCTIONS = [
|
NODE_FUNCTIONS = [
|
||||||
("extract_frames", node_extract_frames),
|
("extract_frames", node_extract_frames),
|
||||||
("filter_scenes", node_filter_scenes),
|
("filter_scenes", node_filter_scenes),
|
||||||
|
("detect_edges", node_detect_edges),
|
||||||
("detect_objects", node_detect_objects),
|
("detect_objects", node_detect_objects),
|
||||||
("preprocess", node_preprocess),
|
("preprocess", node_preprocess),
|
||||||
("run_ocr", node_run_ocr),
|
("run_ocr", node_run_ocr),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import numpy as np
|
|||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .types import DetectResult, OCRResult, ServerStatus, VLMResult
|
from .types import DetectResult, OCRResult, RegionDebugResult, RegionResult, ServerStatus, VLMResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -145,6 +145,92 @@ class InferenceClient:
|
|||||||
reasoning=data.get("reasoning", ""),
|
reasoning=data.get("reasoning", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def detect_edges(
|
||||||
|
self,
|
||||||
|
image: np.ndarray,
|
||||||
|
edge_canny_low: int = 50,
|
||||||
|
edge_canny_high: int = 150,
|
||||||
|
edge_hough_threshold: int = 80,
|
||||||
|
edge_hough_min_length: int = 100,
|
||||||
|
edge_hough_max_gap: int = 10,
|
||||||
|
edge_pair_max_distance: int = 200,
|
||||||
|
edge_pair_min_distance: int = 15,
|
||||||
|
) -> list[RegionResult]:
|
||||||
|
"""Run edge detection on an image."""
|
||||||
|
payload = {
|
||||||
|
"image": _encode_image(image),
|
||||||
|
"edge_canny_low": edge_canny_low,
|
||||||
|
"edge_canny_high": edge_canny_high,
|
||||||
|
"edge_hough_threshold": edge_hough_threshold,
|
||||||
|
"edge_hough_min_length": edge_hough_min_length,
|
||||||
|
"edge_hough_max_gap": edge_hough_max_gap,
|
||||||
|
"edge_pair_max_distance": edge_pair_max_distance,
|
||||||
|
"edge_pair_min_distance": edge_pair_min_distance,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = self.session.post(
|
||||||
|
f"{self.base_url}/detect_edges",
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in resp.json().get("regions", []):
|
||||||
|
result = RegionResult(
|
||||||
|
x=r["x"], y=r["y"], w=r["w"], h=r["h"],
|
||||||
|
confidence=r["confidence"], label=r["label"],
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def detect_edges_debug(
|
||||||
|
self,
|
||||||
|
image: np.ndarray,
|
||||||
|
edge_canny_low: int = 50,
|
||||||
|
edge_canny_high: int = 150,
|
||||||
|
edge_hough_threshold: int = 80,
|
||||||
|
edge_hough_min_length: int = 100,
|
||||||
|
edge_hough_max_gap: int = 10,
|
||||||
|
edge_pair_max_distance: int = 200,
|
||||||
|
edge_pair_min_distance: int = 15,
|
||||||
|
) -> RegionDebugResult:
|
||||||
|
"""Run edge detection with debug overlays."""
|
||||||
|
payload = {
|
||||||
|
"image": _encode_image(image),
|
||||||
|
"edge_canny_low": edge_canny_low,
|
||||||
|
"edge_canny_high": edge_canny_high,
|
||||||
|
"edge_hough_threshold": edge_hough_threshold,
|
||||||
|
"edge_hough_min_length": edge_hough_min_length,
|
||||||
|
"edge_hough_max_gap": edge_hough_max_gap,
|
||||||
|
"edge_pair_max_distance": edge_pair_max_distance,
|
||||||
|
"edge_pair_min_distance": edge_pair_min_distance,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = self.session.post(
|
||||||
|
f"{self.base_url}/detect_edges/debug",
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
regions = []
|
||||||
|
for r in data.get("regions", []):
|
||||||
|
region = RegionResult(
|
||||||
|
x=r["x"], y=r["y"], w=r["w"], h=r["h"],
|
||||||
|
confidence=r["confidence"], label=r["label"],
|
||||||
|
)
|
||||||
|
regions.append(region)
|
||||||
|
|
||||||
|
return RegionDebugResult(
|
||||||
|
regions=regions,
|
||||||
|
edge_overlay_b64=data.get("edge_overlay_b64", ""),
|
||||||
|
lines_overlay_b64=data.get("lines_overlay_b64", ""),
|
||||||
|
horizontal_count=data.get("horizontal_count", 0),
|
||||||
|
pair_count=data.get("pair_count", 0),
|
||||||
|
)
|
||||||
|
|
||||||
def load_model(self, model: str, quantization: str = "fp16") -> None:
|
def load_model(self, model: str, quantization: str = "fp16") -> None:
|
||||||
"""Request the server to load a model into VRAM."""
|
"""Request the server to load a model into VRAM."""
|
||||||
self.session.post(
|
self.session.post(
|
||||||
|
|||||||
@@ -38,6 +38,27 @@ class VLMResult:
|
|||||||
reasoning: str
|
reasoning: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegionResult:
|
||||||
|
"""A candidate region from CV analysis."""
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
w: int
|
||||||
|
h: int
|
||||||
|
confidence: float
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegionDebugResult:
|
||||||
|
"""CV region analysis with debug overlays."""
|
||||||
|
regions: list[RegionResult] = field(default_factory=list)
|
||||||
|
edge_overlay_b64: str = ""
|
||||||
|
lines_overlay_b64: str = ""
|
||||||
|
horizontal_count: int = 0
|
||||||
|
pair_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ModelInfo:
|
class ModelInfo:
|
||||||
"""Info about a loaded model."""
|
"""Info about a loaded model."""
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ from .base import (
|
|||||||
)
|
)
|
||||||
from .soccer import SoccerBroadcastProfile
|
from .soccer import SoccerBroadcastProfile
|
||||||
|
|
||||||
|
_PROFILES: dict[str, type] = {
|
||||||
|
"soccer_broadcast": SoccerBroadcastProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile(name: str) -> ContentTypeProfile:
|
||||||
|
"""Get a profile instance by name."""
|
||||||
|
cls = _PROFILES.get(name)
|
||||||
|
if cls is None:
|
||||||
|
raise ValueError(f"Unknown profile: {name!r}. Available: {list(_PROFILES)}")
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ContentTypeProfile",
|
"ContentTypeProfile",
|
||||||
"CropContext",
|
"CropContext",
|
||||||
@@ -18,4 +31,5 @@ __all__ = [
|
|||||||
"ResolverConfig",
|
"ResolverConfig",
|
||||||
"SceneFilterConfig",
|
"SceneFilterConfig",
|
||||||
"SoccerBroadcastProfile",
|
"SoccerBroadcastProfile",
|
||||||
|
"get_profile",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ class ResolverConfig:
|
|||||||
fuzzy_threshold: int = 75
|
fuzzy_threshold: int = 75
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegionAnalysisConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
# Edge detection (Canny + HoughLinesP)
|
||||||
|
edge_canny_low: int = 50
|
||||||
|
edge_canny_high: int = 150
|
||||||
|
edge_hough_threshold: int = 80
|
||||||
|
edge_hough_min_length: int = 100
|
||||||
|
edge_hough_max_gap: int = 10
|
||||||
|
edge_pair_max_distance: int = 200
|
||||||
|
edge_pair_min_distance: int = 15
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CropContext:
|
class CropContext:
|
||||||
image: bytes
|
image: bytes
|
||||||
@@ -56,6 +69,7 @@ class ContentTypeProfile(Protocol):
|
|||||||
|
|
||||||
def frame_extraction_config(self) -> FrameExtractionConfig: ...
|
def frame_extraction_config(self) -> FrameExtractionConfig: ...
|
||||||
def scene_filter_config(self) -> SceneFilterConfig: ...
|
def scene_filter_config(self) -> SceneFilterConfig: ...
|
||||||
|
def region_analysis_config(self) -> RegionAnalysisConfig: ...
|
||||||
def detection_config(self) -> DetectionConfig: ...
|
def detection_config(self) -> DetectionConfig: ...
|
||||||
def ocr_config(self) -> OCRConfig: ...
|
def ocr_config(self) -> OCRConfig: ...
|
||||||
def resolver_config(self) -> ResolverConfig: ...
|
def resolver_config(self) -> ResolverConfig: ...
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from .base import (
|
|||||||
DetectionConfig,
|
DetectionConfig,
|
||||||
FrameExtractionConfig,
|
FrameExtractionConfig,
|
||||||
OCRConfig,
|
OCRConfig,
|
||||||
|
RegionAnalysisConfig,
|
||||||
ResolverConfig,
|
ResolverConfig,
|
||||||
SceneFilterConfig,
|
SceneFilterConfig,
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,17 @@ class SoccerBroadcastProfile:
|
|||||||
def scene_filter_config(self) -> SceneFilterConfig:
|
def scene_filter_config(self) -> SceneFilterConfig:
|
||||||
return SceneFilterConfig(hamming_threshold=8, enabled=True)
|
return SceneFilterConfig(hamming_threshold=8, enabled=True)
|
||||||
|
|
||||||
|
def region_analysis_config(self) -> RegionAnalysisConfig:
|
||||||
|
return RegionAnalysisConfig(
|
||||||
|
edge_canny_low=50,
|
||||||
|
edge_canny_high=150,
|
||||||
|
edge_hough_threshold=80,
|
||||||
|
edge_hough_min_length=100,
|
||||||
|
edge_hough_max_gap=10,
|
||||||
|
edge_pair_max_distance=200,
|
||||||
|
edge_pair_min_distance=15,
|
||||||
|
)
|
||||||
|
|
||||||
def detection_config(self) -> DetectionConfig:
|
def detection_config(self) -> DetectionConfig:
|
||||||
return DetectionConfig(
|
return DetectionConfig(
|
||||||
model_name="yolov8n.pt",
|
model_name="yolov8n.pt",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class BoundingBoxEvent(BaseModel):
|
|||||||
label: str
|
label: str
|
||||||
resolved_brand: Optional[str] = None
|
resolved_brand: Optional[str] = None
|
||||||
source: Optional[str] = None
|
source: Optional[str] = None
|
||||||
|
stage: Optional[str] = None
|
||||||
|
|
||||||
class BrandSummary(BaseModel):
|
class BrandSummary(BaseModel):
|
||||||
"""Per-brand stats in the final report."""
|
"""Per-brand stats in the final report."""
|
||||||
@@ -54,6 +55,7 @@ class StatsUpdate(BaseModel):
|
|||||||
"""Funnel statistics snapshot. SSE event: stats_update"""
|
"""Funnel statistics snapshot. SSE event: stats_update"""
|
||||||
frames_extracted: int = 0
|
frames_extracted: int = 0
|
||||||
frames_after_scene_filter: int = 0
|
frames_after_scene_filter: int = 0
|
||||||
|
cv_regions_detected: int = 0
|
||||||
regions_detected: int = 0
|
regions_detected: int = 0
|
||||||
regions_resolved_by_ocr: int = 0
|
regions_resolved_by_ocr: int = 0
|
||||||
regions_escalated_to_local_vlm: int = 0
|
regions_escalated_to_local_vlm: int = 0
|
||||||
|
|||||||
174
detect/stages/edge_detector.py
Normal file
174
detect/stages/edge_detector.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Stage — Edge Detection
|
||||||
|
|
||||||
|
Canny + HoughLinesP to find horizontal line pairs that bound
|
||||||
|
advertising hoardings. Pure OpenCV, no ML models.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
- Remote: calls GPU inference server over HTTP
|
||||||
|
- Local: imports cv2 directly (OpenCV on same machine)
|
||||||
|
|
||||||
|
Emits frame_update events with bounding boxes for the frame viewer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from detect import emit
|
||||||
|
from detect.models import BoundingBox, Frame
|
||||||
|
from detect.profiles.base import RegionAnalysisConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_to_b64(frame: Frame) -> str:
|
||||||
|
"""Encode frame as base64 JPEG for SSE frame_update events."""
|
||||||
|
img = Image.fromarray(frame.image)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=70)
|
||||||
|
return base64.b64encode(buf.getvalue()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_remote(
|
||||||
|
frame: Frame,
|
||||||
|
config: RegionAnalysisConfig,
|
||||||
|
inference_url: str,
|
||||||
|
job_id: str = "",
|
||||||
|
log_level: str = "INFO",
|
||||||
|
) -> list[BoundingBox]:
|
||||||
|
"""Call the inference server over HTTP."""
|
||||||
|
from detect.inference import InferenceClient
|
||||||
|
|
||||||
|
client = InferenceClient(
|
||||||
|
base_url=inference_url, job_id=job_id, log_level=log_level,
|
||||||
|
)
|
||||||
|
results = client.detect_edges(
|
||||||
|
image=frame.image,
|
||||||
|
edge_canny_low=config.edge_canny_low,
|
||||||
|
edge_canny_high=config.edge_canny_high,
|
||||||
|
edge_hough_threshold=config.edge_hough_threshold,
|
||||||
|
edge_hough_min_length=config.edge_hough_min_length,
|
||||||
|
edge_hough_max_gap=config.edge_hough_max_gap,
|
||||||
|
edge_pair_max_distance=config.edge_pair_max_distance,
|
||||||
|
edge_pair_min_distance=config.edge_pair_min_distance,
|
||||||
|
)
|
||||||
|
boxes = []
|
||||||
|
for r in results:
|
||||||
|
box = BoundingBox(
|
||||||
|
x=r.x, y=r.y, w=r.w, h=r.h,
|
||||||
|
confidence=r.confidence, label=r.label,
|
||||||
|
)
|
||||||
|
boxes.append(box)
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
|
_cv_edges_mod = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cv_edges():
|
||||||
|
"""Load edges module directly — gpu/models/__init__.py has GPU-container-only imports."""
|
||||||
|
global _cv_edges_mod
|
||||||
|
if _cv_edges_mod is None:
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("cv_edges", Path("gpu/models/cv/edges.py"))
|
||||||
|
_cv_edges_mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(_cv_edges_mod)
|
||||||
|
return _cv_edges_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_local(frame: Frame, config: RegionAnalysisConfig) -> list[BoundingBox]:
|
||||||
|
"""Run edge detection in-process (requires opencv-python)."""
|
||||||
|
detect_edges_fn = _load_cv_edges().detect_edges
|
||||||
|
|
||||||
|
edge_results = detect_edges_fn(
|
||||||
|
frame.image,
|
||||||
|
canny_low=config.edge_canny_low,
|
||||||
|
canny_high=config.edge_canny_high,
|
||||||
|
hough_threshold=config.edge_hough_threshold,
|
||||||
|
hough_min_length=config.edge_hough_min_length,
|
||||||
|
hough_max_gap=config.edge_hough_max_gap,
|
||||||
|
pair_max_distance=config.edge_pair_max_distance,
|
||||||
|
pair_min_distance=config.edge_pair_min_distance,
|
||||||
|
)
|
||||||
|
|
||||||
|
boxes = []
|
||||||
|
for r in edge_results:
|
||||||
|
box = BoundingBox(
|
||||||
|
x=r["x"], y=r["y"], w=r["w"], h=r["h"],
|
||||||
|
confidence=r["confidence"], label=r["label"],
|
||||||
|
)
|
||||||
|
boxes.append(box)
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
|
def detect_edge_regions(
|
||||||
|
frames: list[Frame],
|
||||||
|
config: RegionAnalysisConfig,
|
||||||
|
inference_url: str | None = None,
|
||||||
|
job_id: str | None = None,
|
||||||
|
) -> dict[int, list[BoundingBox]]:
|
||||||
|
"""
|
||||||
|
Run edge detection on all frames.
|
||||||
|
|
||||||
|
Returns a dict mapping frame sequence → list of bounding boxes.
|
||||||
|
"""
|
||||||
|
if not config.enabled:
|
||||||
|
emit.log(job_id, "EdgeDetection", "INFO", "Edge detection disabled, skipping")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
mode = "remote" if inference_url else "local"
|
||||||
|
emit.log(job_id, "EdgeDetection", "INFO",
|
||||||
|
f"Detecting edges in {len(frames)} frames (mode={mode})")
|
||||||
|
|
||||||
|
all_boxes: dict[int, list[BoundingBox]] = {}
|
||||||
|
total_regions = 0
|
||||||
|
|
||||||
|
for i, frame in enumerate(frames):
|
||||||
|
t0 = time.monotonic()
|
||||||
|
if inference_url:
|
||||||
|
from detect.emit import _run_log_level
|
||||||
|
boxes = _detect_remote(
|
||||||
|
frame, config, inference_url,
|
||||||
|
job_id=job_id or "", log_level=_run_log_level,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
boxes = _detect_local(frame, config)
|
||||||
|
analysis_ms = (time.monotonic() - t0) * 1000
|
||||||
|
|
||||||
|
all_boxes[frame.sequence] = boxes
|
||||||
|
total_regions += len(boxes)
|
||||||
|
|
||||||
|
emit.log(job_id, "EdgeDetection", "DEBUG",
|
||||||
|
f"Frame {frame.sequence}: {len(boxes)} regions in {analysis_ms:.0f}ms"
|
||||||
|
+ (f" [{', '.join(b.label for b in boxes)}]" if boxes else ""))
|
||||||
|
|
||||||
|
if boxes and job_id:
|
||||||
|
box_dicts = [
|
||||||
|
{
|
||||||
|
"x": b.x, "y": b.y, "w": b.w, "h": b.h,
|
||||||
|
"confidence": b.confidence, "label": b.label,
|
||||||
|
"stage": "detect_edges",
|
||||||
|
}
|
||||||
|
for b in boxes
|
||||||
|
]
|
||||||
|
emit.frame_update(
|
||||||
|
job_id,
|
||||||
|
frame_ref=frame.sequence,
|
||||||
|
timestamp=frame.timestamp,
|
||||||
|
jpeg_b64=_frame_to_b64(frame),
|
||||||
|
boxes=box_dicts,
|
||||||
|
)
|
||||||
|
|
||||||
|
emit.log(job_id, "EdgeDetection", "INFO",
|
||||||
|
f"Found {total_regions} edge regions across {len(frames)} frames")
|
||||||
|
emit.stats(job_id, cv_regions_detected=total_regions)
|
||||||
|
|
||||||
|
return all_boxes
|
||||||
@@ -3,6 +3,7 @@ Stage registry — registers all built-in stages.
|
|||||||
|
|
||||||
Split by category:
|
Split by category:
|
||||||
preprocessing.py — extract_frames, filter_scenes
|
preprocessing.py — extract_frames, filter_scenes
|
||||||
|
cv_analysis.py — detect_edges (+ future: detect_contours, detect_color, merge_regions)
|
||||||
detection.py — detect_objects, run_ocr
|
detection.py — detect_objects, run_ocr
|
||||||
resolution.py — match_brands
|
resolution.py — match_brands
|
||||||
escalation.py — escalate_vlm, escalate_cloud
|
escalation.py — escalate_vlm, escalate_cloud
|
||||||
@@ -11,6 +12,7 @@ Split by category:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from . import preprocessing
|
from . import preprocessing
|
||||||
|
from . import cv_analysis
|
||||||
from . import detection
|
from . import detection
|
||||||
from . import resolution
|
from . import resolution
|
||||||
from . import escalation
|
from . import escalation
|
||||||
@@ -19,6 +21,7 @@ from . import output
|
|||||||
|
|
||||||
def register_all():
|
def register_all():
|
||||||
preprocessing.register()
|
preprocessing.register()
|
||||||
|
cv_analysis.register()
|
||||||
detection.register()
|
detection.register()
|
||||||
resolution.register()
|
resolution.register()
|
||||||
escalation.register()
|
escalation.register()
|
||||||
|
|||||||
45
detect/stages/registry/cv_analysis.py
Normal file
45
detect/stages/registry/cv_analysis.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Registration for CV analysis stages: edge detection."""
|
||||||
|
|
||||||
|
from detect.stages.base import StageDefinition, StageIO, StageConfigField, register_stage
|
||||||
|
from ._serializers import serialize_dataclass_list, deserialize_bounding_box
|
||||||
|
|
||||||
|
|
||||||
|
def _ser_regions(state: dict, job_id: str) -> dict:
|
||||||
|
regions = state.get("edge_regions_by_frame", {})
|
||||||
|
serialized = {
|
||||||
|
str(seq): serialize_dataclass_list(bl) for seq, bl in regions.items()
|
||||||
|
}
|
||||||
|
return {"edge_regions_by_frame": serialized}
|
||||||
|
|
||||||
|
|
||||||
|
def _deser_regions(data: dict, job_id: str) -> dict:
|
||||||
|
regions = {}
|
||||||
|
for seq_str, box_dicts in data.get("edge_regions_by_frame", {}).items():
|
||||||
|
regions[int(seq_str)] = [deserialize_bounding_box(b) for b in box_dicts]
|
||||||
|
return {"edge_regions_by_frame": regions}
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
edge_detection = StageDefinition(
|
||||||
|
name="detect_edges",
|
||||||
|
label="Edge Detection",
|
||||||
|
description="Canny + HoughLinesP — find horizontal line pairs (hoarding boundaries)",
|
||||||
|
category="cv_analysis",
|
||||||
|
io=StageIO(
|
||||||
|
reads=["filtered_frames"],
|
||||||
|
writes=["edge_regions_by_frame"],
|
||||||
|
),
|
||||||
|
config_fields=[
|
||||||
|
StageConfigField("enabled", "bool", True, "Enable region analysis"),
|
||||||
|
StageConfigField("edge_canny_low", "int", 50, "Canny low threshold", min=0, max=255),
|
||||||
|
StageConfigField("edge_canny_high", "int", 150, "Canny high threshold", min=0, max=255),
|
||||||
|
StageConfigField("edge_hough_threshold", "int", 80, "Hough accumulator threshold", min=1, max=500),
|
||||||
|
StageConfigField("edge_hough_min_length", "int", 100, "Min line length (px)", min=10, max=2000),
|
||||||
|
StageConfigField("edge_hough_max_gap", "int", 10, "Max line gap (px)", min=1, max=100),
|
||||||
|
StageConfigField("edge_pair_max_distance", "int", 200, "Max distance between line pair (px)", min=10, max=500),
|
||||||
|
StageConfigField("edge_pair_min_distance", "int", 15, "Min distance between line pair (px)", min=5, max=200),
|
||||||
|
],
|
||||||
|
serialize_fn=_ser_regions,
|
||||||
|
deserialize_fn=_deser_regions,
|
||||||
|
)
|
||||||
|
register_stage(edge_detection)
|
||||||
@@ -22,6 +22,7 @@ class DetectState(TypedDict, total=False):
|
|||||||
# Stage outputs
|
# Stage outputs
|
||||||
frames: list[Frame]
|
frames: list[Frame]
|
||||||
filtered_frames: list[Frame]
|
filtered_frames: list[Frame]
|
||||||
|
edge_regions_by_frame: dict[int, list[BoundingBox]]
|
||||||
boxes_by_frame: dict[int, list[BoundingBox]]
|
boxes_by_frame: dict[int, list[BoundingBox]]
|
||||||
preprocessed_crops: dict # "{frame_seq}_{box_idx}" → np.ndarray
|
preprocessed_crops: dict # "{frame_seq}_{box_idx}" → np.ndarray
|
||||||
text_candidates: list[TextCandidate]
|
text_candidates: list[TextCandidate]
|
||||||
|
|||||||
1
gpu/models/cv/__init__.py
Normal file
1
gpu/models/cv/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""CV operations — pure OpenCV, no ML models."""
|
||||||
258
gpu/models/cv/edges.py
Normal file
258
gpu/models/cv/edges.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
Edge detection — Canny + HoughLinesP → parallel line pairs → bounding boxes.
|
||||||
|
|
||||||
|
Finds horizontal line pairs with consistent spacing, which correspond to
|
||||||
|
the top and bottom edges of advertising hoardings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def detect_edges(
|
||||||
|
image: np.ndarray,
|
||||||
|
canny_low: int = 50,
|
||||||
|
canny_high: int = 150,
|
||||||
|
hough_threshold: int = 80,
|
||||||
|
hough_min_length: int = 100,
|
||||||
|
hough_max_gap: int = 10,
|
||||||
|
pair_max_distance: int = 200,
|
||||||
|
pair_min_distance: int = 15,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Find horizontal line pairs that likely bound advertising hoardings.
|
||||||
|
|
||||||
|
Returns list of dicts with keys: x, y, w, h, confidence, label.
|
||||||
|
Each box represents the region between a detected pair of parallel
|
||||||
|
horizontal lines.
|
||||||
|
"""
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
||||||
|
edges = cv2.Canny(gray, canny_low, canny_high)
|
||||||
|
|
||||||
|
raw_lines = cv2.HoughLinesP(
|
||||||
|
edges,
|
||||||
|
rho=1,
|
||||||
|
theta=np.pi / 180,
|
||||||
|
threshold=hough_threshold,
|
||||||
|
minLineLength=hough_min_length,
|
||||||
|
maxLineGap=hough_max_gap,
|
||||||
|
)
|
||||||
|
|
||||||
|
if raw_lines is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Filter to near-horizontal lines (within 10 degrees)
|
||||||
|
horizontals = _filter_horizontal(raw_lines, max_angle_deg=10)
|
||||||
|
|
||||||
|
if len(horizontals) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Find pairs of parallel horizontals with consistent spacing
|
||||||
|
pairs = _find_line_pairs(
|
||||||
|
horizontals,
|
||||||
|
min_distance=pair_min_distance,
|
||||||
|
max_distance=pair_max_distance,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert pairs to bounding boxes
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
results = []
|
||||||
|
for top_line, bottom_line in pairs:
|
||||||
|
box = _pair_to_bbox(top_line, bottom_line, frame_width=w, frame_height=h)
|
||||||
|
if box is not None:
|
||||||
|
results.append(box)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_horizontal(lines: np.ndarray, max_angle_deg: float = 10) -> list[tuple]:
|
||||||
|
"""Keep only lines within max_angle_deg of horizontal."""
|
||||||
|
max_slope = np.tan(np.radians(max_angle_deg))
|
||||||
|
result = []
|
||||||
|
for line in lines:
|
||||||
|
x1, y1, x2, y2 = line[0]
|
||||||
|
dx = x2 - x1
|
||||||
|
if dx == 0:
|
||||||
|
continue
|
||||||
|
slope = abs((y2 - y1) / dx)
|
||||||
|
if slope <= max_slope:
|
||||||
|
y_mid = (y1 + y2) / 2
|
||||||
|
x_min = min(x1, x2)
|
||||||
|
x_max = max(x1, x2)
|
||||||
|
length = np.sqrt(dx**2 + (y2 - y1) ** 2)
|
||||||
|
result.append((x_min, x_max, y_mid, length))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _find_line_pairs(
|
||||||
|
horizontals: list[tuple],
|
||||||
|
min_distance: int,
|
||||||
|
max_distance: int,
|
||||||
|
) -> list[tuple]:
|
||||||
|
"""
|
||||||
|
Find pairs of horizontal lines that could be top/bottom of a hoarding.
|
||||||
|
|
||||||
|
Lines must overlap horizontally and be spaced within [min_distance, max_distance].
|
||||||
|
"""
|
||||||
|
# Sort by y position
|
||||||
|
sorted_lines = sorted(horizontals, key=lambda l: l[2])
|
||||||
|
|
||||||
|
pairs = []
|
||||||
|
used = set()
|
||||||
|
|
||||||
|
for i, top in enumerate(sorted_lines):
|
||||||
|
if i in used:
|
||||||
|
continue
|
||||||
|
for j, bottom in enumerate(sorted_lines[i + 1 :], start=i + 1):
|
||||||
|
if j in used:
|
||||||
|
continue
|
||||||
|
|
||||||
|
y_gap = bottom[2] - top[2]
|
||||||
|
if y_gap < min_distance:
|
||||||
|
continue
|
||||||
|
if y_gap > max_distance:
|
||||||
|
break # sorted by y, no point checking further
|
||||||
|
|
||||||
|
# Check horizontal overlap
|
||||||
|
overlap_start = max(top[0], bottom[0])
|
||||||
|
overlap_end = min(top[1], bottom[1])
|
||||||
|
overlap = overlap_end - overlap_start
|
||||||
|
|
||||||
|
# Require at least 50% overlap relative to shorter line
|
||||||
|
shorter_length = min(top[1] - top[0], bottom[1] - bottom[0])
|
||||||
|
if shorter_length > 0 and overlap / shorter_length >= 0.5:
|
||||||
|
pairs.append((top, bottom))
|
||||||
|
used.add(i)
|
||||||
|
used.add(j)
|
||||||
|
break
|
||||||
|
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def _pair_to_bbox(
|
||||||
|
top: tuple,
|
||||||
|
bottom: tuple,
|
||||||
|
frame_width: int,
|
||||||
|
frame_height: int,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Convert a line pair to a bounding box dict."""
|
||||||
|
x = int(max(0, min(top[0], bottom[0])))
|
||||||
|
y = int(max(0, top[2]))
|
||||||
|
x2 = int(min(frame_width, max(top[1], bottom[1])))
|
||||||
|
y2 = int(min(frame_height, bottom[2]))
|
||||||
|
w = x2 - x
|
||||||
|
h = y2 - y
|
||||||
|
|
||||||
|
if w < 20 or h < 5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Confidence based on line lengths relative to box width
|
||||||
|
avg_line_length = (top[3] + bottom[3]) / 2
|
||||||
|
coverage = min(1.0, avg_line_length / max(w, 1))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"w": w,
|
||||||
|
"h": h,
|
||||||
|
"confidence": round(coverage, 3),
|
||||||
|
"label": "edge_region",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _np_to_b64_jpeg(image: np.ndarray, quality: int = 70) -> str:
|
||||||
|
"""Encode a numpy image (BGR or grayscale) as base64 JPEG."""
|
||||||
|
ok, buf = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
||||||
|
if not ok:
|
||||||
|
return ""
|
||||||
|
return base64.b64encode(buf.tobytes()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def detect_edges_debug(
|
||||||
|
image: np.ndarray,
|
||||||
|
canny_low: int = 50,
|
||||||
|
canny_high: int = 150,
|
||||||
|
hough_threshold: int = 80,
|
||||||
|
hough_min_length: int = 100,
|
||||||
|
hough_max_gap: int = 10,
|
||||||
|
pair_max_distance: int = 200,
|
||||||
|
pair_min_distance: int = 15,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Same as detect_edges but returns intermediate visualizations.
|
||||||
|
|
||||||
|
Returns dict with:
|
||||||
|
regions: list[dict] — same boxes as detect_edges
|
||||||
|
edge_overlay_b64: str — Canny edge image as base64 JPEG
|
||||||
|
lines_overlay_b64: str — frame with Hough lines drawn
|
||||||
|
horizontal_count: int — number of horizontal lines found
|
||||||
|
pair_count: int — number of line pairs found
|
||||||
|
"""
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
||||||
|
edges = cv2.Canny(gray, canny_low, canny_high)
|
||||||
|
|
||||||
|
# Edge overlay — Canny output as-is (white edges on black)
|
||||||
|
edge_overlay_b64 = _np_to_b64_jpeg(edges)
|
||||||
|
|
||||||
|
raw_lines = cv2.HoughLinesP(
|
||||||
|
edges,
|
||||||
|
rho=1,
|
||||||
|
theta=np.pi / 180,
|
||||||
|
threshold=hough_threshold,
|
||||||
|
minLineLength=hough_min_length,
|
||||||
|
maxLineGap=hough_max_gap,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lines overlay — draw all Hough lines on a copy of the frame
|
||||||
|
lines_vis = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
||||||
|
if raw_lines is not None:
|
||||||
|
for line in raw_lines:
|
||||||
|
x1, y1, x2, y2 = line[0]
|
||||||
|
cv2.line(lines_vis, (x1, y1), (x2, y2), (0, 0, 255), 1)
|
||||||
|
|
||||||
|
horizontals = []
|
||||||
|
if raw_lines is not None:
|
||||||
|
horizontals = _filter_horizontal(raw_lines, max_angle_deg=10)
|
||||||
|
|
||||||
|
# Draw horizontal lines in cyan, thicker
|
||||||
|
for h_line in horizontals:
|
||||||
|
x_min, x_max, y_mid, _ = h_line
|
||||||
|
cv2.line(lines_vis, (int(x_min), int(y_mid)), (int(x_max), int(y_mid)), (255, 255, 0), 2)
|
||||||
|
|
||||||
|
pairs = []
|
||||||
|
if len(horizontals) >= 2:
|
||||||
|
pairs = _find_line_pairs(
|
||||||
|
horizontals,
|
||||||
|
min_distance=pair_min_distance,
|
||||||
|
max_distance=pair_max_distance,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draw paired lines in green
|
||||||
|
for top_line, bottom_line in pairs:
|
||||||
|
cv2.line(lines_vis, (int(top_line[0]), int(top_line[2])),
|
||||||
|
(int(top_line[1]), int(top_line[2])), (0, 255, 0), 2)
|
||||||
|
cv2.line(lines_vis, (int(bottom_line[0]), int(bottom_line[2])),
|
||||||
|
(int(bottom_line[1]), int(bottom_line[2])), (0, 255, 0), 2)
|
||||||
|
|
||||||
|
lines_overlay_b64 = _np_to_b64_jpeg(lines_vis)
|
||||||
|
|
||||||
|
# Build region boxes (same logic as detect_edges)
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
regions = []
|
||||||
|
for top_line, bottom_line in pairs:
|
||||||
|
box = _pair_to_bbox(top_line, bottom_line, frame_width=w, frame_height=h)
|
||||||
|
if box is not None:
|
||||||
|
regions.append(box)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"regions": regions,
|
||||||
|
"edge_overlay_b64": edge_overlay_b64,
|
||||||
|
"lines_overlay_b64": lines_overlay_b64,
|
||||||
|
"horizontal_count": len(horizontals),
|
||||||
|
"pair_count": len(pairs),
|
||||||
|
}
|
||||||
112
gpu/models/inference_contract.py
Normal file
112
gpu/models/inference_contract.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Pydantic Models - GENERATED FILE
|
||||||
|
|
||||||
|
Do not edit directly. Regenerate using modelgen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class DetectRequest(BaseModel):
|
||||||
|
"""Request body for object detection."""
|
||||||
|
image: str
|
||||||
|
model: Optional[str] = None
|
||||||
|
confidence: Optional[float] = None
|
||||||
|
target_classes: Optional[List[str]] = None
|
||||||
|
|
||||||
|
class BBox(BaseModel):
|
||||||
|
"""A detected bounding box."""
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
w: int
|
||||||
|
h: int
|
||||||
|
confidence: float
|
||||||
|
label: str
|
||||||
|
|
||||||
|
class DetectResponse(BaseModel):
|
||||||
|
"""Response from object detection."""
|
||||||
|
detections: List[BBox] = Field(default_factory=list)
|
||||||
|
|
||||||
|
class OCRRequest(BaseModel):
|
||||||
|
"""Request body for OCR."""
|
||||||
|
image: str
|
||||||
|
languages: Optional[List[str]] = None
|
||||||
|
|
||||||
|
class OCRTextResult(BaseModel):
|
||||||
|
"""A single OCR text extraction result."""
|
||||||
|
text: str
|
||||||
|
confidence: float
|
||||||
|
bbox: List[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
class OCRResponse(BaseModel):
|
||||||
|
"""Response from OCR."""
|
||||||
|
results: List[OCRTextResult] = Field(default_factory=list)
|
||||||
|
|
||||||
|
class PreprocessRequest(BaseModel):
|
||||||
|
"""Request body for image preprocessing."""
|
||||||
|
image: str
|
||||||
|
binarize: bool = False
|
||||||
|
deskew: bool = False
|
||||||
|
contrast: bool = True
|
||||||
|
|
||||||
|
class PreprocessResponse(BaseModel):
|
||||||
|
"""Response from preprocessing."""
|
||||||
|
image: str
|
||||||
|
|
||||||
|
class VLMRequest(BaseModel):
|
||||||
|
"""Request body for visual language model query."""
|
||||||
|
image: str
|
||||||
|
prompt: str
|
||||||
|
model: Optional[str] = None
|
||||||
|
|
||||||
|
class VLMResponse(BaseModel):
|
||||||
|
"""Response from VLM."""
|
||||||
|
brand: str
|
||||||
|
confidence: float
|
||||||
|
reasoning: str
|
||||||
|
|
||||||
|
class AnalyzeRegionsRequest(BaseModel):
|
||||||
|
"""Request body for CV region analysis."""
|
||||||
|
image: str
|
||||||
|
edge_canny_low: int = 50
|
||||||
|
edge_canny_high: int = 150
|
||||||
|
edge_hough_threshold: int = 80
|
||||||
|
edge_hough_min_length: int = 100
|
||||||
|
edge_hough_max_gap: int = 10
|
||||||
|
edge_pair_max_distance: int = 200
|
||||||
|
edge_pair_min_distance: int = 15
|
||||||
|
|
||||||
|
class RegionBox(BaseModel):
|
||||||
|
"""A candidate region from CV analysis."""
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
w: int
|
||||||
|
h: int
|
||||||
|
confidence: float
|
||||||
|
label: str
|
||||||
|
|
||||||
|
class AnalyzeRegionsResponse(BaseModel):
|
||||||
|
"""Response from CV region analysis."""
|
||||||
|
regions: List[RegionBox] = Field(default_factory=list)
|
||||||
|
|
||||||
|
class AnalyzeRegionsDebugResponse(BaseModel):
|
||||||
|
"""Response from CV region analysis with debug overlays."""
|
||||||
|
regions: List[RegionBox] = Field(default_factory=list)
|
||||||
|
edge_overlay_b64: str = ""
|
||||||
|
lines_overlay_b64: str = ""
|
||||||
|
horizontal_count: int = 0
|
||||||
|
pair_count: int = 0
|
||||||
|
|
||||||
|
class ConfigUpdate(BaseModel):
|
||||||
|
"""Request body for updating server configuration."""
|
||||||
|
device: Optional[str] = None
|
||||||
|
yolo_model: Optional[str] = None
|
||||||
|
yolo_confidence: Optional[float] = None
|
||||||
|
vram_budget_mb: Optional[int] = None
|
||||||
|
strategy: Optional[str] = None
|
||||||
|
ocr_languages: Optional[List[str]] = None
|
||||||
|
ocr_min_confidence: Optional[float] = None
|
||||||
163
gpu/server.py
163
gpu/server.py
@@ -52,74 +52,25 @@ def _gpu_log(job_id: str, log_level: str, stage: str, level: str, msg: str):
|
|||||||
emit_log(job_id, stage, level, msg, log_level=log_level)
|
emit_log(job_id, stage, level, msg, log_level=log_level)
|
||||||
|
|
||||||
|
|
||||||
# --- Request/Response models ---
|
# --- Request/Response models (generated from core/schema/models/inference.py) ---
|
||||||
|
|
||||||
class DetectRequest(BaseModel):
|
from models.inference_contract import (
|
||||||
image: str
|
AnalyzeRegionsDebugResponse,
|
||||||
model: str | None = None
|
AnalyzeRegionsRequest,
|
||||||
confidence: float | None = None
|
AnalyzeRegionsResponse,
|
||||||
target_classes: list[str] | None = None
|
BBox,
|
||||||
|
ConfigUpdate,
|
||||||
|
DetectRequest,
|
||||||
class BBox(BaseModel):
|
DetectResponse,
|
||||||
x: int
|
OCRRequest,
|
||||||
y: int
|
OCRResponse,
|
||||||
w: int
|
OCRTextResult,
|
||||||
h: int
|
PreprocessRequest,
|
||||||
confidence: float
|
PreprocessResponse,
|
||||||
label: str
|
RegionBox,
|
||||||
|
VLMRequest,
|
||||||
|
VLMResponse,
|
||||||
class DetectResponse(BaseModel):
|
)
|
||||||
detections: list[BBox]
|
|
||||||
|
|
||||||
|
|
||||||
class OCRRequest(BaseModel):
|
|
||||||
image: str
|
|
||||||
languages: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class OCRTextResult(BaseModel):
|
|
||||||
text: str
|
|
||||||
confidence: float
|
|
||||||
bbox: list[int]
|
|
||||||
|
|
||||||
|
|
||||||
class OCRResponse(BaseModel):
|
|
||||||
results: list[OCRTextResult]
|
|
||||||
|
|
||||||
|
|
||||||
class PreprocessRequest(BaseModel):
|
|
||||||
image: str
|
|
||||||
binarize: bool = False
|
|
||||||
deskew: bool = False
|
|
||||||
contrast: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class PreprocessResponse(BaseModel):
|
|
||||||
image: str # base64 JPEG of processed image
|
|
||||||
|
|
||||||
|
|
||||||
class VLMRequest(BaseModel):
|
|
||||||
image: str
|
|
||||||
prompt: str
|
|
||||||
model: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class VLMResponse(BaseModel):
|
|
||||||
brand: str
|
|
||||||
confidence: float
|
|
||||||
reasoning: str
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigUpdate(BaseModel):
|
|
||||||
device: str | None = None
|
|
||||||
yolo_model: str | None = None
|
|
||||||
yolo_confidence: float | None = None
|
|
||||||
vram_budget_mb: int | None = None
|
|
||||||
strategy: str | None = None
|
|
||||||
ocr_languages: list[str] | None = None
|
|
||||||
ocr_min_confidence: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# --- App ---
|
# --- App ---
|
||||||
@@ -281,6 +232,84 @@ def vlm(req: VLMRequest, request: Request):
|
|||||||
return VLMResponse(**result)
|
return VLMResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/detect_edges", response_model=AnalyzeRegionsResponse)
|
||||||
|
def detect_edges_endpoint(req: AnalyzeRegionsRequest, request: Request):
|
||||||
|
job_id, log_level = _job_ctx(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = _decode_image(req.image)
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Bad image: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
from models.cv.edges import detect_edges
|
||||||
|
|
||||||
|
edge_regions = detect_edges(
|
||||||
|
image,
|
||||||
|
canny_low=req.edge_canny_low,
|
||||||
|
canny_high=req.edge_canny_high,
|
||||||
|
hough_threshold=req.edge_hough_threshold,
|
||||||
|
hough_min_length=req.edge_hough_min_length,
|
||||||
|
hough_max_gap=req.edge_hough_max_gap,
|
||||||
|
pair_max_distance=req.edge_pair_max_distance,
|
||||||
|
pair_min_distance=req.edge_pair_min_distance,
|
||||||
|
)
|
||||||
|
infer_ms = (time.monotonic() - t0) * 1000
|
||||||
|
|
||||||
|
_gpu_log(job_id, log_level, "GPU:CV", "DEBUG",
|
||||||
|
f"Edge analysis {w}x{h}: {infer_ms:.0f}ms → {len(edge_regions)} regions")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Region analysis failed: {e}")
|
||||||
|
|
||||||
|
boxes = [RegionBox(**r) for r in edge_regions]
|
||||||
|
return AnalyzeRegionsResponse(regions=boxes)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/detect_edges/debug", response_model=AnalyzeRegionsDebugResponse)
|
||||||
|
def detect_edges_debug_endpoint(req: AnalyzeRegionsRequest, request: Request):
|
||||||
|
job_id, log_level = _job_ctx(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = _decode_image(req.image)
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Bad image: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
from models.cv.edges import detect_edges_debug
|
||||||
|
|
||||||
|
result = detect_edges_debug(
|
||||||
|
image,
|
||||||
|
canny_low=req.edge_canny_low,
|
||||||
|
canny_high=req.edge_canny_high,
|
||||||
|
hough_threshold=req.edge_hough_threshold,
|
||||||
|
hough_min_length=req.edge_hough_min_length,
|
||||||
|
hough_max_gap=req.edge_hough_max_gap,
|
||||||
|
pair_max_distance=req.edge_pair_max_distance,
|
||||||
|
pair_min_distance=req.edge_pair_min_distance,
|
||||||
|
)
|
||||||
|
infer_ms = (time.monotonic() - t0) * 1000
|
||||||
|
|
||||||
|
_gpu_log(job_id, log_level, "GPU:CV", "DEBUG",
|
||||||
|
f"Edge debug {w}x{h}: {infer_ms:.0f}ms → {len(result['regions'])} regions, "
|
||||||
|
f"{result['horizontal_count']} horizontals, {result['pair_count']} pairs")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Region debug analysis failed: {e}")
|
||||||
|
|
||||||
|
boxes = [RegionBox(**r) for r in result["regions"]]
|
||||||
|
response = AnalyzeRegionsDebugResponse(
|
||||||
|
regions=boxes,
|
||||||
|
edge_overlay_b64=result["edge_overlay_b64"],
|
||||||
|
lines_overlay_b64=result["lines_overlay_b64"],
|
||||||
|
horizontal_count=result["horizontal_count"],
|
||||||
|
pair_count=result["pair_count"],
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
|
|||||||
86
tests/detect/manual/list_scenarios.py
Normal file
86
tests/detect/manual/list_scenarios.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
List available scenarios and open one in the browser.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tests/detect/manual/list_scenarios.py # list all
|
||||||
|
python tests/detect/manual/list_scenarios.py --open 1 # open scenario #1
|
||||||
|
python tests/detect/manual/list_scenarios.py --open chelsea_edges_default # by label
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
kubectl port-forward svc/postgres 5432:5432 &
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="List and open scenarios")
|
||||||
|
parser.add_argument("--open", type=str, default=None,
|
||||||
|
help="Open scenario by number (1-based) or label")
|
||||||
|
parser.add_argument("--db-url",
|
||||||
|
default=os.environ.get("DATABASE_URL", "postgresql://mpr:mpr@localhost:5432/mpr"))
|
||||||
|
parser.add_argument("--base-url", default="http://mpr.local.ar/detection/")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.environ["DATABASE_URL"] = args.db_url
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(name)s — %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
from core.db.detect import list_scenarios
|
||||||
|
|
||||||
|
scenarios = list_scenarios()
|
||||||
|
|
||||||
|
if not scenarios:
|
||||||
|
logger.info("No scenarios found. Create one with:")
|
||||||
|
logger.info(" python tests/detect/manual/seed_scenario.py")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
logger.info("%3s %-35s %-12s %-18s %6s %s", "#", "Label", "Job ID", "Stage", "Frames", "Created")
|
||||||
|
logger.info("─" * 100)
|
||||||
|
|
||||||
|
for i, s in enumerate(scenarios, 1):
|
||||||
|
manifest = s.frames_manifest or {}
|
||||||
|
created = str(s.created_at)[:19] if s.created_at else "—"
|
||||||
|
job_short = str(s.job_id)[:8]
|
||||||
|
logger.info("%3d %-35s %-12s %-18s %6d %s",
|
||||||
|
i, s.scenario_label, job_short, s.stage, len(manifest), created)
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
|
||||||
|
if args.open:
|
||||||
|
target = None
|
||||||
|
try:
|
||||||
|
idx = int(args.open) - 1
|
||||||
|
if 0 <= idx < len(scenarios):
|
||||||
|
target = scenarios[idx]
|
||||||
|
except ValueError:
|
||||||
|
for s in scenarios:
|
||||||
|
if s.scenario_label == args.open:
|
||||||
|
target = s
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
logger.error("Scenario not found: %s", args.open)
|
||||||
|
return
|
||||||
|
|
||||||
|
url = f"{args.base_url}?job={target.job_id}#/editor/detect_edges"
|
||||||
|
logger.info("Opening: %s", url)
|
||||||
|
webbrowser.open(url)
|
||||||
|
else:
|
||||||
|
logger.info("To open a scenario:")
|
||||||
|
logger.info(" python tests/detect/manual/list_scenarios.py --open 1")
|
||||||
|
logger.info(" python tests/detect/manual/list_scenarios.py --open chelsea_edges_default")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
189
tests/detect/manual/run_region_analysis.py
Normal file
189
tests/detect/manual/run_region_analysis.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Run edge detection on test video frames — visual verification.
|
||||||
|
|
||||||
|
Uses a minimal 3-stage pipeline: extract_frames → filter_scenes → detect_edges.
|
||||||
|
No YOLO, OCR, or downstream stages.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tests/detect/manual/run_region_analysis.py [--job JOB_ID] [--port PORT] [--local]
|
||||||
|
|
||||||
|
Opens: http://mpr.local.ar/detection/?job=<JOB_ID>
|
||||||
|
|
||||||
|
What to look for in the frame viewer:
|
||||||
|
- "Edges" toggle appears (cyan)
|
||||||
|
- Cyan boxes around horizontal line pairs (hoarding edges)
|
||||||
|
- No boxes on players, ball, or sky
|
||||||
|
- Boxes concentrated in the lower third of the frame
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--job", default=f"cv-{int(_time.time()) % 100000}")
|
||||||
|
parser.add_argument("--port", type=int, default=6379)
|
||||||
|
parser.add_argument("--local", action="store_true", help="Run CV locally (no inference server)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.environ["REDIS_URL"] = f"redis://localhost:{args.port}/0"
|
||||||
|
if args.local:
|
||||||
|
os.environ.pop("INFERENCE_URL", None)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG, format="%(levelname)-7s %(name)s — %(message)s")
|
||||||
|
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
from langgraph.graph import END, StateGraph
|
||||||
|
|
||||||
|
from detect import emit
|
||||||
|
from detect.models import PipelineStats
|
||||||
|
from detect.profiles.soccer import SoccerBroadcastProfile
|
||||||
|
from detect.stages.frame_extractor import extract_frames
|
||||||
|
from detect.stages.scene_filter import scene_filter
|
||||||
|
from detect.stages.edge_detector import detect_edge_regions
|
||||||
|
from detect.state import DetectState
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VIDEO = "media/mpr/out/chunks/95043d50-4df6-4ac8-bbd5-2ba873117c6e/chunk_0000.mp4"
|
||||||
|
INFERENCE_URL = os.environ.get("INFERENCE_URL")
|
||||||
|
|
||||||
|
|
||||||
|
# --- 3-stage pipeline ---
|
||||||
|
|
||||||
|
NODES = ["extract_frames", "filter_scenes", "detect_edges"]
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_transition(job_id: str, node: str, status: str, node_states: dict):
|
||||||
|
node_states[node] = status
|
||||||
|
nodes = [{"id": n, "status": node_states.get(n, "pending")} for n in NODES]
|
||||||
|
emit.graph_update(job_id, nodes)
|
||||||
|
|
||||||
|
|
||||||
|
def node_extract(state: DetectState) -> dict:
|
||||||
|
job_id = state.get("job_id", "")
|
||||||
|
ns = state.get("_node_states", {n: "pending" for n in NODES})
|
||||||
|
_emit_transition(job_id, "extract_frames", "running", ns)
|
||||||
|
|
||||||
|
profile = SoccerBroadcastProfile()
|
||||||
|
config = profile.frame_extraction_config()
|
||||||
|
frames = extract_frames(state["video_path"], config, job_id=job_id)
|
||||||
|
|
||||||
|
_emit_transition(job_id, "extract_frames", "done", ns)
|
||||||
|
return {"frames": frames, "stats": PipelineStats(frames_extracted=len(frames)), "_node_states": ns}
|
||||||
|
|
||||||
|
|
||||||
|
def node_filter(state: DetectState) -> dict:
|
||||||
|
job_id = state.get("job_id", "")
|
||||||
|
ns = state.get("_node_states", {})
|
||||||
|
_emit_transition(job_id, "filter_scenes", "running", ns)
|
||||||
|
|
||||||
|
profile = SoccerBroadcastProfile()
|
||||||
|
config = profile.scene_filter_config()
|
||||||
|
kept = scene_filter(state.get("frames", []), config, job_id=job_id)
|
||||||
|
|
||||||
|
stats = state.get("stats", PipelineStats())
|
||||||
|
stats.frames_after_scene_filter = len(kept)
|
||||||
|
|
||||||
|
_emit_transition(job_id, "filter_scenes", "done", ns)
|
||||||
|
return {"filtered_frames": kept, "stats": stats, "_node_states": ns}
|
||||||
|
|
||||||
|
|
||||||
|
def node_edges(state: DetectState) -> dict:
|
||||||
|
job_id = state.get("job_id", "")
|
||||||
|
ns = state.get("_node_states", {})
|
||||||
|
_emit_transition(job_id, "detect_edges", "running", ns)
|
||||||
|
|
||||||
|
profile = SoccerBroadcastProfile()
|
||||||
|
config = profile.region_analysis_config()
|
||||||
|
regions = detect_edge_regions(
|
||||||
|
state.get("filtered_frames", []), config,
|
||||||
|
inference_url=INFERENCE_URL, job_id=job_id,
|
||||||
|
)
|
||||||
|
total = sum(len(r) for r in regions.values())
|
||||||
|
|
||||||
|
stats = state.get("stats", PipelineStats())
|
||||||
|
stats.cv_regions_detected = total
|
||||||
|
|
||||||
|
_emit_transition(job_id, "detect_edges", "done", ns)
|
||||||
|
return {"edge_regions_by_frame": regions, "stats": stats, "_node_states": ns}
|
||||||
|
|
||||||
|
|
||||||
|
def build_3stage_graph() -> StateGraph:
|
||||||
|
graph = StateGraph(DetectState)
|
||||||
|
graph.add_node("extract_frames", node_extract)
|
||||||
|
graph.add_node("filter_scenes", node_filter)
|
||||||
|
graph.add_node("detect_edges", node_edges)
|
||||||
|
graph.set_entry_point("extract_frames")
|
||||||
|
graph.add_edge("extract_frames", "filter_scenes")
|
||||||
|
graph.add_edge("filter_scenes", "detect_edges")
|
||||||
|
graph.add_edge("detect_edges", END)
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logger.info("Job: %s", args.job)
|
||||||
|
logger.info("Mode: %s", "remote" if INFERENCE_URL else "local")
|
||||||
|
logger.info("Pipeline: extract_frames → filter_scenes → detect_edges")
|
||||||
|
logger.info("Open: http://mpr.local.ar/detection/?job=%s", args.job)
|
||||||
|
input("\nPress Enter to start...")
|
||||||
|
|
||||||
|
emit.set_run_context(run_id=args.job, parent_job_id=args.job, run_type="initial", log_level="DEBUG")
|
||||||
|
|
||||||
|
graph = build_3stage_graph()
|
||||||
|
pipeline = graph.compile()
|
||||||
|
|
||||||
|
initial_state = {
|
||||||
|
"video_path": VIDEO,
|
||||||
|
"job_id": args.job,
|
||||||
|
"profile_name": "soccer_broadcast",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = pipeline.invoke(initial_state)
|
||||||
|
|
||||||
|
# Print results
|
||||||
|
regions = result.get("edge_regions_by_frame", {})
|
||||||
|
total = sum(len(boxes) for boxes in regions.values())
|
||||||
|
frames_with_regions = sum(1 for boxes in regions.values() if boxes)
|
||||||
|
|
||||||
|
logger.info("Results:")
|
||||||
|
logger.info(" Total edge regions: %d", total)
|
||||||
|
logger.info(" Frames with regions: %d / %d",
|
||||||
|
frames_with_regions, len(result.get("filtered_frames", [])))
|
||||||
|
|
||||||
|
for seq, boxes in sorted(regions.items()):
|
||||||
|
if boxes:
|
||||||
|
labels = [f"{b.label}({b.confidence:.2f})" for b in boxes]
|
||||||
|
logger.info(" Frame %d: %s", seq, ", ".join(labels))
|
||||||
|
|
||||||
|
logger.info("Done. Check the frame viewer for cyan boxes.")
|
||||||
|
logger.info("")
|
||||||
|
|
||||||
|
# --- Parameter sensitivity ---
|
||||||
|
logger.info("=== Parameter sensitivity (local debug) ===")
|
||||||
|
|
||||||
|
from detect.stages.edge_detector import _load_cv_edges
|
||||||
|
edges_mod = _load_cv_edges()
|
||||||
|
|
||||||
|
filtered = result.get("filtered_frames", [])
|
||||||
|
if filtered:
|
||||||
|
sample = filtered[0]
|
||||||
|
for canny_low in [20, 50, 80, 120]:
|
||||||
|
dbg = edges_mod.detect_edges_debug(sample.image, canny_low=canny_low)
|
||||||
|
logger.info(
|
||||||
|
" canny_low=%d → %d horizontals, %d pairs, %d regions",
|
||||||
|
canny_low, dbg["horizontal_count"], dbg["pair_count"], len(dbg["regions"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=== Editor test ===")
|
||||||
|
logger.info(" Dashboard: http://mpr.local.ar/detection/?job=%s", args.job)
|
||||||
|
logger.info(" Editor: http://mpr.local.ar/detection/?job=%s#/editor/detect_edges", args.job)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
176
tests/detect/manual/seed_scenario.py
Normal file
176
tests/detect/manual/seed_scenario.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Seed a scenario checkpoint from a video chunk.
|
||||||
|
|
||||||
|
Extracts frames via ffmpeg, uploads to MinIO, creates a StageCheckpoint
|
||||||
|
in Postgres marked as a scenario. No pipeline, no Redis, no SSE.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Postgres reachable (port-forward or local)
|
||||||
|
- MinIO reachable (port-forward or local)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# With K8s port-forwards:
|
||||||
|
kubectl port-forward svc/postgres 5432:5432 &
|
||||||
|
kubectl port-forward svc/minio 9000:9000 &
|
||||||
|
|
||||||
|
python tests/detect/manual/seed_scenario.py
|
||||||
|
|
||||||
|
# Custom video:
|
||||||
|
python tests/detect/manual/seed_scenario.py --video media/mpr/out/chunks/.../chunk_0001.mp4
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
http://mpr.local.ar/detection/?job=<JOB_ID>&stage=filter_scenes&editor=true
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Seed a scenario checkpoint")
|
||||||
|
parser.add_argument("--video",
|
||||||
|
default="media/mpr/out/chunks/95043d50-4df6-4ac8-bbd5-2ba873117c6e/chunk_0000.mp4")
|
||||||
|
parser.add_argument("--label", default="chelsea_edges_default",
|
||||||
|
help="Scenario label for bookmarking")
|
||||||
|
parser.add_argument("--fps", type=float, default=2.0, help="Frames per second to extract")
|
||||||
|
parser.add_argument("--max-frames", type=int, default=20, help="Max frames to extract")
|
||||||
|
parser.add_argument("--db-url",
|
||||||
|
default=os.environ.get("DATABASE_URL", "postgresql://mpr:mpr@localhost:5432/mpr"))
|
||||||
|
parser.add_argument("--s3-url",
|
||||||
|
default=os.environ.get("S3_ENDPOINT_URL", "http://localhost:9000"))
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Set env before imports
|
||||||
|
os.environ["DATABASE_URL"] = args.db_url
|
||||||
|
os.environ["S3_ENDPOINT_URL"] = args.s3_url
|
||||||
|
os.environ.setdefault("AWS_ACCESS_KEY_ID", "minioadmin")
|
||||||
|
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "minioadmin")
|
||||||
|
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(name)s — %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_frames_ffmpeg(video_path: str, fps: float, max_frames: int):
|
||||||
|
"""Extract frames using ffmpeg subprocess — no pipeline dependencies."""
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from detect.models import Frame
|
||||||
|
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="scenario_")
|
||||||
|
pattern = os.path.join(tmpdir, "frame_%04d.jpg")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-i", video_path,
|
||||||
|
"-vf", f"fps={fps}",
|
||||||
|
"-frames:v", str(max_frames),
|
||||||
|
"-q:v", "2",
|
||||||
|
pattern,
|
||||||
|
"-y", "-loglevel", "error",
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
frames = []
|
||||||
|
for jpg in sorted(Path(tmpdir).glob("frame_*.jpg")):
|
||||||
|
seq = int(jpg.stem.split("_")[1]) - 1 # 0-indexed
|
||||||
|
img = Image.open(jpg).convert("RGB")
|
||||||
|
image_array = np.array(img)
|
||||||
|
frame = Frame(
|
||||||
|
sequence=seq,
|
||||||
|
chunk_id=0,
|
||||||
|
timestamp=seq / fps,
|
||||||
|
image=image_array,
|
||||||
|
)
|
||||||
|
frames.append(frame)
|
||||||
|
jpg.unlink()
|
||||||
|
|
||||||
|
Path(tmpdir).rmdir()
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
video_path = args.video
|
||||||
|
|
||||||
|
if not os.path.exists(video_path):
|
||||||
|
logger.error("Video not found: %s", video_path)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logger.info("Video: %s", video_path)
|
||||||
|
logger.info("Job ID: %s", job_id)
|
||||||
|
logger.info("Label: %s", args.label)
|
||||||
|
|
||||||
|
# Ensure DB tables exist
|
||||||
|
from core.db.connection import create_tables
|
||||||
|
create_tables()
|
||||||
|
|
||||||
|
# Extract frames
|
||||||
|
logger.info("Extracting frames (fps=%.1f, max=%d)...", args.fps, args.max_frames)
|
||||||
|
frames = extract_frames_ffmpeg(video_path, args.fps, args.max_frames)
|
||||||
|
logger.info("Extracted %d frames", len(frames))
|
||||||
|
|
||||||
|
# Upload frames to MinIO
|
||||||
|
from detect.checkpoint.frames import save_frames
|
||||||
|
logger.info("Uploading frames to MinIO...")
|
||||||
|
manifest = save_frames(job_id, frames)
|
||||||
|
logger.info("Uploaded %d frames", len(manifest))
|
||||||
|
|
||||||
|
# Build frame metadata
|
||||||
|
frames_meta = [
|
||||||
|
{
|
||||||
|
"sequence": f.sequence,
|
||||||
|
"chunk_id": f.chunk_id,
|
||||||
|
"timestamp": f.timestamp,
|
||||||
|
"perceptual_hash": "",
|
||||||
|
}
|
||||||
|
for f in frames
|
||||||
|
]
|
||||||
|
|
||||||
|
# All frames are "filtered" (no scene filter ran)
|
||||||
|
filtered_sequences = [f.sequence for f in frames]
|
||||||
|
|
||||||
|
# Save checkpoint as scenario
|
||||||
|
from core.db.detect import save_stage_checkpoint
|
||||||
|
from detect.checkpoint.frames import CHECKPOINT_PREFIX
|
||||||
|
|
||||||
|
checkpoint = save_stage_checkpoint(
|
||||||
|
job_id=job_id,
|
||||||
|
stage="filter_scenes",
|
||||||
|
stage_index=1,
|
||||||
|
frames_prefix=f"{CHECKPOINT_PREFIX}/{job_id}/frames/",
|
||||||
|
frames_manifest={str(k): v for k, v in manifest.items()},
|
||||||
|
frames_meta=frames_meta,
|
||||||
|
filtered_frame_sequences=filtered_sequences,
|
||||||
|
stage_output_key="",
|
||||||
|
stats={"frames_extracted": len(frames), "frames_after_scene_filter": len(frames)},
|
||||||
|
config_snapshot={},
|
||||||
|
config_overrides={},
|
||||||
|
video_path=video_path,
|
||||||
|
profile_name="soccer_broadcast",
|
||||||
|
is_scenario=True,
|
||||||
|
scenario_label=args.label,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
logger.info("Scenario created:")
|
||||||
|
logger.info(" ID: %s", checkpoint.id)
|
||||||
|
logger.info(" Job: %s", job_id)
|
||||||
|
logger.info(" Label: %s", args.label)
|
||||||
|
logger.info(" Frames: %d", len(frames))
|
||||||
|
logger.info("")
|
||||||
|
logger.info("Open in editor:")
|
||||||
|
logger.info(" http://mpr.local.ar/detection/?job=%s#/editor/detect_edges", job_id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -161,3 +161,36 @@ def test_all_serialized_is_json_compatible():
|
|||||||
|
|
||||||
roundtrip = json.loads(json_str)
|
roundtrip = json.loads(json_str)
|
||||||
assert roundtrip["frame_meta"]["sequence"] == frame.sequence
|
assert roundtrip["frame_meta"]["sequence"] == frame.sequence
|
||||||
|
|
||||||
|
|
||||||
|
# --- OverrideProfile ---
|
||||||
|
|
||||||
|
def test_override_profile_region_analysis():
|
||||||
|
"""OverrideProfile must patch region_analysis_config with overrides."""
|
||||||
|
from detect.checkpoint.replay import OverrideProfile
|
||||||
|
from detect.profiles.soccer import SoccerBroadcastProfile
|
||||||
|
from detect.profiles.base import RegionAnalysisConfig
|
||||||
|
|
||||||
|
base = SoccerBroadcastProfile()
|
||||||
|
original = base.region_analysis_config()
|
||||||
|
|
||||||
|
overrides = {"region_analysis": {"edge_canny_low": 25, "edge_canny_high": 200}}
|
||||||
|
wrapped = OverrideProfile(base, overrides)
|
||||||
|
patched = wrapped.region_analysis_config()
|
||||||
|
|
||||||
|
assert isinstance(patched, RegionAnalysisConfig)
|
||||||
|
assert patched.edge_canny_low == 25
|
||||||
|
assert patched.edge_canny_high == 200
|
||||||
|
# Unmodified fields keep their defaults
|
||||||
|
assert patched.edge_hough_threshold == original.edge_hough_threshold
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_profile_passthrough():
|
||||||
|
"""OverrideProfile without region_analysis key passes through unchanged."""
|
||||||
|
from detect.checkpoint.replay import OverrideProfile
|
||||||
|
from detect.profiles.soccer import SoccerBroadcastProfile
|
||||||
|
|
||||||
|
base = SoccerBroadcastProfile()
|
||||||
|
wrapped = OverrideProfile(base, {"ocr": {"min_confidence": 0.1}})
|
||||||
|
config = wrapped.region_analysis_config()
|
||||||
|
assert config.edge_canny_low == base.region_analysis_config().edge_canny_low
|
||||||
|
|||||||
87
tests/detect/test_edge_sensitivity.py
Normal file
87
tests/detect/test_edge_sensitivity.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Parameter sensitivity tests for edge detection.
|
||||||
|
|
||||||
|
Verifies that adjusting parameters in expected directions produces
|
||||||
|
expected changes in detection counts. Uses synthetic frames with
|
||||||
|
known geometry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Load edges module directly
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"cv_edges", Path("gpu/models/cv/edges.py"),
|
||||||
|
)
|
||||||
|
_edges_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_edges_mod)
|
||||||
|
detect_edges = _edges_mod.detect_edges
|
||||||
|
detect_edges_debug = _edges_mod.detect_edges_debug
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_with_lines(n_pairs: int = 3, line_thickness: int = 2) -> np.ndarray:
|
||||||
|
"""Create a frame with multiple horizontal line pairs."""
|
||||||
|
image = np.zeros((1080, 1920, 3), dtype=np.uint8)
|
||||||
|
y_start = 300
|
||||||
|
for i in range(n_pairs):
|
||||||
|
y_top = y_start + i * 120
|
||||||
|
y_bot = y_top + 40 + i * 10 # varying gap
|
||||||
|
cv2.line(image, (100, y_top), (1800, y_top), (255, 255, 255), line_thickness)
|
||||||
|
cv2.line(image, (100, y_bot), (1800, y_bot), (255, 255, 255), line_thickness)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def test_canny_low_sensitivity():
|
||||||
|
"""Lowering canny_low should find same or more horizontal lines."""
|
||||||
|
image = _frame_with_lines()
|
||||||
|
|
||||||
|
high_threshold = detect_edges_debug(image, canny_low=100, canny_high=200)
|
||||||
|
low_threshold = detect_edges_debug(image, canny_low=30, canny_high=200)
|
||||||
|
|
||||||
|
assert low_threshold["horizontal_count"] >= high_threshold["horizontal_count"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hough_threshold_sensitivity():
|
||||||
|
"""Lowering hough_threshold should find same or more lines."""
|
||||||
|
image = _frame_with_lines()
|
||||||
|
|
||||||
|
strict = detect_edges_debug(image, hough_threshold=150)
|
||||||
|
lenient = detect_edges_debug(image, hough_threshold=40)
|
||||||
|
|
||||||
|
assert lenient["horizontal_count"] >= strict["horizontal_count"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_distance_range():
|
||||||
|
"""Widening pair distance range should find same or more pairs."""
|
||||||
|
image = _frame_with_lines()
|
||||||
|
|
||||||
|
narrow = detect_edges_debug(image, pair_min_distance=30, pair_max_distance=50)
|
||||||
|
wide = detect_edges_debug(image, pair_min_distance=10, pair_max_distance=200)
|
||||||
|
|
||||||
|
assert wide["pair_count"] >= narrow["pair_count"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hough_min_length_sensitivity():
|
||||||
|
"""Shorter min_length should find same or more lines."""
|
||||||
|
image = _frame_with_lines()
|
||||||
|
|
||||||
|
long_min = detect_edges_debug(image, hough_min_length=500)
|
||||||
|
short_min = detect_edges_debug(image, hough_min_length=50)
|
||||||
|
|
||||||
|
assert short_min["horizontal_count"] >= long_min["horizontal_count"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_frame_no_false_positives():
|
||||||
|
"""All parameter combinations on blank frame should produce zero regions."""
|
||||||
|
image = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
# Very lenient parameters
|
||||||
|
results = detect_edges(
|
||||||
|
image, canny_low=10, canny_high=50, hough_threshold=10,
|
||||||
|
hough_min_length=20, pair_min_distance=5, pair_max_distance=500,
|
||||||
|
)
|
||||||
|
assert results == []
|
||||||
195
tests/detect/test_region_analyzer.py
Normal file
195
tests/detect/test_region_analyzer.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Tests for CV region analysis stage — regression checks only."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from detect.models import BoundingBox, Frame
|
||||||
|
from detect.profiles.base import RegionAnalysisConfig
|
||||||
|
from detect.profiles.soccer import SoccerBroadcastProfile
|
||||||
|
|
||||||
|
|
||||||
|
# Load edges module directly — gpu/models/__init__.py has GPU-only imports
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"cv_edges", Path("gpu/models/cv/edges.py"),
|
||||||
|
)
|
||||||
|
_edges_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_edges_mod)
|
||||||
|
detect_edges = _edges_mod.detect_edges
|
||||||
|
detect_edges_debug = _edges_mod.detect_edges_debug
|
||||||
|
|
||||||
|
|
||||||
|
def _make_frame(seq: int = 0, h: int = 1080, w: int = 1920) -> Frame:
|
||||||
|
"""Create a blank frame for testing."""
|
||||||
|
image = np.zeros((h, w, 3), dtype=np.uint8)
|
||||||
|
return Frame(sequence=seq, chunk_id=0, timestamp=seq * 0.5, image=image)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_frame_with_lines(seq: int = 0) -> Frame:
|
||||||
|
"""Create a frame with two strong horizontal lines (simulates hoarding edges)."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
image = np.zeros((1080, 1920, 3), dtype=np.uint8)
|
||||||
|
cv2.line(image, (100, 800), (1800, 800), (255, 255, 255), 3)
|
||||||
|
cv2.line(image, (100, 860), (1800, 860), (255, 255, 255), 3)
|
||||||
|
return Frame(sequence=seq, chunk_id=0, timestamp=seq * 0.5, image=image)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
|
||||||
|
def test_soccer_profile_has_region_analysis_config():
|
||||||
|
profile = SoccerBroadcastProfile()
|
||||||
|
config = profile.region_analysis_config()
|
||||||
|
assert isinstance(config, RegionAnalysisConfig)
|
||||||
|
assert config.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_region_analysis_config_defaults():
|
||||||
|
config = RegionAnalysisConfig()
|
||||||
|
assert config.edge_canny_low == 50
|
||||||
|
assert config.edge_canny_high == 150
|
||||||
|
assert config.edge_hough_threshold == 80
|
||||||
|
|
||||||
|
|
||||||
|
# --- Edge detection (GPU side, loaded standalone) ---
|
||||||
|
|
||||||
|
def test_detect_edges_blank_frame():
|
||||||
|
"""Blank frame should produce no regions."""
|
||||||
|
image = np.zeros((1080, 1920, 3), dtype=np.uint8)
|
||||||
|
results = detect_edges(image)
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_edges_with_lines():
|
||||||
|
"""Frame with parallel horizontal lines should produce at least one region."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
image = np.zeros((1080, 1920, 3), dtype=np.uint8)
|
||||||
|
cv2.line(image, (100, 800), (1800, 800), (255, 255, 255), 3)
|
||||||
|
cv2.line(image, (100, 860), (1800, 860), (255, 255, 255), 3)
|
||||||
|
|
||||||
|
results = detect_edges(image)
|
||||||
|
assert len(results) >= 1
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
assert "x" in r and "y" in r and "w" in r and "h" in r
|
||||||
|
assert r["label"] == "edge_region"
|
||||||
|
assert 0 <= r["confidence"] <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_edges_returns_dict_format():
|
||||||
|
"""Each result must have the expected keys."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
image = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||||
|
cv2.line(image, (50, 400), (1200, 400), (255, 255, 255), 2)
|
||||||
|
cv2.line(image, (50, 450), (1200, 450), (255, 255, 255), 2)
|
||||||
|
|
||||||
|
results = detect_edges(image)
|
||||||
|
if results:
|
||||||
|
r = results[0]
|
||||||
|
expected_keys = {"x", "y", "w", "h", "confidence", "label"}
|
||||||
|
assert set(r.keys()) == expected_keys
|
||||||
|
|
||||||
|
|
||||||
|
# --- Debug function ---
|
||||||
|
|
||||||
|
def test_detect_edges_debug_returns_overlays():
|
||||||
|
"""Debug function must return overlay images and counts."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
image = np.zeros((1080, 1920, 3), dtype=np.uint8)
|
||||||
|
cv2.line(image, (100, 800), (1800, 800), (255, 255, 255), 3)
|
||||||
|
cv2.line(image, (100, 860), (1800, 860), (255, 255, 255), 3)
|
||||||
|
|
||||||
|
result = detect_edges_debug(image)
|
||||||
|
assert "regions" in result
|
||||||
|
assert "edge_overlay_b64" in result
|
||||||
|
assert "lines_overlay_b64" in result
|
||||||
|
assert "horizontal_count" in result
|
||||||
|
assert "pair_count" in result
|
||||||
|
assert isinstance(result["edge_overlay_b64"], str)
|
||||||
|
assert len(result["edge_overlay_b64"]) > 0 # non-empty base64
|
||||||
|
assert isinstance(result["lines_overlay_b64"], str)
|
||||||
|
assert len(result["lines_overlay_b64"]) > 0
|
||||||
|
assert result["horizontal_count"] >= 2
|
||||||
|
assert result["pair_count"] >= 1
|
||||||
|
assert len(result["regions"]) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_edges_debug_blank_frame():
|
||||||
|
"""Debug on blank frame should still return structure."""
|
||||||
|
image = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||||
|
result = detect_edges_debug(image)
|
||||||
|
assert result["regions"] == []
|
||||||
|
assert result["horizontal_count"] == 0
|
||||||
|
assert result["pair_count"] == 0
|
||||||
|
assert isinstance(result["edge_overlay_b64"], str)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stage function ---
|
||||||
|
|
||||||
|
def test_stage_disabled(monkeypatch):
|
||||||
|
"""When disabled, returns empty dict."""
|
||||||
|
monkeypatch.setattr("detect.emit.push_detect_event", lambda *a, **kw: None)
|
||||||
|
|
||||||
|
from detect.stages.edge_detector import detect_edge_regions
|
||||||
|
|
||||||
|
config = RegionAnalysisConfig(enabled=False)
|
||||||
|
result = detect_edge_regions([_make_frame()], config, job_id="test")
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_local_blank(monkeypatch):
|
||||||
|
"""Local mode on blank frames returns empty boxes."""
|
||||||
|
monkeypatch.setattr("detect.emit.push_detect_event", lambda *a, **kw: None)
|
||||||
|
|
||||||
|
from detect.stages.edge_detector import detect_edge_regions
|
||||||
|
|
||||||
|
config = RegionAnalysisConfig()
|
||||||
|
result = detect_edge_regions([_make_frame()], config, job_id="test")
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert all(isinstance(v, list) for v in result.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_local_with_lines(monkeypatch):
|
||||||
|
"""Local mode on frame with lines should find regions."""
|
||||||
|
monkeypatch.setattr("detect.emit.push_detect_event", lambda *a, **kw: None)
|
||||||
|
|
||||||
|
from detect.stages.edge_detector import detect_edge_regions
|
||||||
|
|
||||||
|
config = RegionAnalysisConfig()
|
||||||
|
frame = _make_frame_with_lines()
|
||||||
|
result = detect_edge_regions([frame], config, job_id="test")
|
||||||
|
|
||||||
|
boxes = result.get(frame.sequence, [])
|
||||||
|
assert len(boxes) >= 1
|
||||||
|
assert all(isinstance(b, BoundingBox) for b in boxes)
|
||||||
|
assert all(b.label == "edge_region" for b in boxes)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Graph wiring ---
|
||||||
|
|
||||||
|
def test_detect_edges_in_nodes():
|
||||||
|
"""detect_edges must be in the pipeline node list."""
|
||||||
|
from detect.graph import NODES, NODE_FUNCTIONS
|
||||||
|
|
||||||
|
assert "detect_edges" in NODES
|
||||||
|
node_names = [name for name, _ in NODE_FUNCTIONS]
|
||||||
|
assert "detect_edges" in node_names
|
||||||
|
|
||||||
|
# Must be after filter_scenes, before detect_objects
|
||||||
|
idx = NODES.index("detect_edges")
|
||||||
|
assert NODES[idx - 1] == "filter_scenes"
|
||||||
|
assert NODES[idx + 1] == "detect_objects"
|
||||||
|
|
||||||
|
|
||||||
|
# --- State ---
|
||||||
|
|
||||||
|
def test_state_has_edge_regions_field():
|
||||||
|
from detect.state import DetectState
|
||||||
|
|
||||||
|
hints = DetectState.__annotations__
|
||||||
|
assert "edge_regions_by_frame" in hints
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from detect.profiles.soccer import SoccerBroadcastProfile
|
from detect.profiles.soccer import SoccerBroadcastProfile
|
||||||
from detect.checkpoint.replay import OverrideProfile
|
from detect.profiles.base import RegionAnalysisConfig
|
||||||
|
from detect.checkpoint.replay import OverrideProfile, replay_single_stage
|
||||||
|
|
||||||
|
|
||||||
def test_override_profile_patches_ocr():
|
def test_override_profile_patches_ocr():
|
||||||
@@ -65,3 +66,31 @@ def test_override_profile_ignores_unknown_fields():
|
|||||||
|
|
||||||
assert not hasattr(config, "nonexistent_field")
|
assert not hasattr(config, "nonexistent_field")
|
||||||
assert config.min_confidence == base.ocr_config().min_confidence
|
assert config.min_confidence == base.ocr_config().min_confidence
|
||||||
|
|
||||||
|
|
||||||
|
# --- OverrideProfile for region_analysis ---
|
||||||
|
|
||||||
|
def test_override_profile_patches_region_analysis():
|
||||||
|
base = SoccerBroadcastProfile()
|
||||||
|
overrides = {"region_analysis": {"edge_canny_low": 25, "edge_canny_high": 200}}
|
||||||
|
profile = OverrideProfile(base, overrides)
|
||||||
|
|
||||||
|
config = profile.region_analysis_config()
|
||||||
|
|
||||||
|
assert isinstance(config, RegionAnalysisConfig)
|
||||||
|
assert config.edge_canny_low == 25
|
||||||
|
assert config.edge_canny_high == 200
|
||||||
|
# Unchanged fields keep defaults
|
||||||
|
assert config.edge_hough_threshold == base.region_analysis_config().edge_hough_threshold
|
||||||
|
|
||||||
|
|
||||||
|
# --- replay_single_stage ---
|
||||||
|
|
||||||
|
def test_replay_single_stage_unknown_stage():
|
||||||
|
with pytest.raises(ValueError, match="Unknown stage"):
|
||||||
|
replay_single_stage("fake-job", "nonexistent_stage")
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_single_stage_first_stage():
|
||||||
|
with pytest.raises(ValueError, match="Cannot replay the first stage"):
|
||||||
|
replay_single_stage("fake-job", "extract_frames")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from detect.stages import list_stages, get_stage, get_palette
|
|||||||
|
|
||||||
|
|
||||||
EXPECTED_STAGES = [
|
EXPECTED_STAGES = [
|
||||||
"extract_frames", "filter_scenes", "detect_objects", "preprocess",
|
"extract_frames", "filter_scenes", "detect_edges", "detect_objects", "preprocess",
|
||||||
"run_ocr", "match_brands", "escalate_vlm", "escalate_cloud", "compile_report",
|
"run_ocr", "match_brands", "escalate_vlm", "escalate_cloud", "compile_report",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import BrandTablePanel from './panels/BrandTablePanel.vue'
|
|||||||
import TimelinePanel from './panels/TimelinePanel.vue'
|
import TimelinePanel from './panels/TimelinePanel.vue'
|
||||||
import CostStatsPanel from './panels/CostStatsPanel.vue'
|
import CostStatsPanel from './panels/CostStatsPanel.vue'
|
||||||
import SourceSelector from './panels/SourceSelector.vue'
|
import SourceSelector from './panels/SourceSelector.vue'
|
||||||
|
import StageConfigSliders from './components/StageConfigSliders.vue'
|
||||||
import type { StatsUpdate, RunContext } from './types/sse-contract'
|
import type { StatsUpdate, RunContext } from './types/sse-contract'
|
||||||
|
import type { FrameOverlay } from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
||||||
import { usePipelineStore } from './stores/pipeline'
|
import { usePipelineStore } from './stores/pipeline'
|
||||||
|
|
||||||
const pipeline = usePipelineStore()
|
const pipeline = usePipelineStore()
|
||||||
@@ -21,9 +23,10 @@ const stats = ref<StatsUpdate | null>(null)
|
|||||||
const runContext = ref<RunContext | null>(null)
|
const runContext = ref<RunContext | null>(null)
|
||||||
const status = ref<'idle' | 'live' | 'processing' | 'error'>('idle')
|
const status = ref<'idle' | 'live' | 'processing' | 'error'>('idle')
|
||||||
const logPanel = ref<{ clear: () => void } | null>(null)
|
const logPanel = ref<{ clear: () => void } | null>(null)
|
||||||
|
const sseConnected = ref(false)
|
||||||
|
|
||||||
// No job selected → open source selector
|
// No job selected and no hash route → open source selector
|
||||||
if (!jobParam) {
|
if (!jobParam && !window.location.hash.replace(/^#\/?/, '')) {
|
||||||
pipeline.openSourceSelector()
|
pipeline.openSourceSelector()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +38,6 @@ const source = new SSEDataSource({
|
|||||||
|
|
||||||
source.on<StatsUpdate>('stats_update', (e) => {
|
source.on<StatsUpdate>('stats_update', (e) => {
|
||||||
stats.value = e
|
stats.value = e
|
||||||
// Capture run context from first event that carries it
|
|
||||||
if (!runContext.value && e.run_id) {
|
if (!runContext.value && e.run_id) {
|
||||||
runContext.value = {
|
runContext.value = {
|
||||||
run_id: (e as any).run_id,
|
run_id: (e as any).run_id,
|
||||||
@@ -53,7 +55,7 @@ source.on<{ report?: { status?: string, error?: string } }>('job_complete', (e)
|
|||||||
|
|
||||||
// Resizable splits
|
// Resizable splits
|
||||||
const pipelineWidth = ref(320)
|
const pipelineWidth = ref(320)
|
||||||
const detectionsFlex = ref(3) // ratio for detections vs stats
|
const detectionsFlex = ref(3)
|
||||||
const viewerHeight = ref(240)
|
const viewerHeight = ref(240)
|
||||||
const timelineFlex = ref(1)
|
const timelineFlex = ref(1)
|
||||||
const tableFlex = ref(1)
|
const tableFlex = ref(1)
|
||||||
@@ -82,11 +84,19 @@ const statusMap: Record<string, 'idle' | 'live' | 'processing' | 'error'> = {
|
|||||||
live: 'live',
|
live: 'live',
|
||||||
error: 'error',
|
error: 'error',
|
||||||
}
|
}
|
||||||
const checkStatus = () => { status.value = statusMap[source.status.value] ?? 'idle' }
|
const checkStatus = () => {
|
||||||
|
if (sseConnected.value) {
|
||||||
|
status.value = statusMap[source.status.value] ?? 'idle'
|
||||||
|
}
|
||||||
|
}
|
||||||
setInterval(checkStatus, 500)
|
setInterval(checkStatus, 500)
|
||||||
|
|
||||||
if (jobId.value) {
|
// Only connect SSE for live pipeline runs (no hash route = dashboard mode)
|
||||||
|
// Scenario URLs use hash routing and load from checkpoint instead
|
||||||
|
const isScenarioMode = pipeline.isEditing || pipeline.layoutMode !== 'normal'
|
||||||
|
if (jobId.value && !isScenarioMode) {
|
||||||
source.connect()
|
source.connect()
|
||||||
|
sseConnected.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopPipeline() {
|
async function stopPipeline() {
|
||||||
@@ -96,6 +106,37 @@ async function stopPipeline() {
|
|||||||
} catch { /* ignore — UI will see the cancel event via SSE */ }
|
} catch { /* ignore — UI will see the cancel event via SSE */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Current frame image (base64) — tracked for the editor's direct GPU calls
|
||||||
|
const currentFrameImage = ref<string | null>(null)
|
||||||
|
const currentFrameRef = ref<number | null>(null)
|
||||||
|
|
||||||
|
source.on<{ frame_ref: number; jpeg_b64: string }>('frame_update', (e) => {
|
||||||
|
currentFrameImage.value = e.jpeg_b64
|
||||||
|
currentFrameRef.value = e.frame_ref
|
||||||
|
})
|
||||||
|
|
||||||
|
// Debug overlays from replay-stage results
|
||||||
|
const editorOverlays = ref<FrameOverlay[]>([])
|
||||||
|
|
||||||
|
function onReplayResult(result: {
|
||||||
|
debug?: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
|
||||||
|
}) {
|
||||||
|
const overlays: FrameOverlay[] = []
|
||||||
|
if (result.debug) {
|
||||||
|
// Take first frame's debug data (editor shows one frame at a time)
|
||||||
|
const firstDebug = Object.values(result.debug)[0]
|
||||||
|
if (firstDebug) {
|
||||||
|
if (firstDebug.edge_overlay_b64) {
|
||||||
|
overlays.push({ src: firstDebug.edge_overlay_b64, label: 'Canny edges', visible: true, opacity: 0.4 })
|
||||||
|
}
|
||||||
|
if (firstDebug.lines_overlay_b64) {
|
||||||
|
overlays.push({ src: firstDebug.lines_overlay_b64, label: 'Hough lines', visible: true, opacity: 0.5 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editorOverlays.value = overlays
|
||||||
|
}
|
||||||
|
|
||||||
function onJobStarted(newJobId: string) {
|
function onJobStarted(newJobId: string) {
|
||||||
jobId.value = newJobId
|
jobId.value = newJobId
|
||||||
// Reset UI state
|
// Reset UI state
|
||||||
@@ -113,7 +154,7 @@ function onJobStarted(newJobId: string) {
|
|||||||
source.disconnect()
|
source.disconnect()
|
||||||
source.setUrl(`/api/detect/stream/${newJobId}`)
|
source.setUrl(`/api/detect/stream/${newJobId}`)
|
||||||
source.connect()
|
source.connect()
|
||||||
// Switch to normal layout (reset sets it to normal already)
|
sseConnected.value = true
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -131,7 +172,7 @@ function onJobStarted(newJobId: string) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="jobId && (status === 'live' || status === 'processing')"
|
v-if="sseConnected && (status === 'live' || status === 'processing')"
|
||||||
class="header-btn stop-btn"
|
class="header-btn stop-btn"
|
||||||
title="Stop pipeline"
|
title="Stop pipeline"
|
||||||
@click="stopPipeline"
|
@click="stopPipeline"
|
||||||
@@ -198,11 +239,17 @@ function onJobStarted(newJobId: string) {
|
|||||||
<Panel :title="`Region Editor — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
|
<Panel :title="`Region Editor — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
|
||||||
<div class="editor-placeholder">
|
<div class="editor-placeholder">
|
||||||
<div class="editor-frame">
|
<div class="editor-frame">
|
||||||
<FramePanel :source="source" :status="status" />
|
<FramePanel :source="source" :status="status" :overlays="editorOverlays" />
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-tools">
|
<div class="editor-tools">
|
||||||
<p>Stage: <strong>{{ pipeline.editorStage }}</strong></p>
|
<StageConfigSliders
|
||||||
<p>Draw polygons to define regions</p>
|
v-if="pipeline.editorStage"
|
||||||
|
:stage="pipeline.editorStage"
|
||||||
|
:job-id="jobId"
|
||||||
|
:frame-image="currentFrameImage"
|
||||||
|
:frame-ref="currentFrameRef"
|
||||||
|
@replay-result="onReplayResult"
|
||||||
|
/>
|
||||||
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
|
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,10 +280,24 @@ function onJobStarted(newJobId: string) {
|
|||||||
<!-- Bottom bar: Log or Blob viewer depending on mode -->
|
<!-- Bottom bar: Log or Blob viewer depending on mode -->
|
||||||
<div class="log-row">
|
<div class="log-row">
|
||||||
<template v-if="pipeline.layoutMode === 'bbox_editor'">
|
<template v-if="pipeline.layoutMode === 'bbox_editor'">
|
||||||
<Panel :title="`Blobs — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
|
<Panel :title="`Debug Overlays — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
|
||||||
<div class="blob-viewer">
|
<div class="overlay-controls">
|
||||||
<div class="blob-placeholder">
|
<template v-if="editorOverlays.length > 0">
|
||||||
Blob viewer: crops, preprocessed images, OCR results for {{ pipeline.editorStage }}
|
<label v-for="(overlay, idx) in editorOverlays" :key="idx" class="overlay-toggle">
|
||||||
|
<input type="checkbox" v-model="overlay.visible" />
|
||||||
|
<span class="overlay-label">{{ overlay.label }}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0" max="1" step="0.05"
|
||||||
|
:value="overlay.opacity ?? 0.5"
|
||||||
|
@input="(e) => overlay.opacity = Number((e.target as HTMLInputElement).value)"
|
||||||
|
class="opacity-slider"
|
||||||
|
/>
|
||||||
|
<span class="opacity-value">{{ Math.round((overlay.opacity ?? 0.5) * 100) }}%</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<div v-else class="blob-placeholder">
|
||||||
|
Run analysis with debug enabled to see edge and line overlays
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -473,11 +534,6 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
|
|||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blob-viewer {
|
|
||||||
height: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-placeholder {
|
.blob-placeholder {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -485,6 +541,42 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-toggle input[type="checkbox"] {
|
||||||
|
accent-color: #00bcd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-label {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-slider {
|
||||||
|
width: 80px;
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-value {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Source selector */
|
/* Source selector */
|
||||||
.source-selector {
|
.source-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
422
ui/detection-app/src/components/StageConfigSliders.vue
Normal file
422
ui/detection-app/src/components/StageConfigSliders.vue
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
|
||||||
|
interface ConfigField {
|
||||||
|
name: string
|
||||||
|
type: string // "bool" | "int" | "float" | "str"
|
||||||
|
default: unknown
|
||||||
|
description: string
|
||||||
|
min: number | null
|
||||||
|
max: number | null
|
||||||
|
options: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Stage name (e.g. "detect_edges") */
|
||||||
|
stage: string
|
||||||
|
/** Job ID for replay-stage calls (used as fallback) */
|
||||||
|
jobId: string
|
||||||
|
/** Currently displayed frame image (base64 JPEG) — sent directly to GPU for fast feedback */
|
||||||
|
frameImage?: string | null
|
||||||
|
/** Currently displayed frame sequence number */
|
||||||
|
frameRef?: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** Emitted when replay returns new regions */
|
||||||
|
'replay-result': [result: {
|
||||||
|
regions_by_frame: Record<string, unknown[]>
|
||||||
|
debug: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
|
||||||
|
}]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const fields = ref<ConfigField[]>([])
|
||||||
|
const values = ref<Record<string, unknown>>({})
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const regionCount = ref<number | null>(null)
|
||||||
|
const debugEnabled = ref(true)
|
||||||
|
|
||||||
|
// Fetch stage config fields from API
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/detect/config/stages/${props.stage}`)
|
||||||
|
if (!resp.ok) {
|
||||||
|
error.value = `Failed to load config: ${resp.status}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await resp.json()
|
||||||
|
fields.value = data.config_fields ?? []
|
||||||
|
|
||||||
|
// Initialize values from defaults
|
||||||
|
for (const f of fields.value) {
|
||||||
|
values.value[f.name] = f.default
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = `Failed to load config: ${e}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const numericFields = computed(() => fields.value.filter(f => f.type === 'int' || f.type === 'float'))
|
||||||
|
const boolFields = computed(() => fields.value.filter(f => f.type === 'bool'))
|
||||||
|
|
||||||
|
function resetDefaults() {
|
||||||
|
for (const f of fields.value) {
|
||||||
|
values.value[f.name] = f.default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function onSliderChange() {
|
||||||
|
// Debounce — wait 300ms after last change before calling replay
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => applyReplay(), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyReplay() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
// Direct GPU call — send the frame image + current slider params
|
||||||
|
// Skip checkpoint/replay path for ~50-100ms round trips instead of seconds
|
||||||
|
if (props.frameImage && props.stage === 'detect_edges') {
|
||||||
|
await callGpuDirect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: replay-stage path (for stages without direct GPU endpoint)
|
||||||
|
if (!props.jobId) {
|
||||||
|
error.value = 'No frame image or job ID available'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await callReplayStage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callGpuDirect() {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
image: props.frameImage,
|
||||||
|
}
|
||||||
|
// Pass current slider values as edge detection params
|
||||||
|
for (const f of fields.value) {
|
||||||
|
if (f.name !== 'enabled') {
|
||||||
|
body[f.name] = values.value[f.name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = debugEnabled.value
|
||||||
|
? '/api/detect/gpu/detect_edges/debug'
|
||||||
|
: '/api/detect/gpu/detect_edges'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const detail = await resp.text()
|
||||||
|
error.value = `GPU call failed: ${detail}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json()
|
||||||
|
regionCount.value = data.regions?.length ?? 0
|
||||||
|
|
||||||
|
// Build result in the same shape the parent expects
|
||||||
|
const frameKey = String(props.frameRef ?? 0)
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
regions_by_frame: { [frameKey]: data.regions ?? [] },
|
||||||
|
debug: {},
|
||||||
|
}
|
||||||
|
if (data.edge_overlay_b64 || data.lines_overlay_b64) {
|
||||||
|
result.debug = {
|
||||||
|
[frameKey]: {
|
||||||
|
edge_overlay_b64: data.edge_overlay_b64 ?? '',
|
||||||
|
lines_overlay_b64: data.lines_overlay_b64 ?? '',
|
||||||
|
horizontal_count: data.horizontal_count ?? 0,
|
||||||
|
pair_count: data.pair_count ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('replay-result', result as any)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = `GPU call failed: ${e}`
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callReplayStage() {
|
||||||
|
const overrides: Record<string, unknown> = {}
|
||||||
|
for (const f of fields.value) {
|
||||||
|
if (values.value[f.name] !== f.default) {
|
||||||
|
overrides[f.name] = values.value[f.name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideKey = stageToOverrideKey(props.stage)
|
||||||
|
const configOverrides = Object.keys(overrides).length > 0
|
||||||
|
? { [overrideKey]: overrides }
|
||||||
|
: null
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
job_id: props.jobId,
|
||||||
|
stage: props.stage,
|
||||||
|
frame_refs: props.frameRef != null ? [props.frameRef] : null,
|
||||||
|
config_overrides: configOverrides,
|
||||||
|
debug: debugEnabled.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/detect/replay-stage', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const detail = await resp.text()
|
||||||
|
error.value = `Replay failed: ${detail}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resp.json()
|
||||||
|
regionCount.value = result.region_count ?? 0
|
||||||
|
emit('replay-result', result)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = `Replay failed: ${e}`
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageToOverrideKey(stage: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
detect_edges: 'region_analysis',
|
||||||
|
detect_objects: 'detection',
|
||||||
|
run_ocr: 'ocr',
|
||||||
|
match_brands: 'resolver',
|
||||||
|
}
|
||||||
|
return map[stage] || stage
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sliders-panel">
|
||||||
|
<div class="sliders-header">
|
||||||
|
<span class="sliders-title">{{ stage.replace(/_/g, ' ') }}</span>
|
||||||
|
<button class="sliders-reset" @click="resetDefaults" title="Reset to defaults">Reset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="sliders-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="sliders-list">
|
||||||
|
<!-- Boolean fields -->
|
||||||
|
<label v-for="f in boolFields" :key="f.name" class="slider-field bool-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="!!values[f.name]"
|
||||||
|
@change="(e) => { values[f.name] = (e.target as HTMLInputElement).checked; onSliderChange() }"
|
||||||
|
/>
|
||||||
|
<span class="field-label" :title="f.description">{{ f.name.replace(/_/g, ' ') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Numeric fields (range sliders) -->
|
||||||
|
<div v-for="f in numericFields" :key="f.name" class="slider-field">
|
||||||
|
<div class="field-header">
|
||||||
|
<span class="field-label" :title="f.description">{{ f.name.replace(/^edge_/, '').replace(/_/g, ' ') }}</span>
|
||||||
|
<span class="field-value">{{ values[f.name] }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:min="f.min ?? 0"
|
||||||
|
:max="f.max ?? 500"
|
||||||
|
:step="f.type === 'float' ? 0.01 : 1"
|
||||||
|
:value="values[f.name] as number"
|
||||||
|
@input="(e) => { values[f.name] = Number((e.target as HTMLInputElement).value); onSliderChange() }"
|
||||||
|
/>
|
||||||
|
<div class="field-range">
|
||||||
|
<span>{{ f.min ?? 0 }}</span>
|
||||||
|
<span>{{ f.max ?? 500 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug overlay toggle -->
|
||||||
|
<label class="slider-field bool-field debug-toggle">
|
||||||
|
<input type="checkbox" v-model="debugEnabled" @change="onSliderChange" />
|
||||||
|
<span class="field-label">Show edge overlays</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Feedback -->
|
||||||
|
<div class="sliders-footer">
|
||||||
|
<button class="apply-btn" :disabled="loading" @click="applyReplay">
|
||||||
|
{{ loading ? 'Running...' : 'Apply' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="regionCount != null" class="region-count">
|
||||||
|
{{ regionCount }} regions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sliders-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliders-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
border-bottom: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliders-title {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliders-reset {
|
||||||
|
background: var(--surface-3);
|
||||||
|
border: 1px solid var(--surface-3);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sliders-reset:hover { background: var(--surface-2); }
|
||||||
|
|
||||||
|
.sliders-error {
|
||||||
|
color: var(--status-error);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliders-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bool-field {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-range {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range slider styling */
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
accent-color: #00bcd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-toggle {
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
border-top: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliders-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
border-top: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-btn {
|
||||||
|
background: #00bcd4;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: #000;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.apply-btn:hover { opacity: 0.85; }
|
||||||
|
.apply-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.region-count {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Panel } from 'mpr-ui-framework'
|
import { Panel } from 'mpr-ui-framework'
|
||||||
import FrameRenderer from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
import FrameRenderer from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
||||||
import type { FrameBBox } from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
import type { FrameBBox, FrameOverlay } from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
||||||
import type { DataSource } from 'mpr-ui-framework'
|
import type { DataSource } from 'mpr-ui-framework'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
source: DataSource
|
source: DataSource
|
||||||
status?: 'idle' | 'live' | 'processing' | 'error'
|
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||||
|
/** Debug overlay layers passed from parent (editor mode) */
|
||||||
|
overlays?: FrameOverlay[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const imageSrc = ref('')
|
const imageSrc = ref('')
|
||||||
@@ -19,6 +21,7 @@ const stageStatus = ref<Record<string, string>>({})
|
|||||||
const activeToggles = ref<Set<string>>(new Set())
|
const activeToggles = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const STAGE_TABS = [
|
const STAGE_TABS = [
|
||||||
|
{ key: 'detect_edges', label: 'Edges', color: '#00bcd4' },
|
||||||
{ key: 'detect_objects', label: 'YOLO', color: '#f5a623' },
|
{ key: 'detect_objects', label: 'YOLO', color: '#f5a623' },
|
||||||
{ key: 'preprocess', label: 'Prep', color: '#e0e0e0' },
|
{ key: 'preprocess', label: 'Prep', color: '#e0e0e0' },
|
||||||
{ key: 'run_ocr', label: 'OCR', color: '#ff8c42' },
|
{ key: 'run_ocr', label: 'OCR', color: '#ff8c42' },
|
||||||
@@ -166,7 +169,7 @@ function hasData(key: string): boolean {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="frame-content">
|
<div class="frame-content">
|
||||||
<FrameRenderer :image-src="imageSrc" :boxes="visibleBoxes" />
|
<FrameRenderer :image-src="imageSrc" :boxes="visibleBoxes" :overlays="overlays" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Pipeline store — run state, transport controls, checkpoint status.
|
* Pipeline store — run state, transport controls, checkpoint status.
|
||||||
*
|
*
|
||||||
|
* Layout is driven by URL hash:
|
||||||
|
* #/ → normal dashboard
|
||||||
|
* #/editor/<stage> → bbox/region editor for that stage
|
||||||
|
* #/config/<stage> → stage config editor
|
||||||
|
* #/source → source selector
|
||||||
|
*
|
||||||
* State shape defined in types/store-state.ts.
|
* State shape defined in types/store-state.ts.
|
||||||
* This file is just the Pinia binding.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { NodeState } from '../types/store-state'
|
import type { NodeState } from '../types/store-state'
|
||||||
import type { CheckpointInfo } from '../types/sse-contract'
|
import type { CheckpointInfo } from '../types/sse-contract'
|
||||||
|
|
||||||
@@ -21,15 +26,65 @@ export const usePipelineStore = defineStore('pipeline', () => {
|
|||||||
const checkpoints = ref<CheckpointInfo[]>([])
|
const checkpoints = ref<CheckpointInfo[]>([])
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
// Layout mode
|
// Layout mode — synced with URL hash
|
||||||
const layoutMode = ref<string>('normal') // normal | bbox_editor | stage_editor | source_selector
|
const layoutMode = ref<string>('normal')
|
||||||
const editorStage = ref<string | null>(null) // which stage's editor is open
|
const editorStage = ref<string | null>(null)
|
||||||
|
|
||||||
const isRunning = computed(() => status.value === 'running')
|
const isRunning = computed(() => status.value === 'running')
|
||||||
const isPaused = computed(() => status.value === 'paused')
|
const isPaused = computed(() => status.value === 'paused')
|
||||||
const canReplay = computed(() => checkpoints.value.length > 0)
|
const canReplay = computed(() => checkpoints.value.length > 0)
|
||||||
const isEditing = computed(() => layoutMode.value !== 'normal')
|
const isEditing = computed(() => layoutMode.value !== 'normal')
|
||||||
|
|
||||||
|
// --- Hash routing ---
|
||||||
|
|
||||||
|
function parseHash(hash: string) {
|
||||||
|
const path = hash.replace(/^#\/?/, '')
|
||||||
|
if (!path || path === 'dashboard') {
|
||||||
|
return { mode: 'normal', stage: null }
|
||||||
|
}
|
||||||
|
if (path === 'source') {
|
||||||
|
return { mode: 'source_selector', stage: null }
|
||||||
|
}
|
||||||
|
const editorMatch = path.match(/^editor\/(.+)$/)
|
||||||
|
if (editorMatch) {
|
||||||
|
return { mode: 'bbox_editor', stage: editorMatch[1] }
|
||||||
|
}
|
||||||
|
const configMatch = path.match(/^config\/(.+)$/)
|
||||||
|
if (configMatch) {
|
||||||
|
return { mode: 'stage_editor', stage: configMatch[1] }
|
||||||
|
}
|
||||||
|
return { mode: 'normal', stage: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHash() {
|
||||||
|
const { mode, stage } = parseHash(window.location.hash)
|
||||||
|
layoutMode.value = mode
|
||||||
|
editorStage.value = stage
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHash() {
|
||||||
|
let hash = '#/'
|
||||||
|
if (layoutMode.value === 'bbox_editor' && editorStage.value) {
|
||||||
|
hash = `#/editor/${editorStage.value}`
|
||||||
|
} else if (layoutMode.value === 'stage_editor' && editorStage.value) {
|
||||||
|
hash = `#/config/${editorStage.value}`
|
||||||
|
} else if (layoutMode.value === 'source_selector') {
|
||||||
|
hash = '#/source'
|
||||||
|
}
|
||||||
|
if (window.location.hash !== hash) {
|
||||||
|
window.history.pushState(null, '', hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync hash → state on load and popstate (back/forward)
|
||||||
|
applyHash()
|
||||||
|
window.addEventListener('popstate', applyHash)
|
||||||
|
|
||||||
|
// Sync state → hash when layout changes
|
||||||
|
watch([layoutMode, editorStage], updateHash)
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
function setJob(id: string) {
|
function setJob(id: string) {
|
||||||
jobId.value = id
|
jobId.value = id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface BoundingBoxEvent {
|
|||||||
label: string;
|
label: string;
|
||||||
resolved_brand: string | null;
|
resolved_brand: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
|
stage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrandSummary {
|
export interface BrandSummary {
|
||||||
@@ -47,6 +48,7 @@ export interface GraphUpdate {
|
|||||||
export interface StatsUpdate {
|
export interface StatsUpdate {
|
||||||
frames_extracted: number;
|
frames_extracted: number;
|
||||||
frames_after_scene_filter: number;
|
frames_after_scene_filter: number;
|
||||||
|
cv_regions_detected: number;
|
||||||
regions_detected: number;
|
regions_detected: number;
|
||||||
regions_resolved_by_ocr: number;
|
regions_resolved_by_ocr: number;
|
||||||
regions_escalated_to_local_vlm: number;
|
regions_escalated_to_local_vlm: number;
|
||||||
@@ -104,6 +106,8 @@ export interface RunContext {
|
|||||||
|
|
||||||
export interface CheckpointInfo {
|
export interface CheckpointInfo {
|
||||||
stage: string;
|
stage: string;
|
||||||
|
is_scenario: boolean;
|
||||||
|
scenario_label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReplayRequest {
|
export interface ReplayRequest {
|
||||||
|
|||||||
@@ -53,7 +53,19 @@ export interface PreprocessingConfigOverrides {
|
|||||||
contrast: boolean | null;
|
contrast: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegionAnalysisConfigOverrides {
|
||||||
|
enabled: boolean | null;
|
||||||
|
edge_canny_low: number | null;
|
||||||
|
edge_canny_high: number | null;
|
||||||
|
edge_hough_threshold: number | null;
|
||||||
|
edge_hough_min_length: number | null;
|
||||||
|
edge_hough_max_gap: number | null;
|
||||||
|
edge_pair_max_distance: number | null;
|
||||||
|
edge_pair_min_distance: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigOverrides {
|
export interface ConfigOverrides {
|
||||||
|
region_analysis: RegionAnalysisConfigOverrides | null;
|
||||||
detection: DetectionConfigOverrides | null;
|
detection: DetectionConfigOverrides | null;
|
||||||
ocr: OCRConfigOverrides | null;
|
ocr: OCRConfigOverrides | null;
|
||||||
resolver: ResolverConfigOverrides | null;
|
resolver: ResolverConfigOverrides | null;
|
||||||
|
|||||||
@@ -14,11 +14,22 @@ export interface FrameBBox {
|
|||||||
ocr_text?: string | null
|
ocr_text?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FrameOverlay {
|
||||||
|
/** Base64 JPEG image (same dimensions as main image) */
|
||||||
|
src: string
|
||||||
|
label: string
|
||||||
|
visible: boolean
|
||||||
|
/** Opacity 0-1, default 0.5 */
|
||||||
|
opacity?: number
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Base64 JPEG image */
|
/** Base64 JPEG image */
|
||||||
imageSrc: string
|
imageSrc: string
|
||||||
/** Bounding boxes to overlay */
|
/** Bounding boxes to overlay */
|
||||||
boxes: FrameBBox[]
|
boxes: FrameBBox[]
|
||||||
|
/** Debug overlay layers (edge images, line visualizations, etc.) */
|
||||||
|
overlays?: FrameOverlay[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
@@ -44,6 +55,10 @@ function draw() {
|
|||||||
ctx.clearRect(0, 0, cvs.width, cvs.height)
|
ctx.clearRect(0, 0, cvs.width, cvs.height)
|
||||||
ctx.drawImage(img, dx, dy, img.width * scale, img.height * scale)
|
ctx.drawImage(img, dx, dy, img.width * scale, img.height * scale)
|
||||||
|
|
||||||
|
// Draw debug overlays (edge images, line visualizations)
|
||||||
|
drawOverlays(ctx, dx, dy, img.width * scale, img.height * scale)
|
||||||
|
|
||||||
|
// Draw bounding boxes on top
|
||||||
for (const box of props.boxes) {
|
for (const box of props.boxes) {
|
||||||
const bx = dx + box.x * scale
|
const bx = dx + box.x * scale
|
||||||
const by = dy + box.y * scale
|
const by = dy + box.y * scale
|
||||||
@@ -53,7 +68,6 @@ function draw() {
|
|||||||
const color = sourceColor(box)
|
const color = sourceColor(box)
|
||||||
const resolved = box.resolved_brand || box.ocr_text
|
const resolved = box.resolved_brand || box.ocr_text
|
||||||
|
|
||||||
// Box outline only — no labels, no percentages
|
|
||||||
ctx.strokeStyle = color
|
ctx.strokeStyle = color
|
||||||
ctx.lineWidth = 2
|
ctx.lineWidth = 2
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
@@ -66,6 +80,29 @@ function draw() {
|
|||||||
img.src = `data:image/jpeg;base64,${props.imageSrc}`
|
img.src = `data:image/jpeg;base64,${props.imageSrc}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pending overlay images that need async loading */
|
||||||
|
const overlayCache = new Map<string, HTMLImageElement>()
|
||||||
|
|
||||||
|
function drawOverlays(ctx: CanvasRenderingContext2D, dx: number, dy: number, dw: number, dh: number) {
|
||||||
|
const layers = props.overlays ?? []
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (!layer.visible || !layer.src) continue
|
||||||
|
|
||||||
|
const cached = overlayCache.get(layer.src)
|
||||||
|
if (cached && cached.complete) {
|
||||||
|
ctx.globalAlpha = layer.opacity ?? 0.5
|
||||||
|
ctx.drawImage(cached, dx, dy, dw, dh)
|
||||||
|
ctx.globalAlpha = 1.0
|
||||||
|
} else if (!cached) {
|
||||||
|
// Load async, redraw when ready
|
||||||
|
const overlay = new window.Image()
|
||||||
|
overlay.onload = () => draw()
|
||||||
|
overlay.src = `data:image/jpeg;base64,${layer.src}`
|
||||||
|
overlayCache.set(layer.src, overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SOURCE_COLORS: Record<string, string> = {
|
const SOURCE_COLORS: Record<string, string> = {
|
||||||
yolo: '#f5a623', // yellow — raw detection
|
yolo: '#f5a623', // yellow — raw detection
|
||||||
ocr: '#ff8c42', // orange — text extracted
|
ocr: '#ff8c42', // orange — text extracted
|
||||||
@@ -75,9 +112,19 @@ const SOURCE_COLORS: Record<string, string> = {
|
|||||||
unresolved: '#e05252', // red — nothing matched
|
unresolved: '#e05252', // red — nothing matched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CV region labels — distinct from source-based colors
|
||||||
|
const REGION_COLORS: Record<string, string> = {
|
||||||
|
edge_region: '#00bcd4', // cyan
|
||||||
|
contour_region: '#ffd54f', // yellow
|
||||||
|
color_region: '#e040fb', // magenta
|
||||||
|
candidate: '#4caf50', // green — passed readability
|
||||||
|
rejected: '#e05252', // red — failed readability
|
||||||
|
}
|
||||||
|
|
||||||
function sourceColor(box: FrameBBox): string {
|
function sourceColor(box: FrameBBox): string {
|
||||||
|
if (REGION_COLORS[box.label]) return REGION_COLORS[box.label]
|
||||||
if (box.resolved_brand) return SOURCE_COLORS.ocr_matched
|
if (box.resolved_brand) return SOURCE_COLORS.ocr_matched
|
||||||
if (box.source && box.source in SOURCE_COLORS) return SOURCE_COLORS[box.source]
|
if (box.source && SOURCE_COLORS[box.source]) return SOURCE_COLORS[box.source]
|
||||||
return confidenceColor(box.confidence)
|
return confidenceColor(box.confidence)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +134,7 @@ function confidenceColor(conf: number): string {
|
|||||||
return 'var(--conf-low)'
|
return 'var(--conf-low)'
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => [props.imageSrc, props.boxes], () => nextTick(draw), { deep: true })
|
watch(() => [props.imageSrc, props.boxes, props.overlays], () => nextTick(draw), { deep: true })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(draw)
|
nextTick(draw)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const regionStageSet = computed(() => new Set(props.regionStages ?? [
|
const regionStageSet = computed(() => new Set(props.regionStages ?? [
|
||||||
'detect_objects', 'run_ocr', 'match_brands', 'escalate_vlm', 'escalate_cloud',
|
'detect_edges', 'detect_objects', 'run_ocr', 'match_brands', 'escalate_vlm', 'escalate_cloud',
|
||||||
]))
|
]))
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user