diff --git a/core/api/detect_replay.py b/core/api/detect_replay.py index 9f3fedd..28aea0a 100644 --- a/core/api/detect_replay.py +++ b/core/api/detect_replay.py @@ -116,6 +116,65 @@ def list_checkpoints(job_id: str) -> list[CheckpointInfo]: return result +class CheckpointFrameInfo(BaseModel): + seq: int + timestamp: float + jpeg_b64: str + + +class CheckpointData(BaseModel): + job_id: str + stage: str + profile_name: str + video_path: str + is_scenario: bool + scenario_label: str + frames: list[CheckpointFrameInfo] + stats: dict = {} + config_snapshot: dict = {} + stage_output_key: str = "" + + +@router.get("/checkpoints/{job_id}/{stage}", response_model=CheckpointData) +def get_checkpoint_data(job_id: str, stage: str): + """Load checkpoint frames + metadata for the editor UI.""" + from core.db.detect import get_stage_checkpoint + from detect.checkpoint.frames import load_frames_b64 + + checkpoint = get_stage_checkpoint(job_id, stage) + if not checkpoint: + raise HTTPException(status_code=404, detail=f"No checkpoint for {job_id}/{stage}") + + raw_manifest = checkpoint.frames_manifest or {} + manifest = {int(k): v for k, v in raw_manifest.items()} + frame_metadata = checkpoint.frames_meta or [] + + # Only load filtered frames if available, otherwise all + filtered = set(checkpoint.filtered_frame_sequences or []) + if filtered: + manifest = {k: v for k, v in manifest.items() if k in filtered} + + frames_b64 = load_frames_b64(manifest, frame_metadata) + + frame_list = [ + CheckpointFrameInfo(seq=f["seq"], timestamp=f["timestamp"], jpeg_b64=f["jpeg_b64"]) + for f in frames_b64 + ] + + return CheckpointData( + job_id=str(checkpoint.job_id), + stage=checkpoint.stage, + profile_name=checkpoint.profile_name, + video_path=checkpoint.video_path, + is_scenario=checkpoint.is_scenario, + scenario_label=checkpoint.scenario_label, + frames=frame_list, + stats=checkpoint.stats or {}, + config_snapshot=checkpoint.config_snapshot or {}, + stage_output_key=checkpoint.stage_output_key or "", + ) + + @router.get("/scenarios", response_model=list[ScenarioInfo]) def list_scenarios_endpoint(): """List all available scenarios (bookmarked checkpoints).""" diff --git a/core/schema/models/detect_jobs.py b/core/schema/models/detect_jobs.py index 3354c68..f1cf459 100644 --- a/core/schema/models/detect_jobs.py +++ b/core/schema/models/detect_jobs.py @@ -110,7 +110,7 @@ class StageCheckpoint: # 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=&stage=&editor=true + # /detection/?job=#/editor/ is_scenario: bool = False scenario_label: str = "" # human-readable name, e.g. "chelsea_edges_lowcanny" diff --git a/detect/checkpoint/frames.py b/detect/checkpoint/frames.py index 4f3a698..ec2e0fa 100644 --- a/detect/checkpoint/frames.py +++ b/detect/checkpoint/frames.py @@ -78,3 +78,34 @@ def load_frames(manifest: dict[int, str], frame_metadata: list[dict]) -> list[Fr frames.sort(key=lambda f: f.sequence) return frames + + +def load_frames_b64(manifest: dict[int, str], frame_metadata: list[dict]) -> list[dict]: + """ + Load frame images from S3 as base64 JPEG — lightweight, no numpy. + + Returns list of dicts: {seq, timestamp, jpeg_b64} + """ + import base64 + from core.storage.s3 import download_to_temp + + meta_map = {m["sequence"]: m for m in frame_metadata} + frames = [] + + for seq, key in manifest.items(): + tmp_path = download_to_temp(BUCKET, key) + try: + with open(tmp_path, "rb") as f: + jpeg_bytes = f.read() + finally: + os.unlink(tmp_path) + + meta = meta_map.get(seq, {}) + frames.append({ + "seq": seq, + "timestamp": meta.get("timestamp", 0.0), + "jpeg_b64": base64.b64encode(jpeg_bytes).decode(), + }) + + frames.sort(key=lambda f: f["seq"]) + return frames diff --git a/tests/detect/manual/seed_scenario.py b/tests/detect/manual/seed_scenario.py index 104bd8e..3bef4b1 100644 --- a/tests/detect/manual/seed_scenario.py +++ b/tests/detect/manual/seed_scenario.py @@ -20,7 +20,7 @@ Usage: python tests/detect/manual/seed_scenario.py --video media/mpr/out/chunks/.../chunk_0001.mp4 Then open: - http://mpr.local.ar/detection/?job=&stage=filter_scenes&editor=true + http://mpr.local.ar/detection/?job=#/editor/detect_edges """ from __future__ import annotations diff --git a/ui/detection-app/src/App.vue b/ui/detection-app/src/App.vue index ecc396c..90c6d60 100644 --- a/ui/detection-app/src/App.vue +++ b/ui/detection-app/src/App.vue @@ -78,6 +78,18 @@ function onTimelineResize(delta: number) { tableFlex.value = Math.max(0.3, Math.min(3, tableFlex.value - shift)) } +// Editor sliders sidebar width — drag right = shrink sliders (grow frame) +const slidersWidth = ref(210) +function onSlidersResize(delta: number) { + slidersWidth.value = Math.max(210, Math.min(350, slidersWidth.value - delta)) +} + +// Editor bottom height (overlays bar) +const editorBottomHeight = ref(50) +function onEditorBottomResize(delta: number) { + editorBottomHeight.value = Math.max(36, Math.min(120, editorBottomHeight.value - delta)) +} + const statusMap: Record = { idle: 'idle', connecting: 'processing', @@ -110,31 +122,107 @@ async function stopPipeline() { const currentFrameImage = ref(null) const currentFrameRef = ref(null) +// All checkpoint frames (for scenario mode — scrubbing) +const checkpointFrames = ref<{ seq: number; timestamp: number; jpeg_b64: string }[]>([]) +const checkpointFrameIndex = ref(0) +const checkpointStage = ref(null) // which stage the checkpoint is at + + source.on<{ frame_ref: number; jpeg_b64: string }>('frame_update', (e) => { currentFrameImage.value = e.jpeg_b64 currentFrameRef.value = e.frame_ref }) +// Load checkpoint data when in scenario mode +async function loadCheckpoint(job: string, stage: string) { + try { + const resp = await fetch(`/api/detect/checkpoints/${job}/${stage}`) + if (!resp.ok) return + + const data = await resp.json() + checkpointFrames.value = data.frames ?? [] + checkpointStage.value = stage + + // Show first frame + if (checkpointFrames.value.length > 0) { + checkpointFrameIndex.value = 0 + const first = checkpointFrames.value[0] + currentFrameImage.value = first.jpeg_b64 + currentFrameRef.value = first.seq + } + + status.value = 'idle' + } catch (e) { + console.error('Failed to load checkpoint:', e) + } +} + +function setCheckpointFrame(index: number) { + if (index < 0 || index >= checkpointFrames.value.length) return + checkpointFrameIndex.value = index + const frame = checkpointFrames.value[index] + currentFrameImage.value = frame.jpeg_b64 + currentFrameRef.value = frame.seq +} + +// Load checkpoint when in editor mode with a job (scenario URL) +// Uses watch to handle both initial load and navigation +import { watch as vueWatch } from 'vue' +vueWatch( + () => [pipeline.layoutMode, pipeline.editorStage, jobId.value] as const, + ([mode, stage, job]) => { + if (mode === 'bbox_editor' && stage && job) { + const stageMap: Record = { + detect_edges: 'filter_scenes', + detect_contours: 'detect_edges', + detect_color: 'detect_contours', + merge_regions: 'detect_color', + } + const cpStage = stageMap[stage] ?? 'filter_scenes' + loadCheckpoint(job, cpStage) + } + }, + { immediate: true }, +) + // Debug overlays from replay-stage results const editorOverlays = ref([]) +// Boxes from edge detection (local or server) +const editorBoxes = ref([]) + function onReplayResult(result: { + regions_by_frame?: Record debug?: Record }) { - const overlays: FrameOverlay[] = [] + // Update boxes + if (result.regions_by_frame) { + const firstRegions = Object.values(result.regions_by_frame)[0] as any[] ?? [] + editorBoxes.value = firstRegions.map((r: any) => ({ + x: r.x, y: r.y, w: r.w, h: r.h, + confidence: r.confidence, + label: r.label ?? 'edge_region', + stage: 'detect_edges', + })) + } + + // Update overlays — only when debug data is present, preserve existing otherwise 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) { + const overlays: FrameOverlay[] = [] if (firstDebug.edge_overlay_b64) { - overlays.push({ src: firstDebug.edge_overlay_b64, label: 'Canny edges', visible: true, opacity: 0.4 }) + // Preserve visibility/opacity from existing overlays if they exist + const existing = editorOverlays.value.find(o => o.label === 'Canny edges') + overlays.push({ src: firstDebug.edge_overlay_b64, label: 'Canny edges', visible: existing?.visible ?? true, opacity: existing?.opacity ?? 0.25 }) } if (firstDebug.lines_overlay_b64) { - overlays.push({ src: firstDebug.lines_overlay_b64, label: 'Hough lines', visible: true, opacity: 0.5 }) + const existing = editorOverlays.value.find(o => o.label === 'Hough lines') + overlays.push({ src: firstDebug.lines_overlay_b64, label: 'Hough lines', visible: existing?.visible ?? true, opacity: existing?.opacity ?? 0.25 }) } + editorOverlays.value = overlays } } - editorOverlays.value = overlays } function onJobStarted(newJobId: string) { @@ -236,12 +324,14 @@ function onJobStarted(newJobId: string) { @@ -279,28 +388,8 @@ function onJobStarted(newJobId: string) {
-