phase 1
This commit is contained in:
@@ -116,6 +116,65 @@ def list_checkpoints(job_id: str) -> list[CheckpointInfo]:
|
|||||||
return result
|
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])
|
@router.get("/scenarios", response_model=list[ScenarioInfo])
|
||||||
def list_scenarios_endpoint():
|
def list_scenarios_endpoint():
|
||||||
"""List all available scenarios (bookmarked checkpoints)."""
|
"""List all available scenarios (bookmarked checkpoints)."""
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class StageCheckpoint:
|
|||||||
# Scenario — a checkpoint bookmarked for the editor workflow.
|
# Scenario — a checkpoint bookmarked for the editor workflow.
|
||||||
# Created by seeders (manual scripts that populate state from real footage)
|
# Created by seeders (manual scripts that populate state from real footage)
|
||||||
# or captured from a running pipeline. Loaded via URL:
|
# or captured from a running pipeline. Loaded via URL:
|
||||||
# /detection/?job=<job_id>&stage=<stage>&editor=true
|
# /detection/?job=<job_id>#/editor/<stage>
|
||||||
is_scenario: bool = False
|
is_scenario: bool = False
|
||||||
scenario_label: str = "" # human-readable name, e.g. "chelsea_edges_lowcanny"
|
scenario_label: str = "" # human-readable name, e.g. "chelsea_edges_lowcanny"
|
||||||
|
|
||||||
|
|||||||
@@ -78,3 +78,34 @@ def load_frames(manifest: dict[int, str], frame_metadata: list[dict]) -> list[Fr
|
|||||||
|
|
||||||
frames.sort(key=lambda f: f.sequence)
|
frames.sort(key=lambda f: f.sequence)
|
||||||
return frames
|
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
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Usage:
|
|||||||
python tests/detect/manual/seed_scenario.py --video media/mpr/out/chunks/.../chunk_0001.mp4
|
python tests/detect/manual/seed_scenario.py --video media/mpr/out/chunks/.../chunk_0001.mp4
|
||||||
|
|
||||||
Then open:
|
Then open:
|
||||||
http://mpr.local.ar/detection/?job=<JOB_ID>&stage=filter_scenes&editor=true
|
http://mpr.local.ar/detection/?job=<JOB_ID>#/editor/detect_edges
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ function onTimelineResize(delta: number) {
|
|||||||
tableFlex.value = Math.max(0.3, Math.min(3, tableFlex.value - shift))
|
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<string, 'idle' | 'live' | 'processing' | 'error'> = {
|
const statusMap: Record<string, 'idle' | 'live' | 'processing' | 'error'> = {
|
||||||
idle: 'idle',
|
idle: 'idle',
|
||||||
connecting: 'processing',
|
connecting: 'processing',
|
||||||
@@ -110,31 +122,107 @@ async function stopPipeline() {
|
|||||||
const currentFrameImage = ref<string | null>(null)
|
const currentFrameImage = ref<string | null>(null)
|
||||||
const currentFrameRef = ref<number | null>(null)
|
const currentFrameRef = ref<number | null>(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<string | null>(null) // which stage the checkpoint is at
|
||||||
|
|
||||||
|
|
||||||
source.on<{ frame_ref: number; jpeg_b64: string }>('frame_update', (e) => {
|
source.on<{ frame_ref: number; jpeg_b64: string }>('frame_update', (e) => {
|
||||||
currentFrameImage.value = e.jpeg_b64
|
currentFrameImage.value = e.jpeg_b64
|
||||||
currentFrameRef.value = e.frame_ref
|
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<string, string> = {
|
||||||
|
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
|
// Debug overlays from replay-stage results
|
||||||
const editorOverlays = ref<FrameOverlay[]>([])
|
const editorOverlays = ref<FrameOverlay[]>([])
|
||||||
|
|
||||||
|
// Boxes from edge detection (local or server)
|
||||||
|
const editorBoxes = ref<import('mpr-ui-framework/src/renderers/FrameRenderer.vue').FrameBBox[]>([])
|
||||||
|
|
||||||
function onReplayResult(result: {
|
function onReplayResult(result: {
|
||||||
|
regions_by_frame?: Record<string, unknown[]>
|
||||||
debug?: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
|
debug?: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
|
||||||
}) {
|
}) {
|
||||||
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) {
|
if (result.debug) {
|
||||||
// Take first frame's debug data (editor shows one frame at a time)
|
|
||||||
const firstDebug = Object.values(result.debug)[0]
|
const firstDebug = Object.values(result.debug)[0]
|
||||||
if (firstDebug) {
|
if (firstDebug) {
|
||||||
|
const overlays: FrameOverlay[] = []
|
||||||
if (firstDebug.edge_overlay_b64) {
|
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) {
|
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) {
|
function onJobStarted(newJobId: string) {
|
||||||
@@ -236,12 +324,14 @@ function onJobStarted(newJobId: string) {
|
|||||||
|
|
||||||
<!-- === BBOX EDITOR MODE === -->
|
<!-- === BBOX EDITOR MODE === -->
|
||||||
<template v-else-if="pipeline.layoutMode === 'bbox_editor'">
|
<template v-else-if="pipeline.layoutMode === 'bbox_editor'">
|
||||||
<Panel :title="`Region Editor — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
|
<div class="editor-layout">
|
||||||
<div class="editor-placeholder">
|
<!-- Top: frame + sliders side by side -->
|
||||||
|
<div class="editor-top">
|
||||||
<div class="editor-frame">
|
<div class="editor-frame">
|
||||||
<FramePanel :source="source" :status="status" :overlays="editorOverlays" />
|
<FramePanel :source="source" :status="status" :overlays="editorOverlays" :frame-image="currentFrameImage" :editor-boxes="editorBoxes" />
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-tools">
|
<ResizeHandle direction="horizontal" @resize="onSlidersResize" />
|
||||||
|
<div class="editor-sliders" :style="{ width: slidersWidth + 'px' }">
|
||||||
<StageConfigSliders
|
<StageConfigSliders
|
||||||
v-if="pipeline.editorStage"
|
v-if="pipeline.editorStage"
|
||||||
:stage="pipeline.editorStage"
|
:stage="pipeline.editorStage"
|
||||||
@@ -250,10 +340,29 @@ function onJobStarted(newJobId: string) {
|
|||||||
:frame-ref="currentFrameRef"
|
:frame-ref="currentFrameRef"
|
||||||
@replay-result="onReplayResult"
|
@replay-result="onReplayResult"
|
||||||
/>
|
/>
|
||||||
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
<!-- Bottom: debug overlays + close -->
|
||||||
|
<div class="editor-bottom">
|
||||||
|
<div class="overlay-controls">
|
||||||
|
<template v-if="editorOverlays.length > 0">
|
||||||
|
<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: Event) => 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>
|
||||||
|
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- === STAGE EDITOR MODE === -->
|
<!-- === STAGE EDITOR MODE === -->
|
||||||
@@ -279,28 +388,8 @@ 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 === 'source_selector'">
|
||||||
<Panel :title="`Debug Overlays — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
|
<!-- no log in source selector -->
|
||||||
<div class="overlay-controls">
|
|
||||||
<template v-if="editorOverlays.length > 0">
|
|
||||||
<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>
|
|
||||||
</Panel>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<LogPanel ref="logPanel" :source="source" :status="status" />
|
<LogPanel ref="logPanel" :source="source" :status="status" />
|
||||||
@@ -478,34 +567,82 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
|
|||||||
/* Log: full width bottom */
|
/* Log: full width bottom */
|
||||||
.log-row {
|
.log-row {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 200px;
|
height: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty { color: var(--text-dim); padding: var(--space-6); text-align: center; }
|
.empty { color: var(--text-dim); padding: var(--space-6); text-align: center; }
|
||||||
|
|
||||||
/* Editor placeholders */
|
/* Editor layout — frame maximized, sliders right, overlays bottom */
|
||||||
.editor-placeholder {
|
.editor-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: var(--space-2);
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-top {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-frame {
|
.editor-frame {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.editor-frame > * {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-tools {
|
.editor-sliders {
|
||||||
width: 200px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: var(--space-3);
|
min-width: 210px;
|
||||||
|
padding: var(--space-2);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border-radius: var(--panel-radius);
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-bottom {
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-4);
|
||||||
font-size: var(--font-size-sm);
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-top: var(--panel-border);
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-close {
|
||||||
|
background: var(--surface-3);
|
||||||
|
border: 1px solid var(--surface-3);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-close:hover {
|
||||||
|
background: var(--status-error);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage config editor (placeholder) */
|
||||||
|
.editor-placeholder {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-config {
|
.editor-config {
|
||||||
@@ -517,23 +654,6 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-close {
|
|
||||||
background: var(--surface-3);
|
|
||||||
border: 1px solid var(--surface-3);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-close:hover {
|
|
||||||
background: var(--status-error);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-placeholder {
|
.blob-placeholder {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
runEdgeDetection,
|
||||||
|
runEdgeDetectionDebug,
|
||||||
|
b64ToImageData,
|
||||||
|
imageDataToB64,
|
||||||
|
type EdgeDetectionParams,
|
||||||
|
} from 'mpr-ui-framework/src/cv'
|
||||||
|
|
||||||
interface ConfigField {
|
interface ConfigField {
|
||||||
name: string
|
name: string
|
||||||
type: string // "bool" | "int" | "float" | "str"
|
type: string
|
||||||
default: unknown
|
default: unknown
|
||||||
description: string
|
description: string
|
||||||
min: number | null
|
min: number | null
|
||||||
@@ -14,16 +21,15 @@ interface ConfigField {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Stage name (e.g. "detect_edges") */
|
/** Stage name (e.g. "detect_edges") */
|
||||||
stage: string
|
stage: string
|
||||||
/** Job ID for replay-stage calls (used as fallback) */
|
/** Job ID (used for server mode fallback) */
|
||||||
jobId: string
|
jobId: string
|
||||||
/** Currently displayed frame image (base64 JPEG) — sent directly to GPU for fast feedback */
|
/** Currently displayed frame image (base64 JPEG) */
|
||||||
frameImage?: string | null
|
frameImage?: string | null
|
||||||
/** Currently displayed frame sequence number */
|
/** Currently displayed frame sequence number */
|
||||||
frameRef?: number | null
|
frameRef?: number | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
/** Emitted when replay returns new regions */
|
|
||||||
'replay-result': [result: {
|
'replay-result': [result: {
|
||||||
regions_by_frame: Record<string, unknown[]>
|
regions_by_frame: Record<string, unknown[]>
|
||||||
debug: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
|
debug: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
|
||||||
@@ -36,24 +42,50 @@ const loading = ref(false)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const regionCount = ref<number | null>(null)
|
const regionCount = ref<number | null>(null)
|
||||||
const debugEnabled = ref(true)
|
const debugEnabled = ref(true)
|
||||||
|
const autoApply = ref(true) // auto-run on slider change (fast CV); uncheck for heavy stages
|
||||||
|
const execMode = ref<'local' | 'server'>('local')
|
||||||
|
const execTimeMs = ref<number | null>(null)
|
||||||
|
|
||||||
|
// Config field defaults for detect_edges (used when API is unavailable)
|
||||||
|
const EDGE_DEFAULTS: ConfigField[] = [
|
||||||
|
{ name: 'enabled', type: 'bool', default: true, description: 'Enable edge detection', min: null, max: null, options: null },
|
||||||
|
{ name: 'edge_canny_low', type: 'int', default: 50, description: 'Canny low threshold', min: 0, max: 255, options: null },
|
||||||
|
{ name: 'edge_canny_high', type: 'int', default: 150, description: 'Canny high threshold', min: 0, max: 255, options: null },
|
||||||
|
{ name: 'edge_hough_threshold', type: 'int', default: 80, description: 'Hough accumulator threshold', min: 1, max: 500, options: null },
|
||||||
|
{ name: 'edge_hough_min_length', type: 'int', default: 100, description: 'Min line length (px)', min: 10, max: 2000, options: null },
|
||||||
|
{ name: 'edge_hough_max_gap', type: 'int', default: 10, description: 'Max line gap (px)', min: 1, max: 100, options: null },
|
||||||
|
{ name: 'edge_pair_max_distance', type: 'int', default: 200, description: 'Max pair distance (px)', min: 10, max: 500, options: null },
|
||||||
|
{ name: 'edge_pair_min_distance', type: 'int', default: 15, description: 'Min pair distance (px)', min: 5, max: 200, options: null },
|
||||||
|
]
|
||||||
|
|
||||||
// Fetch stage config fields from API
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Try loading from API, fall back to hardcoded defaults
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/detect/config/stages/${props.stage}`)
|
const resp = await fetch(`/api/detect/config/stages/${props.stage}`)
|
||||||
if (!resp.ok) {
|
if (resp.ok) {
|
||||||
error.value = `Failed to load config: ${resp.status}`
|
const data = await resp.json()
|
||||||
return
|
fields.value = data.config_fields ?? []
|
||||||
|
} else {
|
||||||
|
fields.value = EDGE_DEFAULTS
|
||||||
}
|
}
|
||||||
const data = await resp.json()
|
} catch {
|
||||||
fields.value = data.config_fields ?? []
|
fields.value = EDGE_DEFAULTS
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize values from defaults
|
for (const f of fields.value) {
|
||||||
for (const f of fields.value) {
|
values.value[f.name] = f.default
|
||||||
values.value[f.name] = f.default
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
// Auto-run on first frame if already available
|
||||||
error.value = `Failed to load config: ${e}`
|
if (props.frameImage) {
|
||||||
|
applyDetection()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-run when frame arrives after mount (checkpoint load is async)
|
||||||
|
watch(() => props.frameImage, (newVal, oldVal) => {
|
||||||
|
if (newVal && !oldVal && fields.value.length > 0) {
|
||||||
|
applyDetection()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,41 +96,97 @@ function resetDefaults() {
|
|||||||
for (const f of fields.value) {
|
for (const f of fields.value) {
|
||||||
values.value[f.name] = f.default
|
values.value[f.name] = f.default
|
||||||
}
|
}
|
||||||
|
applyDetection()
|
||||||
}
|
}
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function onSliderChange() {
|
function onSliderChange() {
|
||||||
// Debounce — wait 300ms after last change before calling replay
|
if (!autoApply.value) return
|
||||||
if (debounceTimer) clearTimeout(debounceTimer)
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
debounceTimer = setTimeout(() => applyReplay(), 300)
|
debounceTimer = setTimeout(() => applyDetection(), 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyReplay() {
|
async function applyDetection() {
|
||||||
|
if (!props.frameImage) {
|
||||||
|
error.value = 'No frame available'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
execTimeMs.value = null
|
||||||
|
|
||||||
// Direct GPU call — send the frame image + current slider params
|
try {
|
||||||
// Skip checkpoint/replay path for ~50-100ms round trips instead of seconds
|
if (execMode.value === 'local') {
|
||||||
if (props.frameImage && props.stage === 'detect_edges') {
|
await runLocal()
|
||||||
await callGpuDirect()
|
} else {
|
||||||
return
|
await runServer()
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
// Fallback: replay-stage path (for stages without direct GPU endpoint)
|
error.value = `${execMode.value} failed: ${e}`
|
||||||
if (!props.jobId) {
|
} finally {
|
||||||
error.value = 'No frame image or job ID available'
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
await callReplayStage()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callGpuDirect() {
|
/** Browser-side CV — no network, instant */
|
||||||
const body: Record<string, unknown> = {
|
async function runLocal() {
|
||||||
image: props.frameImage,
|
const t0 = performance.now()
|
||||||
|
|
||||||
|
// Decode base64 JPEG → ImageData
|
||||||
|
const imageData = await b64ToImageData(props.frameImage!)
|
||||||
|
|
||||||
|
// Build params from slider values
|
||||||
|
const params: Partial<EdgeDetectionParams> = {
|
||||||
|
cannyLow: values.value['edge_canny_low'] as number,
|
||||||
|
cannyHigh: values.value['edge_canny_high'] as number,
|
||||||
|
houghThreshold: values.value['edge_hough_threshold'] as number,
|
||||||
|
houghMinLength: values.value['edge_hough_min_length'] as number,
|
||||||
|
houghMaxGap: values.value['edge_hough_max_gap'] as number,
|
||||||
|
pairMaxDistance: values.value['edge_pair_max_distance'] as number,
|
||||||
|
pairMinDistance: values.value['edge_pair_min_distance'] as number,
|
||||||
}
|
}
|
||||||
// Pass current slider values as edge detection params
|
|
||||||
|
const frameKey = String(props.frameRef ?? 0)
|
||||||
|
|
||||||
|
if (debugEnabled.value) {
|
||||||
|
const result = await runEdgeDetectionDebug(imageData, params)
|
||||||
|
execTimeMs.value = Math.round(performance.now() - t0)
|
||||||
|
regionCount.value = result.regions.length
|
||||||
|
|
||||||
|
// Convert ImageData overlays to base64 for FrameRenderer
|
||||||
|
const edgeB64 = await imageDataToB64(result.edgeImageData)
|
||||||
|
const linesB64 = await imageDataToB64(result.linesImageData)
|
||||||
|
|
||||||
|
emit('replay-result', {
|
||||||
|
regions_by_frame: { [frameKey]: result.regions },
|
||||||
|
debug: {
|
||||||
|
[frameKey]: {
|
||||||
|
edge_overlay_b64: edgeB64,
|
||||||
|
lines_overlay_b64: linesB64,
|
||||||
|
horizontal_count: result.horizontalCount,
|
||||||
|
pair_count: result.pairCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const result = await runEdgeDetection(imageData, params)
|
||||||
|
execTimeMs.value = Math.round(performance.now() - t0)
|
||||||
|
regionCount.value = result.regions.length
|
||||||
|
|
||||||
|
emit('replay-result', {
|
||||||
|
regions_by_frame: { [frameKey]: result.regions },
|
||||||
|
debug: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-side CV — calls GPU box via proxy */
|
||||||
|
async function runServer() {
|
||||||
|
const t0 = performance.now()
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = { image: props.frameImage }
|
||||||
for (const f of fields.value) {
|
for (const f of fields.value) {
|
||||||
if (f.name !== 'enabled') {
|
if (f.name !== 'enabled') {
|
||||||
body[f.name] = values.value[f.name]
|
body[f.name] = values.value[f.name]
|
||||||
@@ -109,98 +197,37 @@ async function callGpuDirect() {
|
|||||||
? '/api/detect/gpu/detect_edges/debug'
|
? '/api/detect/gpu/detect_edges/debug'
|
||||||
: '/api/detect/gpu/detect_edges'
|
: '/api/detect/gpu/detect_edges'
|
||||||
|
|
||||||
try {
|
const resp = await fetch(endpoint, {
|
||||||
const resp = await fetch(endpoint, {
|
method: 'POST',
|
||||||
method: 'POST',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify(body),
|
||||||
body: JSON.stringify(body),
|
})
|
||||||
})
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const detail = await resp.text()
|
const detail = await resp.text()
|
||||||
error.value = `GPU call failed: ${detail}`
|
throw new Error(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 data = await resp.json()
|
||||||
const configOverrides = Object.keys(overrides).length > 0
|
execTimeMs.value = Math.round(performance.now() - t0)
|
||||||
? { [overrideKey]: overrides }
|
regionCount.value = data.regions?.length ?? 0
|
||||||
: null
|
|
||||||
|
|
||||||
const body = {
|
const frameKey = String(props.frameRef ?? 0)
|
||||||
job_id: props.jobId,
|
const result: any = {
|
||||||
stage: props.stage,
|
regions_by_frame: { [frameKey]: data.regions ?? [] },
|
||||||
frame_refs: props.frameRef != null ? [props.frameRef] : null,
|
debug: {},
|
||||||
config_overrides: configOverrides,
|
|
||||||
debug: debugEnabled.value,
|
|
||||||
}
|
}
|
||||||
|
if (data.edge_overlay_b64 || data.lines_overlay_b64) {
|
||||||
try {
|
result.debug = {
|
||||||
const resp = await fetch('/api/detect/replay-stage', {
|
[frameKey]: {
|
||||||
method: 'POST',
|
edge_overlay_b64: data.edge_overlay_b64 ?? '',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
lines_overlay_b64: data.lines_overlay_b64 ?? '',
|
||||||
body: JSON.stringify(body),
|
horizontal_count: data.horizontal_count ?? 0,
|
||||||
})
|
pair_count: data.pair_count ?? 0,
|
||||||
|
},
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
emit('replay-result', result)
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
@@ -211,6 +238,18 @@ function stageToOverrideKey(stage: string): string {
|
|||||||
<button class="sliders-reset" @click="resetDefaults" title="Reset to defaults">Reset</button>
|
<button class="sliders-reset" @click="resetDefaults" title="Reset to defaults">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Local / Server toggle -->
|
||||||
|
<div class="mode-toggle">
|
||||||
|
<button
|
||||||
|
:class="['mode-btn', { active: execMode === 'local' }]"
|
||||||
|
@click="execMode = 'local'"
|
||||||
|
>Local</button>
|
||||||
|
<button
|
||||||
|
:class="['mode-btn', { active: execMode === 'server' }]"
|
||||||
|
@click="execMode = 'server'"
|
||||||
|
>Server</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="sliders-error">{{ error }}</div>
|
<div v-if="error" class="sliders-error">{{ error }}</div>
|
||||||
|
|
||||||
<div class="sliders-list">
|
<div class="sliders-list">
|
||||||
@@ -245,20 +284,25 @@ function stageToOverrideKey(stage: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug overlay toggle -->
|
<!-- Footer -->
|
||||||
<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">
|
<div class="sliders-footer">
|
||||||
<button class="apply-btn" :disabled="loading" @click="applyReplay">
|
<button
|
||||||
|
class="apply-btn"
|
||||||
|
:disabled="loading || autoApply"
|
||||||
|
@click="applyDetection"
|
||||||
|
>
|
||||||
{{ loading ? 'Running...' : 'Apply' }}
|
{{ loading ? 'Running...' : 'Apply' }}
|
||||||
</button>
|
</button>
|
||||||
|
<label class="auto-apply-toggle">
|
||||||
|
<input type="checkbox" v-model="autoApply" />
|
||||||
|
<span>Auto</span>
|
||||||
|
</label>
|
||||||
<span v-if="regionCount != null" class="region-count">
|
<span v-if="regionCount != null" class="region-count">
|
||||||
{{ regionCount }} regions
|
{{ regionCount }} regions
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="execTimeMs != null" class="exec-time">
|
||||||
|
{{ execTimeMs }}ms
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -298,6 +342,34 @@ function stageToOverrideKey(stage: string): string {
|
|||||||
}
|
}
|
||||||
.sliders-reset:hover { background: var(--surface-2); }
|
.sliders-reset:hover { background: var(--surface-2); }
|
||||||
|
|
||||||
|
.mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.mode-btn.active {
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mode-btn:hover:not(.active) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.sliders-error {
|
.sliders-error {
|
||||||
color: var(--status-error);
|
color: var(--status-error);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -354,7 +426,6 @@ function stageToOverrideKey(stage: string): string {
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Range slider styling */
|
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -388,11 +459,6 @@ input[type="checkbox"] {
|
|||||||
accent-color: #00bcd4;
|
accent-color: #00bcd4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.debug-toggle {
|
|
||||||
padding-top: var(--space-1);
|
|
||||||
border-top: var(--panel-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sliders-footer {
|
.sliders-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -415,8 +481,25 @@ input[type="checkbox"] {
|
|||||||
.apply-btn:hover { opacity: 0.85; }
|
.apply-btn:hover { opacity: 0.85; }
|
||||||
.apply-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
.apply-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.auto-apply-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-apply-toggle input { accent-color: #00bcd4; }
|
||||||
|
|
||||||
.region-count {
|
.region-count {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exec-time {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 9px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } 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, FrameOverlay } from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
import type { FrameBBox, FrameOverlay } from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
||||||
@@ -10,9 +10,18 @@ const props = defineProps<{
|
|||||||
status?: 'idle' | 'live' | 'processing' | 'error'
|
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||||
/** Debug overlay layers passed from parent (editor mode) */
|
/** Debug overlay layers passed from parent (editor mode) */
|
||||||
overlays?: FrameOverlay[]
|
overlays?: FrameOverlay[]
|
||||||
|
/** Frame image from checkpoint (scenario mode) — overrides SSE */
|
||||||
|
frameImage?: string | null
|
||||||
|
/** Boxes from editor (local CV or server) — merged with SSE boxes */
|
||||||
|
editorBoxes?: import('mpr-ui-framework/src/renderers/FrameRenderer.vue').FrameBBox[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const imageSrc = ref('')
|
const imageSrc = ref(props.frameImage ?? '')
|
||||||
|
|
||||||
|
// Sync prop → internal ref when checkpoint frame changes
|
||||||
|
watch(() => props.frameImage, (v) => {
|
||||||
|
if (v) imageSrc.value = v
|
||||||
|
})
|
||||||
|
|
||||||
// Per-stage box accumulation
|
// Per-stage box accumulation
|
||||||
const stageBoxes = ref<Record<string, FrameBBox[]>>({})
|
const stageBoxes = ref<Record<string, FrameBBox[]>>({})
|
||||||
@@ -117,14 +126,19 @@ function sourceToStage(source: string): string {
|
|||||||
return map[source] || 'match_brands'
|
return map[source] || 'match_brands'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtered boxes — show all toggled-on stages
|
// Filtered boxes — show all toggled-on stages + editor boxes
|
||||||
const visibleBoxes = computed<FrameBBox[]>(() => {
|
const visibleBoxes = computed<FrameBBox[]>(() => {
|
||||||
const result: FrameBBox[] = []
|
const result: FrameBBox[] = []
|
||||||
|
// SSE boxes filtered by toggles
|
||||||
for (const [stage, boxes] of Object.entries(stageBoxes.value)) {
|
for (const [stage, boxes] of Object.entries(stageBoxes.value)) {
|
||||||
if (activeToggles.value.has(stage)) {
|
if (activeToggles.value.has(stage)) {
|
||||||
result.push(...boxes)
|
result.push(...boxes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Editor boxes (from local CV or server) — always shown
|
||||||
|
if (props.editorBoxes?.length) {
|
||||||
|
result.push(...props.editorBoxes)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { DataSource } from 'mpr-ui-framework'
|
|||||||
import { usePipelineStore } from '../stores/pipeline'
|
import { usePipelineStore } from '../stores/pipeline'
|
||||||
|
|
||||||
const PIPELINE_NODES = [
|
const PIPELINE_NODES = [
|
||||||
'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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
278
ui/framework/src/cv/edges.ts
Normal file
278
ui/framework/src/cv/edges.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Edge detection — TypeScript port of gpu/models/cv/edges.py
|
||||||
|
*
|
||||||
|
* 1:1 with the Python version. Same algorithm, same parameters,
|
||||||
|
* same output format. Runs in the browser, no network.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { toGrayscale, canny } from './imageOps'
|
||||||
|
import { houghLinesP, type LineSegment } from './hough'
|
||||||
|
|
||||||
|
export interface EdgeRegion {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
confidence: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeDetectionParams {
|
||||||
|
cannyLow: number
|
||||||
|
cannyHigh: number
|
||||||
|
houghThreshold: number
|
||||||
|
houghMinLength: number
|
||||||
|
houghMaxGap: number
|
||||||
|
pairMaxDistance: number
|
||||||
|
pairMinDistance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeDetectionResult {
|
||||||
|
regions: EdgeRegion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeDetectionDebugResult extends EdgeDetectionResult {
|
||||||
|
edgeImageData: ImageData // Canny output for overlay
|
||||||
|
linesImageData: ImageData // Frame with Hough lines drawn
|
||||||
|
horizontalCount: number
|
||||||
|
pairCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type HLine = { xMin: number; xMax: number; yMid: number; length: number }
|
||||||
|
|
||||||
|
/** Set a pixel on ImageData with bounds check */
|
||||||
|
function setPixel(img: ImageData, x: number, y: number, r: number, g: number, b: number) {
|
||||||
|
if (x >= 0 && x < img.width && y >= 0 && y < img.height) {
|
||||||
|
const p = (y * img.width + x) * 4
|
||||||
|
img.data[p] = r
|
||||||
|
img.data[p + 1] = g
|
||||||
|
img.data[p + 2] = b
|
||||||
|
img.data[p + 3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bresenham line drawing with thickness */
|
||||||
|
function drawLineThick(
|
||||||
|
img: ImageData,
|
||||||
|
x0: number, y0: number, x1: number, y1: number,
|
||||||
|
r: number, g: number, b: number,
|
||||||
|
thickness: number = 1,
|
||||||
|
) {
|
||||||
|
const dx = Math.abs(x1 - x0)
|
||||||
|
const dy = Math.abs(y1 - y0)
|
||||||
|
const sx = x0 < x1 ? 1 : -1
|
||||||
|
const sy = y0 < y1 ? 1 : -1
|
||||||
|
let err = dx - dy
|
||||||
|
const half = Math.floor(thickness / 2)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
for (let oy = -half; oy <= half; oy++) {
|
||||||
|
for (let ox = -half; ox <= half; ox++) {
|
||||||
|
setPixel(img, x0 + ox, y0 + oy, r, g, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (x0 === x1 && y0 === y1) break
|
||||||
|
const e2 = 2 * err
|
||||||
|
if (e2 > -dy) { err -= dy; x0 += sx }
|
||||||
|
if (e2 < dx) { err += dx; y0 += sy }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PARAMS: EdgeDetectionParams = {
|
||||||
|
cannyLow: 50,
|
||||||
|
cannyHigh: 150,
|
||||||
|
houghThreshold: 80,
|
||||||
|
houghMinLength: 100,
|
||||||
|
houghMaxGap: 10,
|
||||||
|
pairMaxDistance: 200,
|
||||||
|
pairMinDistance: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter to near-horizontal lines (within 10 degrees) */
|
||||||
|
function filterHorizontal(lines: LineSegment[], maxAngleDeg: number = 10): HLine[] {
|
||||||
|
const maxSlope = Math.tan((maxAngleDeg * Math.PI) / 180)
|
||||||
|
const result: HLine[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const dx = line.x2 - line.x1
|
||||||
|
if (dx === 0) continue
|
||||||
|
const slope = Math.abs((line.y2 - line.y1) / dx)
|
||||||
|
if (slope <= maxSlope) {
|
||||||
|
const yMid = (line.y1 + line.y2) / 2
|
||||||
|
const xMin = Math.min(line.x1, line.x2)
|
||||||
|
const xMax = Math.max(line.x1, line.x2)
|
||||||
|
const length = Math.sqrt(dx * dx + (line.y2 - line.y1) ** 2)
|
||||||
|
result.push({ xMin, xMax, yMid, length })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find pairs of horizontal lines that could be top/bottom of a hoarding */
|
||||||
|
function findLinePairs(
|
||||||
|
horizontals: HLine[],
|
||||||
|
minDistance: number,
|
||||||
|
maxDistance: number,
|
||||||
|
): [HLine, HLine][] {
|
||||||
|
const sorted = [...horizontals].sort((a, b) => a.yMid - b.yMid)
|
||||||
|
const pairs: [HLine, HLine][] = []
|
||||||
|
const used = new Set<number>()
|
||||||
|
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
if (used.has(i)) continue
|
||||||
|
const top = sorted[i]
|
||||||
|
|
||||||
|
for (let j = i + 1; j < sorted.length; j++) {
|
||||||
|
if (used.has(j)) continue
|
||||||
|
const bottom = sorted[j]
|
||||||
|
const yGap = bottom.yMid - top.yMid
|
||||||
|
|
||||||
|
if (yGap < minDistance) continue
|
||||||
|
if (yGap > maxDistance) break
|
||||||
|
|
||||||
|
// Check horizontal overlap (50% of shorter line)
|
||||||
|
const overlapStart = Math.max(top.xMin, bottom.xMin)
|
||||||
|
const overlapEnd = Math.min(top.xMax, bottom.xMax)
|
||||||
|
const overlap = overlapEnd - overlapStart
|
||||||
|
const shorterLength = Math.min(top.xMax - top.xMin, bottom.xMax - bottom.xMin)
|
||||||
|
|
||||||
|
if (shorterLength > 0 && overlap / shorterLength >= 0.5) {
|
||||||
|
pairs.push([top, bottom])
|
||||||
|
used.add(i)
|
||||||
|
used.add(j)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a line pair to a bounding box */
|
||||||
|
function pairToBox(
|
||||||
|
top: HLine,
|
||||||
|
bottom: HLine,
|
||||||
|
frameWidth: number,
|
||||||
|
frameHeight: number,
|
||||||
|
): EdgeRegion | null {
|
||||||
|
const x = Math.max(0, Math.min(top.xMin, bottom.xMin))
|
||||||
|
const y = Math.max(0, top.yMid)
|
||||||
|
const x2 = Math.min(frameWidth, Math.max(top.xMax, bottom.xMax))
|
||||||
|
const y2 = Math.min(frameHeight, bottom.yMid)
|
||||||
|
const w = x2 - x
|
||||||
|
const h = y2 - y
|
||||||
|
|
||||||
|
if (w < 20 || h < 5) return null
|
||||||
|
|
||||||
|
const avgLineLength = (top.length + bottom.length) / 2
|
||||||
|
const coverage = Math.min(1.0, avgLineLength / Math.max(w, 1))
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.round(x),
|
||||||
|
y: Math.round(y),
|
||||||
|
w: Math.round(w),
|
||||||
|
h: Math.round(h),
|
||||||
|
confidence: Math.round(coverage * 1000) / 1000,
|
||||||
|
label: 'edge_region',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect edges in an RGBA ImageData.
|
||||||
|
*
|
||||||
|
* Equivalent to gpu/models/cv/edges.py detect_edges()
|
||||||
|
*/
|
||||||
|
export function detectEdges(
|
||||||
|
imageData: ImageData,
|
||||||
|
params: Partial<EdgeDetectionParams> = {},
|
||||||
|
): EdgeDetectionResult {
|
||||||
|
const p = { ...DEFAULT_PARAMS, ...params }
|
||||||
|
const { width, height } = imageData
|
||||||
|
|
||||||
|
const gray = toGrayscale(imageData.data, width, height)
|
||||||
|
const edges = canny(gray, width, height, p.cannyLow, p.cannyHigh)
|
||||||
|
|
||||||
|
const rawLines = houghLinesP(edges, width, height, p.houghThreshold, p.houghMinLength, p.houghMaxGap)
|
||||||
|
const horizontals = filterHorizontal(rawLines)
|
||||||
|
|
||||||
|
if (horizontals.length < 2) return { regions: [] }
|
||||||
|
|
||||||
|
const pairs = findLinePairs(horizontals, p.pairMinDistance, p.pairMaxDistance)
|
||||||
|
const regions: EdgeRegion[] = []
|
||||||
|
for (const [top, bottom] of pairs) {
|
||||||
|
const box = pairToBox(top, bottom, width, height)
|
||||||
|
if (box) regions.push(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { regions }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect edges with debug visualizations.
|
||||||
|
*
|
||||||
|
* Equivalent to gpu/models/cv/edges.py detect_edges_debug()
|
||||||
|
*/
|
||||||
|
export function detectEdgesDebug(
|
||||||
|
imageData: ImageData,
|
||||||
|
params: Partial<EdgeDetectionParams> = {},
|
||||||
|
): EdgeDetectionDebugResult {
|
||||||
|
const p = { ...DEFAULT_PARAMS, ...params }
|
||||||
|
const { width, height, data } = imageData
|
||||||
|
|
||||||
|
const gray = toGrayscale(data, width, height)
|
||||||
|
const edges = canny(gray, width, height, p.cannyLow, p.cannyHigh)
|
||||||
|
|
||||||
|
// Edge overlay — white edges on black
|
||||||
|
const edgeImageData = new ImageData(width, height)
|
||||||
|
for (let i = 0; i < edges.length; i++) {
|
||||||
|
const px = i * 4
|
||||||
|
edgeImageData.data[px] = edges[i]
|
||||||
|
edgeImageData.data[px + 1] = edges[i]
|
||||||
|
edgeImageData.data[px + 2] = edges[i]
|
||||||
|
edgeImageData.data[px + 3] = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLines = houghLinesP(edges, width, height, p.houghThreshold, p.houghMinLength, p.houghMaxGap)
|
||||||
|
const horizontals = filterHorizontal(rawLines)
|
||||||
|
|
||||||
|
// Lines overlay — darken original frame so lines pop, then draw
|
||||||
|
const linesImageData = new ImageData(new Uint8ClampedArray(data), width, height)
|
||||||
|
for (let i = 0; i < linesImageData.data.length; i += 4) {
|
||||||
|
linesImageData.data[i] = Math.round(linesImageData.data[i] * 0.3)
|
||||||
|
linesImageData.data[i + 1] = Math.round(linesImageData.data[i + 1] * 0.3)
|
||||||
|
linesImageData.data[i + 2] = Math.round(linesImageData.data[i + 2] * 0.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw all Hough lines in red (3px thick)
|
||||||
|
for (const line of rawLines) {
|
||||||
|
drawLineThick(linesImageData, line.x1, line.y1, line.x2, line.y2, 255, 50, 50, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw horizontal lines in cyan (3px thick)
|
||||||
|
for (const h of horizontals) {
|
||||||
|
drawLineThick(linesImageData, Math.round(h.xMin), Math.round(h.yMid), Math.round(h.xMax), Math.round(h.yMid), 0, 255, 255, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairs = horizontals.length >= 2
|
||||||
|
? findLinePairs(horizontals, p.pairMinDistance, p.pairMaxDistance)
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Draw paired lines in bright green (4px thick)
|
||||||
|
for (const [top, bottom] of pairs) {
|
||||||
|
drawLineThick(linesImageData, Math.round(top.xMin), Math.round(top.yMid), Math.round(top.xMax), Math.round(top.yMid), 0, 255, 0, 4)
|
||||||
|
drawLineThick(linesImageData, Math.round(bottom.xMin), Math.round(bottom.yMid), Math.round(bottom.xMax), Math.round(bottom.yMid), 0, 255, 0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions: EdgeRegion[] = []
|
||||||
|
for (const [top, bottom] of pairs) {
|
||||||
|
const box = pairToBox(top, bottom, width, height)
|
||||||
|
if (box) regions.push(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
regions,
|
||||||
|
edgeImageData,
|
||||||
|
linesImageData,
|
||||||
|
horizontalCount: horizontals.length,
|
||||||
|
pairCount: pairs.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
147
ui/framework/src/cv/hough.ts
Normal file
147
ui/framework/src/cv/hough.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Probabilistic Hough Line Transform — pure TypeScript.
|
||||||
|
*
|
||||||
|
* Equivalent to cv2.HoughLinesP. Finds line segments in a binary edge image.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LineSegment {
|
||||||
|
x1: number
|
||||||
|
y1: number
|
||||||
|
x2: number
|
||||||
|
y2: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probabilistic Hough Line Transform.
|
||||||
|
*
|
||||||
|
* @param edges - Binary edge image (255 = edge, 0 = not)
|
||||||
|
* @param width - Image width
|
||||||
|
* @param height - Image height
|
||||||
|
* @param threshold - Accumulator threshold (min votes for a line)
|
||||||
|
* @param minLineLength - Minimum line length in pixels
|
||||||
|
* @param maxLineGap - Maximum gap between points on the same line
|
||||||
|
*/
|
||||||
|
export function houghLinesP(
|
||||||
|
edges: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
threshold: number,
|
||||||
|
minLineLength: number,
|
||||||
|
maxLineGap: number,
|
||||||
|
): LineSegment[] {
|
||||||
|
const diag = Math.ceil(Math.sqrt(width * width + height * height))
|
||||||
|
const numAngles = 180
|
||||||
|
const rhoMax = diag
|
||||||
|
|
||||||
|
// Precompute sin/cos tables
|
||||||
|
const cosTable = new Float64Array(numAngles)
|
||||||
|
const sinTable = new Float64Array(numAngles)
|
||||||
|
for (let t = 0; t < numAngles; t++) {
|
||||||
|
const angle = (t * Math.PI) / numAngles
|
||||||
|
cosTable[t] = Math.cos(angle)
|
||||||
|
sinTable[t] = Math.sin(angle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect edge points
|
||||||
|
const edgePoints: [number, number][] = []
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
if (edges[y * width + x] === 255) {
|
||||||
|
edgePoints.push([x, y])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edgePoints.length === 0) return []
|
||||||
|
|
||||||
|
// Shuffle edge points for probabilistic sampling
|
||||||
|
for (let i = edgePoints.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
const tmp = edgePoints[i]
|
||||||
|
edgePoints[i] = edgePoints[j]
|
||||||
|
edgePoints[j] = tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulator
|
||||||
|
const accum = new Int32Array(numAngles * (2 * rhoMax + 1))
|
||||||
|
const used = new Uint8Array(width * height)
|
||||||
|
const lines: LineSegment[] = []
|
||||||
|
|
||||||
|
for (const [px, py] of edgePoints) {
|
||||||
|
if (used[py * width + px]) continue
|
||||||
|
|
||||||
|
// Vote
|
||||||
|
let maxVotes = 0
|
||||||
|
let bestTheta = 0
|
||||||
|
for (let t = 0; t < numAngles; t++) {
|
||||||
|
const rho = Math.round(px * cosTable[t] + py * sinTable[t]) + rhoMax
|
||||||
|
const idx = t * (2 * rhoMax + 1) + rho
|
||||||
|
accum[idx]++
|
||||||
|
if (accum[idx] > maxVotes) {
|
||||||
|
maxVotes = accum[idx]
|
||||||
|
bestTheta = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxVotes < threshold) continue
|
||||||
|
|
||||||
|
// Walk along the line at bestTheta through (px, py)
|
||||||
|
const ct = cosTable[bestTheta]
|
||||||
|
const st = sinTable[bestTheta]
|
||||||
|
|
||||||
|
// Line direction is perpendicular to (cos, sin)
|
||||||
|
const dx = -st
|
||||||
|
const dy = ct
|
||||||
|
|
||||||
|
// Walk forward and backward to find line extent
|
||||||
|
const walkLine = (startX: number, startY: number, dirX: number, dirY: number): [number, number] => {
|
||||||
|
let lastEdgeX = startX
|
||||||
|
let lastEdgeY = startY
|
||||||
|
let gap = 0
|
||||||
|
let cx = startX
|
||||||
|
let cy = startY
|
||||||
|
|
||||||
|
for (let step = 1; step < Math.max(width, height); step++) {
|
||||||
|
const nx = Math.round(cx + dirX * step)
|
||||||
|
const ny = Math.round(cy + dirY * step)
|
||||||
|
if (nx < 0 || nx >= width || ny < 0 || ny >= height) break
|
||||||
|
|
||||||
|
if (edges[ny * width + nx] === 255 && !used[ny * width + nx]) {
|
||||||
|
lastEdgeX = nx
|
||||||
|
lastEdgeY = ny
|
||||||
|
gap = 0
|
||||||
|
} else {
|
||||||
|
gap++
|
||||||
|
if (gap > maxLineGap) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [lastEdgeX, lastEdgeY]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [x1, y1] = walkLine(px, py, -dx, -dy)
|
||||||
|
const [x2, y2] = walkLine(px, py, dx, dy)
|
||||||
|
|
||||||
|
const length = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
|
||||||
|
if (length < minLineLength) continue
|
||||||
|
|
||||||
|
// Mark pixels as used
|
||||||
|
const steps = Math.ceil(length)
|
||||||
|
for (let s = 0; s <= steps; s++) {
|
||||||
|
const mx = Math.round(x1 + (x2 - x1) * s / steps)
|
||||||
|
const my = Math.round(y1 + (y2 - y1) * s / steps)
|
||||||
|
if (mx >= 0 && mx < width && my >= 0 && my < height) {
|
||||||
|
used[my * width + mx] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push({ x1, y1, x2, y2 })
|
||||||
|
|
||||||
|
// Unvote (clean accumulator for used points)
|
||||||
|
for (let t = 0; t < numAngles; t++) {
|
||||||
|
const rho = Math.round(px * cosTable[t] + py * sinTable[t]) + rhoMax
|
||||||
|
accum[t * (2 * rhoMax + 1) + rho]--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
190
ui/framework/src/cv/imageOps.ts
Normal file
190
ui/framework/src/cv/imageOps.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Pure TypeScript image operations — no OpenCV dependency.
|
||||||
|
*
|
||||||
|
* These implement the subset of CV operations needed for the edge
|
||||||
|
* detection editor. Same algorithms as the Python/OpenCV versions
|
||||||
|
* but running in the browser with zero WASM overhead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Grayscale from RGBA ImageData */
|
||||||
|
export function toGrayscale(data: Uint8ClampedArray, width: number, height: number): Uint8Array {
|
||||||
|
const gray = new Uint8Array(width * height)
|
||||||
|
for (let i = 0; i < gray.length; i++) {
|
||||||
|
const p = i * 4
|
||||||
|
// ITU-R BT.601 luma
|
||||||
|
gray[i] = Math.round(0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2])
|
||||||
|
}
|
||||||
|
return gray
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 5x5 Gaussian blur */
|
||||||
|
export function gaussianBlur(src: Uint8Array, width: number, height: number): Uint8Array {
|
||||||
|
// 5x5 Gaussian kernel (sigma ~1.4, matches OpenCV default for Canny)
|
||||||
|
const kernel = [
|
||||||
|
2, 4, 5, 4, 2,
|
||||||
|
4, 9, 12, 9, 4,
|
||||||
|
5, 12, 15, 12, 5,
|
||||||
|
4, 9, 12, 9, 4,
|
||||||
|
2, 4, 5, 4, 2,
|
||||||
|
]
|
||||||
|
const kSum = 159
|
||||||
|
const out = new Uint8Array(width * height)
|
||||||
|
|
||||||
|
for (let y = 2; y < height - 2; y++) {
|
||||||
|
for (let x = 2; x < width - 2; x++) {
|
||||||
|
let sum = 0
|
||||||
|
for (let ky = -2; ky <= 2; ky++) {
|
||||||
|
for (let kx = -2; kx <= 2; kx++) {
|
||||||
|
sum += src[(y + ky) * width + (x + kx)] * kernel[(ky + 2) * 5 + (kx + 2)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[y * width + x] = Math.round(sum / kSum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sobel gradients → magnitude + direction */
|
||||||
|
export function sobelGradients(
|
||||||
|
src: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): { magnitude: Float32Array; direction: Float32Array } {
|
||||||
|
const size = width * height
|
||||||
|
const magnitude = new Float32Array(size)
|
||||||
|
const direction = new Float32Array(size)
|
||||||
|
|
||||||
|
for (let y = 1; y < height - 1; y++) {
|
||||||
|
for (let x = 1; x < width - 1; x++) {
|
||||||
|
const i = y * width + x
|
||||||
|
// Sobel 3x3
|
||||||
|
const gx =
|
||||||
|
-src[(y - 1) * width + (x - 1)] - 2 * src[y * width + (x - 1)] - src[(y + 1) * width + (x - 1)] +
|
||||||
|
src[(y - 1) * width + (x + 1)] + 2 * src[y * width + (x + 1)] + src[(y + 1) * width + (x + 1)]
|
||||||
|
const gy =
|
||||||
|
-src[(y - 1) * width + (x - 1)] - 2 * src[(y - 1) * width + x] - src[(y - 1) * width + (x + 1)] +
|
||||||
|
src[(y + 1) * width + (x - 1)] + 2 * src[(y + 1) * width + x] + src[(y + 1) * width + (x + 1)]
|
||||||
|
|
||||||
|
magnitude[i] = Math.sqrt(gx * gx + gy * gy)
|
||||||
|
direction[i] = Math.atan2(gy, gx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { magnitude, direction }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-maximum suppression for Canny */
|
||||||
|
export function nonMaxSuppression(
|
||||||
|
magnitude: Float32Array,
|
||||||
|
direction: Float32Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): Float32Array {
|
||||||
|
const out = new Float32Array(width * height)
|
||||||
|
|
||||||
|
for (let y = 1; y < height - 1; y++) {
|
||||||
|
for (let x = 1; x < width - 1; x++) {
|
||||||
|
const i = y * width + x
|
||||||
|
const mag = magnitude[i]
|
||||||
|
if (mag === 0) continue
|
||||||
|
|
||||||
|
// Quantize direction to 4 angles (0, 45, 90, 135)
|
||||||
|
let angle = (direction[i] * 180) / Math.PI
|
||||||
|
if (angle < 0) angle += 180
|
||||||
|
|
||||||
|
let n1 = 0, n2 = 0
|
||||||
|
if ((angle < 22.5) || (angle >= 157.5)) {
|
||||||
|
n1 = magnitude[y * width + (x + 1)]
|
||||||
|
n2 = magnitude[y * width + (x - 1)]
|
||||||
|
} else if (angle < 67.5) {
|
||||||
|
n1 = magnitude[(y - 1) * width + (x + 1)]
|
||||||
|
n2 = magnitude[(y + 1) * width + (x - 1)]
|
||||||
|
} else if (angle < 112.5) {
|
||||||
|
n1 = magnitude[(y - 1) * width + x]
|
||||||
|
n2 = magnitude[(y + 1) * width + x]
|
||||||
|
} else {
|
||||||
|
n1 = magnitude[(y - 1) * width + (x - 1)]
|
||||||
|
n2 = magnitude[(y + 1) * width + (x + 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
out[i] = (mag >= n1 && mag >= n2) ? mag : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hysteresis thresholding for Canny */
|
||||||
|
export function hysteresis(
|
||||||
|
nms: Float32Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
low: number,
|
||||||
|
high: number,
|
||||||
|
): Uint8Array {
|
||||||
|
const out = new Uint8Array(width * height)
|
||||||
|
|
||||||
|
// Mark strong and weak edges
|
||||||
|
const STRONG = 255
|
||||||
|
const WEAK = 128
|
||||||
|
for (let i = 0; i < nms.length; i++) {
|
||||||
|
if (nms[i] >= high) out[i] = STRONG
|
||||||
|
else if (nms[i] >= low) out[i] = WEAK
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect weak edges adjacent to strong edges
|
||||||
|
let changed = true
|
||||||
|
while (changed) {
|
||||||
|
changed = false
|
||||||
|
for (let y = 1; y < height - 1; y++) {
|
||||||
|
for (let x = 1; x < width - 1; x++) {
|
||||||
|
const i = y * width + x
|
||||||
|
if (out[i] !== WEAK) continue
|
||||||
|
// Check 8-neighbors for strong edge
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (out[(y + dy) * width + (x + dx)] === STRONG) {
|
||||||
|
out[i] = STRONG
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress remaining weak edges
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
if (out[i] !== STRONG) out[i] = 0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full Canny edge detection */
|
||||||
|
export function canny(
|
||||||
|
gray: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
lowThreshold: number,
|
||||||
|
highThreshold: number,
|
||||||
|
): Uint8Array {
|
||||||
|
const blurred = gaussianBlur(gray, width, height)
|
||||||
|
const { magnitude, direction } = sobelGradients(blurred, width, height)
|
||||||
|
const nms = nonMaxSuppression(magnitude, direction, width, height)
|
||||||
|
return hysteresis(nms, width, height, lowThreshold, highThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert edge image (Uint8Array) to base64 JPEG via offscreen canvas */
|
||||||
|
export function edgeImageToB64(edges: Uint8Array, width: number, height: number): string {
|
||||||
|
const canvas = new OffscreenCanvas(width, height)
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
const imgData = ctx.createImageData(width, height)
|
||||||
|
for (let i = 0; i < edges.length; i++) {
|
||||||
|
const p = i * 4
|
||||||
|
imgData.data[p] = edges[i]
|
||||||
|
imgData.data[p + 1] = edges[i]
|
||||||
|
imgData.data[p + 2] = edges[i]
|
||||||
|
imgData.data[p + 3] = 255
|
||||||
|
}
|
||||||
|
ctx.putImageData(imgData, 0, 0)
|
||||||
|
const blob = canvas.convertToBlob({ type: 'image/jpeg', quality: 0.7 })
|
||||||
|
return '' // placeholder — async handled in worker
|
||||||
|
}
|
||||||
80
ui/framework/src/cv/index.ts
Normal file
80
ui/framework/src/cv/index.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Browser-side CV — public API.
|
||||||
|
*
|
||||||
|
* Runs edge detection directly on the main thread.
|
||||||
|
* Pure TypeScript, no WASM, no dependencies.
|
||||||
|
* ~10-50ms per 1080p frame — fast enough for slider feedback.
|
||||||
|
*
|
||||||
|
* TODO: Move to Web Worker when processing larger batches.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { runEdgeDetection, runEdgeDetectionDebug } from 'mpr-ui-framework/src/cv'
|
||||||
|
* const result = await runEdgeDetection(imageData, params)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { detectEdges, detectEdgesDebug, type EdgeRegion, type EdgeDetectionParams } from './edges'
|
||||||
|
|
||||||
|
export type { EdgeRegion, EdgeDetectionParams } from './edges'
|
||||||
|
export type { EdgeDetectionResult, EdgeDetectionDebugResult } from './edges'
|
||||||
|
|
||||||
|
/** Run edge detection. Returns bounding boxes. */
|
||||||
|
export async function runEdgeDetection(
|
||||||
|
imageData: ImageData,
|
||||||
|
params: Partial<EdgeDetectionParams> = {},
|
||||||
|
): Promise<{ regions: EdgeRegion[] }> {
|
||||||
|
return detectEdges(imageData, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run edge detection with debug overlays. Returns boxes + visualization ImageData. */
|
||||||
|
export async function runEdgeDetectionDebug(
|
||||||
|
imageData: ImageData,
|
||||||
|
params: Partial<EdgeDetectionParams> = {},
|
||||||
|
): Promise<{
|
||||||
|
regions: EdgeRegion[]
|
||||||
|
edgeImageData: ImageData
|
||||||
|
linesImageData: ImageData
|
||||||
|
horizontalCount: number
|
||||||
|
pairCount: number
|
||||||
|
}> {
|
||||||
|
return detectEdgesDebug(imageData, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a base64 JPEG string to ImageData.
|
||||||
|
*
|
||||||
|
* Used to convert the checkpoint frame (base64) into ImageData
|
||||||
|
* that the CV functions can process.
|
||||||
|
*/
|
||||||
|
export function b64ToImageData(b64: string): Promise<ImageData> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = new OffscreenCanvas(img.width, img.height)
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
resolve(ctx.getImageData(0, 0, img.width, img.height))
|
||||||
|
}
|
||||||
|
img.onerror = () => reject(new Error('Failed to decode image'))
|
||||||
|
img.src = `data:image/jpeg;base64,${b64}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode ImageData to base64 JPEG string.
|
||||||
|
*
|
||||||
|
* Used to convert debug overlay ImageData back to base64
|
||||||
|
* for the FrameRenderer overlays prop.
|
||||||
|
*/
|
||||||
|
export async function imageDataToB64(imageData: ImageData): Promise<string> {
|
||||||
|
const canvas = new OffscreenCanvas(imageData.width, imageData.height)
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
ctx.putImageData(imageData, 0, 0)
|
||||||
|
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.7 })
|
||||||
|
const buffer = await blob.arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ''
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
41
ui/framework/src/cv/worker.ts
Normal file
41
ui/framework/src/cv/worker.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* CV Web Worker — runs edge detection off the main thread.
|
||||||
|
*
|
||||||
|
* Message protocol:
|
||||||
|
* Main → Worker: { type: 'detect_edges', imageData: ImageData, params: {...} }
|
||||||
|
* Main → Worker: { type: 'detect_edges_debug', imageData: ImageData, params: {...} }
|
||||||
|
* Worker → Main: { type: 'result', regions: [...] }
|
||||||
|
* Worker → Main: { type: 'debug_result', regions: [...], edgeImageData, linesImageData, horizontalCount, pairCount }
|
||||||
|
* Worker → Main: { type: 'error', message: string }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { detectEdges, detectEdgesDebug, type EdgeDetectionParams } from './edges'
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent) => {
|
||||||
|
const { type, imageData, params } = event.data
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === 'detect_edges') {
|
||||||
|
const result = detectEdges(imageData, params)
|
||||||
|
self.postMessage({ type: 'result', regions: result.regions })
|
||||||
|
} else if (type === 'detect_edges_debug') {
|
||||||
|
const result = detectEdgesDebug(imageData, params)
|
||||||
|
self.postMessage({
|
||||||
|
type: 'debug_result',
|
||||||
|
regions: result.regions,
|
||||||
|
edgeImageData: result.edgeImageData,
|
||||||
|
linesImageData: result.linesImageData,
|
||||||
|
horizontalCount: result.horizontalCount,
|
||||||
|
pairCount: result.pairCount,
|
||||||
|
}, [
|
||||||
|
// Transfer ownership of the backing buffers for zero-copy
|
||||||
|
result.edgeImageData.data.buffer,
|
||||||
|
result.linesImageData.data.buffer,
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
self.postMessage({ type: 'error', message: `Unknown message type: ${type}` })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
self.postMessage({ type: 'error', message: String(e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ const flowNodes = computed(() =>
|
|||||||
label: n.id.replace(/_/g, ' '),
|
label: n.id.replace(/_/g, ' '),
|
||||||
status: n.status,
|
status: n.status,
|
||||||
color: statusColors[n.status] ?? statusColors.pending,
|
color: statusColors[n.status] ?? statusColors.pending,
|
||||||
textColor: n.status === 'pending' ? '#888' : '#000',
|
textColor: '#fff',
|
||||||
hasRegionEditor: regionStageSet.value.has(n.id),
|
hasRegionEditor: regionStageSet.value.has(n.id),
|
||||||
isRunning: n.status === 'running',
|
isRunning: n.status === 'running',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user