phase 1
This commit is contained in:
0
detect/__init__.py
Normal file
0
detect/__init__.py
Normal file
41
detect/events.py
Normal file
41
detect/events.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Detection pipeline event helpers.
|
||||
|
||||
Non-generated runtime code for pushing SSE events.
|
||||
The event payload types are in sse_contract.py (generated by modelgen).
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.events import push_event
|
||||
|
||||
DETECT_EVENTS_PREFIX = "detect_events"
|
||||
|
||||
# SSE event type names
|
||||
EVENT_GRAPH_UPDATE = "graph_update"
|
||||
EVENT_STATS_UPDATE = "stats_update"
|
||||
EVENT_FRAME_UPDATE = "frame_update"
|
||||
EVENT_DETECTION = "detection"
|
||||
EVENT_LOG = "log"
|
||||
EVENT_JOB_COMPLETE = "job_complete"
|
||||
|
||||
ALL_EVENT_TYPES = [
|
||||
EVENT_GRAPH_UPDATE,
|
||||
EVENT_STATS_UPDATE,
|
||||
EVENT_FRAME_UPDATE,
|
||||
EVENT_DETECTION,
|
||||
EVENT_LOG,
|
||||
EVENT_JOB_COMPLETE,
|
||||
]
|
||||
|
||||
TERMINAL_EVENTS = [EVENT_JOB_COMPLETE]
|
||||
|
||||
|
||||
def push_detect_event(job_id: str, event_type: str, data: BaseModel) -> None:
|
||||
"""Push a typed detection event to Redis."""
|
||||
push_event(
|
||||
job_id=job_id,
|
||||
event_type=event_type,
|
||||
data=data.model_dump(mode="json"),
|
||||
prefix=DETECT_EVENTS_PREFIX,
|
||||
)
|
||||
86
detect/models.py
Normal file
86
detect/models.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Core domain models for the detection pipeline.
|
||||
|
||||
These are pipeline-internal models — the data structures that flow
|
||||
between LangGraph nodes. SSE event payloads (sse_contract.py) are
|
||||
derived from these when emitting to the UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
sequence: int
|
||||
chunk_id: int
|
||||
timestamp: float # position in video (seconds)
|
||||
image: np.ndarray
|
||||
perceptual_hash: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoundingBox:
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
confidence: float
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextCandidate:
|
||||
frame: Frame
|
||||
bbox: BoundingBox
|
||||
text: str
|
||||
ocr_confidence: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrandDetection:
|
||||
brand: str
|
||||
timestamp: float
|
||||
duration: float
|
||||
confidence: float
|
||||
source: Literal["ocr", "local_vlm", "cloud_llm", "logo_match", "auxiliary"]
|
||||
bbox: BoundingBox | None = None
|
||||
frame_ref: int | None = None
|
||||
content_type: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrandStats:
|
||||
total_appearances: int = 0
|
||||
total_screen_time: float = 0.0
|
||||
avg_confidence: float = 0.0
|
||||
first_seen: float = 0.0
|
||||
last_seen: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineStats:
|
||||
frames_extracted: int = 0
|
||||
frames_after_scene_filter: int = 0
|
||||
regions_detected: int = 0
|
||||
regions_resolved_by_ocr: int = 0
|
||||
regions_escalated_to_local_vlm: int = 0
|
||||
regions_escalated_to_cloud_llm: int = 0
|
||||
auxiliary_detections: int = 0
|
||||
cloud_llm_calls: int = 0
|
||||
processing_time_seconds: float = 0.0
|
||||
estimated_cloud_cost_usd: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionReport:
|
||||
video_source: str
|
||||
content_type: str
|
||||
duration_seconds: float
|
||||
brands: dict[str, BrandStats] = field(default_factory=dict)
|
||||
timeline: list[BrandDetection] = field(default_factory=list)
|
||||
pipeline_stats: PipelineStats = field(default_factory=PipelineStats)
|
||||
103
detect/sse_contract.py
Normal file
103
detect/sse_contract.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
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 GraphNode(BaseModel):
|
||||
"""A pipeline stage node."""
|
||||
id: str
|
||||
status: str = "idle"
|
||||
items_in: int = 0
|
||||
items_out: int = 0
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
"""An edge between pipeline stages."""
|
||||
source: str
|
||||
target: str
|
||||
throughput: int = 0
|
||||
|
||||
class BoundingBoxEvent(BaseModel):
|
||||
"""Bounding box in SSE event payloads."""
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
confidence: float
|
||||
label: str
|
||||
resolved_brand: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
|
||||
class BrandSummary(BaseModel):
|
||||
"""Per-brand stats in the final report."""
|
||||
brand: str
|
||||
total_appearances: int = 0
|
||||
total_screen_time: float = 0.0
|
||||
avg_confidence: float = 0.0
|
||||
first_seen: float = 0.0
|
||||
last_seen: float = 0.0
|
||||
|
||||
class GraphUpdate(BaseModel):
|
||||
"""Pipeline node state transition. SSE event: graph_update"""
|
||||
nodes: List[GraphNode] = Field(default_factory=list)
|
||||
edges: List[GraphEdge] = Field(default_factory=list)
|
||||
active_path: List[str] = Field(default_factory=list)
|
||||
|
||||
class StatsUpdate(BaseModel):
|
||||
"""Funnel statistics snapshot. SSE event: stats_update"""
|
||||
frames_extracted: int = 0
|
||||
frames_after_scene_filter: int = 0
|
||||
regions_detected: int = 0
|
||||
regions_resolved_by_ocr: int = 0
|
||||
regions_escalated_to_local_vlm: int = 0
|
||||
regions_escalated_to_cloud_llm: int = 0
|
||||
cloud_llm_calls: int = 0
|
||||
processing_time_seconds: float = 0.0
|
||||
estimated_cloud_cost_usd: float = 0.0
|
||||
|
||||
class FrameUpdate(BaseModel):
|
||||
"""Current frame being processed. SSE event: frame_update"""
|
||||
frame_ref: int
|
||||
timestamp: float
|
||||
jpeg_b64: str
|
||||
boxes: List[BoundingBoxEvent] = Field(default_factory=list)
|
||||
|
||||
class Detection(BaseModel):
|
||||
"""A confirmed brand detection. SSE event: detection"""
|
||||
brand: str
|
||||
timestamp: float
|
||||
duration: float
|
||||
confidence: float
|
||||
source: str
|
||||
content_type: str
|
||||
bbox: Optional[BoundingBoxEvent] = None
|
||||
frame_ref: Optional[int] = None
|
||||
|
||||
class LogEvent(BaseModel):
|
||||
"""Pipeline log line. SSE event: log"""
|
||||
level: str
|
||||
stage: str
|
||||
msg: str
|
||||
ts: str
|
||||
trace_id: Optional[str] = None
|
||||
|
||||
class DetectionReportSummary(BaseModel):
|
||||
"""Final detection report summary."""
|
||||
video_source: str
|
||||
content_type: str
|
||||
duration_seconds: float
|
||||
total_detections: int = 0
|
||||
brands: List[BrandSummary] = Field(default_factory=list)
|
||||
stats: Optional[StatsUpdate] = None
|
||||
|
||||
class JobComplete(BaseModel):
|
||||
"""Final report when pipeline finishes. SSE event: job_complete"""
|
||||
job_id: str
|
||||
report: Optional[DetectionReportSummary] = None
|
||||
Reference in New Issue
Block a user