phase 3
This commit is contained in:
@@ -31,11 +31,12 @@ ALL_EVENT_TYPES = [
|
||||
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."""
|
||||
def push_detect_event(job_id: str, event_type: str, data: BaseModel | dict) -> None:
|
||||
"""Push a detection event to Redis. Accepts Pydantic models or plain dicts."""
|
||||
payload = data.model_dump(mode="json") if isinstance(data, BaseModel) else data
|
||||
push_event(
|
||||
job_id=job_id,
|
||||
event_type=event_type,
|
||||
data=data.model_dump(mode="json"),
|
||||
data=payload,
|
||||
prefix=DETECT_EVENTS_PREFIX,
|
||||
)
|
||||
|
||||
0
detect/stages/__init__.py
Normal file
0
detect/stages/__init__.py
Normal file
102
detect/stages/frame_extractor.py
Normal file
102
detect/stages/frame_extractor.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Stage 1 — Frame Extraction
|
||||
|
||||
Extracts frames from a video at a configurable FPS using FFmpeg.
|
||||
Emits log + stats_update SSE events as it works.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from core.ffmpeg.probe import probe_file
|
||||
from detect.events import push_detect_event
|
||||
from detect.models import Frame
|
||||
from detect.profiles.base import FrameExtractionConfig
|
||||
|
||||
|
||||
def extract_frames(
|
||||
video_path: str,
|
||||
config: FrameExtractionConfig,
|
||||
job_id: str | None = None,
|
||||
) -> list[Frame]:
|
||||
"""
|
||||
Extract frames from video at the configured FPS.
|
||||
|
||||
Uses FFmpeg to decode frames as raw images, then loads them
|
||||
as numpy arrays. Caps at config.max_frames.
|
||||
"""
|
||||
probe = probe_file(video_path)
|
||||
duration = probe.duration or 0.0
|
||||
|
||||
if job_id:
|
||||
push_detect_event(job_id, "log", {
|
||||
"level": "INFO",
|
||||
"stage": "FrameExtractor",
|
||||
"msg": f"Starting extraction: {Path(video_path).name} "
|
||||
f"({duration:.1f}s, {probe.width}x{probe.height}, fps={config.fps})",
|
||||
})
|
||||
|
||||
frames: list[Frame] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
pattern = str(Path(tmpdir) / "frame_%06d.jpg")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-i", video_path,
|
||||
"-vf", f"fps={config.fps}",
|
||||
"-q:v", "2",
|
||||
"-frames:v", str(config.max_frames),
|
||||
pattern,
|
||||
"-y", "-loglevel", "warning",
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
if job_id:
|
||||
push_detect_event(job_id, "log", {
|
||||
"level": "ERROR",
|
||||
"stage": "FrameExtractor",
|
||||
"msg": f"FFmpeg failed: {result.stderr[:200]}",
|
||||
})
|
||||
raise RuntimeError(f"FFmpeg failed: {result.stderr}")
|
||||
|
||||
frame_files = sorted(Path(tmpdir).glob("frame_*.jpg"))
|
||||
|
||||
for i, fpath in enumerate(frame_files):
|
||||
img = Image.open(fpath)
|
||||
arr = np.array(img)
|
||||
timestamp = i / config.fps
|
||||
|
||||
frames.append(Frame(
|
||||
sequence=i,
|
||||
chunk_id=0,
|
||||
timestamp=timestamp,
|
||||
image=arr,
|
||||
))
|
||||
|
||||
if job_id:
|
||||
push_detect_event(job_id, "log", {
|
||||
"level": "INFO",
|
||||
"stage": "FrameExtractor",
|
||||
"msg": f"Extracted {len(frames)} frames",
|
||||
})
|
||||
push_detect_event(job_id, "stats_update", {
|
||||
"frames_extracted": len(frames),
|
||||
"frames_after_scene_filter": 0,
|
||||
"regions_detected": 0,
|
||||
"regions_resolved_by_ocr": 0,
|
||||
"regions_escalated_to_local_vlm": 0,
|
||||
"regions_escalated_to_cloud_llm": 0,
|
||||
"cloud_llm_calls": 0,
|
||||
"processing_time_seconds": 0.0,
|
||||
"estimated_cloud_cost_usd": 0.0,
|
||||
})
|
||||
|
||||
return frames
|
||||
Reference in New Issue
Block a user