phase 1
This commit is contained in:
@@ -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<string, 'idle' | 'live' | 'processing' | 'error'> = {
|
||||
idle: 'idle',
|
||||
connecting: 'processing',
|
||||
@@ -110,31 +122,107 @@ async function stopPipeline() {
|
||||
const currentFrameImage = ref<string | 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) => {
|
||||
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<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
|
||||
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: {
|
||||
regions_by_frame?: Record<string, unknown[]>
|
||||
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) {
|
||||
// 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) {
|
||||
|
||||
<!-- === BBOX EDITOR MODE === -->
|
||||
<template v-else-if="pipeline.layoutMode === 'bbox_editor'">
|
||||
<Panel :title="`Region Editor — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
|
||||
<div class="editor-placeholder">
|
||||
<div class="editor-layout">
|
||||
<!-- Top: frame + sliders side by side -->
|
||||
<div class="editor-top">
|
||||
<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 class="editor-tools">
|
||||
<ResizeHandle direction="horizontal" @resize="onSlidersResize" />
|
||||
<div class="editor-sliders" :style="{ width: slidersWidth + 'px' }">
|
||||
<StageConfigSliders
|
||||
v-if="pipeline.editorStage"
|
||||
:stage="pipeline.editorStage"
|
||||
@@ -250,10 +340,29 @@ function onJobStarted(newJobId: string) {
|
||||
:frame-ref="currentFrameRef"
|
||||
@replay-result="onReplayResult"
|
||||
/>
|
||||
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
|
||||
</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>
|
||||
|
||||
<!-- === STAGE EDITOR MODE === -->
|
||||
@@ -279,28 +388,8 @@ function onJobStarted(newJobId: string) {
|
||||
|
||||
<!-- Bottom bar: Log or Blob viewer depending on mode -->
|
||||
<div class="log-row">
|
||||
<template v-if="pipeline.layoutMode === 'bbox_editor'">
|
||||
<Panel :title="`Debug Overlays — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
|
||||
<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 v-if="pipeline.layoutMode === 'source_selector'">
|
||||
<!-- no log in source selector -->
|
||||
</template>
|
||||
<template v-else>
|
||||
<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-row {
|
||||
flex-shrink: 0;
|
||||
height: 200px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.empty { color: var(--text-dim); padding: var(--space-6); text-align: center; }
|
||||
|
||||
/* Editor placeholders */
|
||||
.editor-placeholder {
|
||||
/* Editor layout — frame maximized, sliders right, overlays bottom */
|
||||
.editor-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: var(--space-2);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-top {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-frame {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
.editor-frame > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-tools {
|
||||
width: 200px;
|
||||
.editor-sliders {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-3);
|
||||
min-width: 210px;
|
||||
padding: var(--space-2);
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--panel-radius);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.editor-bottom {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
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);
|
||||
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 {
|
||||
@@ -517,23 +654,6 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
|
||||
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 {
|
||||
padding: var(--space-4);
|
||||
color: var(--text-dim);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<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 {
|
||||
name: string
|
||||
type: string // "bool" | "int" | "float" | "str"
|
||||
type: string
|
||||
default: unknown
|
||||
description: string
|
||||
min: number | null
|
||||
@@ -14,16 +21,15 @@ interface ConfigField {
|
||||
const props = defineProps<{
|
||||
/** Stage name (e.g. "detect_edges") */
|
||||
stage: string
|
||||
/** Job ID for replay-stage calls (used as fallback) */
|
||||
/** Job ID (used for server mode fallback) */
|
||||
jobId: string
|
||||
/** Currently displayed frame image (base64 JPEG) — sent directly to GPU for fast feedback */
|
||||
/** Currently displayed frame image (base64 JPEG) */
|
||||
frameImage?: string | null
|
||||
/** Currently displayed frame sequence number */
|
||||
frameRef?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Emitted when replay returns new regions */
|
||||
'replay-result': [result: {
|
||||
regions_by_frame: Record<string, unknown[]>
|
||||
debug: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
|
||||
@@ -36,24 +42,50 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const regionCount = ref<number | null>(null)
|
||||
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 () => {
|
||||
// Try loading from API, fall back to hardcoded defaults
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/config/stages/${props.stage}`)
|
||||
if (!resp.ok) {
|
||||
error.value = `Failed to load config: ${resp.status}`
|
||||
return
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
fields.value = data.config_fields ?? []
|
||||
} else {
|
||||
fields.value = EDGE_DEFAULTS
|
||||
}
|
||||
const data = await resp.json()
|
||||
fields.value = data.config_fields ?? []
|
||||
} catch {
|
||||
fields.value = EDGE_DEFAULTS
|
||||
}
|
||||
|
||||
// Initialize values from defaults
|
||||
for (const f of fields.value) {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = `Failed to load config: ${e}`
|
||||
for (const f of fields.value) {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
|
||||
// Auto-run on first frame if already available
|
||||
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) {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
applyDetection()
|
||||
}
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function onSliderChange() {
|
||||
// Debounce — wait 300ms after last change before calling replay
|
||||
if (!autoApply.value) return
|
||||
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
|
||||
error.value = null
|
||||
execTimeMs.value = null
|
||||
|
||||
// Direct GPU call — send the frame image + current slider params
|
||||
// Skip checkpoint/replay path for ~50-100ms round trips instead of seconds
|
||||
if (props.frameImage && props.stage === 'detect_edges') {
|
||||
await callGpuDirect()
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: replay-stage path (for stages without direct GPU endpoint)
|
||||
if (!props.jobId) {
|
||||
error.value = 'No frame image or job ID available'
|
||||
try {
|
||||
if (execMode.value === 'local') {
|
||||
await runLocal()
|
||||
} else {
|
||||
await runServer()
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = `${execMode.value} failed: ${e}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
await callReplayStage()
|
||||
}
|
||||
|
||||
async function callGpuDirect() {
|
||||
const body: Record<string, unknown> = {
|
||||
image: props.frameImage,
|
||||
/** Browser-side CV — no network, instant */
|
||||
async function runLocal() {
|
||||
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) {
|
||||
if (f.name !== 'enabled') {
|
||||
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'
|
||||
|
||||
try {
|
||||
const resp = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const resp = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text()
|
||||
error.value = `GPU call failed: ${detail}`
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
regionCount.value = data.regions?.length ?? 0
|
||||
|
||||
// Build result in the same shape the parent expects
|
||||
const frameKey = String(props.frameRef ?? 0)
|
||||
const result: Record<string, unknown> = {
|
||||
regions_by_frame: { [frameKey]: data.regions ?? [] },
|
||||
debug: {},
|
||||
}
|
||||
if (data.edge_overlay_b64 || data.lines_overlay_b64) {
|
||||
result.debug = {
|
||||
[frameKey]: {
|
||||
edge_overlay_b64: data.edge_overlay_b64 ?? '',
|
||||
lines_overlay_b64: data.lines_overlay_b64 ?? '',
|
||||
horizontal_count: data.horizontal_count ?? 0,
|
||||
pair_count: data.pair_count ?? 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
emit('replay-result', result as any)
|
||||
} catch (e) {
|
||||
error.value = `GPU call failed: ${e}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function callReplayStage() {
|
||||
const overrides: Record<string, unknown> = {}
|
||||
for (const f of fields.value) {
|
||||
if (values.value[f.name] !== f.default) {
|
||||
overrides[f.name] = values.value[f.name]
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text()
|
||||
throw new Error(detail)
|
||||
}
|
||||
|
||||
const overrideKey = stageToOverrideKey(props.stage)
|
||||
const configOverrides = Object.keys(overrides).length > 0
|
||||
? { [overrideKey]: overrides }
|
||||
: null
|
||||
const data = await resp.json()
|
||||
execTimeMs.value = Math.round(performance.now() - t0)
|
||||
regionCount.value = data.regions?.length ?? 0
|
||||
|
||||
const body = {
|
||||
job_id: props.jobId,
|
||||
stage: props.stage,
|
||||
frame_refs: props.frameRef != null ? [props.frameRef] : null,
|
||||
config_overrides: configOverrides,
|
||||
debug: debugEnabled.value,
|
||||
const frameKey = String(props.frameRef ?? 0)
|
||||
const result: any = {
|
||||
regions_by_frame: { [frameKey]: data.regions ?? [] },
|
||||
debug: {},
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/detect/replay-stage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text()
|
||||
error.value = `Replay failed: ${detail}`
|
||||
return
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await resp.json()
|
||||
regionCount.value = result.region_count ?? 0
|
||||
emit('replay-result', result)
|
||||
} catch (e) {
|
||||
error.value = `Replay failed: ${e}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stageToOverrideKey(stage: string): string {
|
||||
const map: Record<string, string> = {
|
||||
detect_edges: 'region_analysis',
|
||||
detect_objects: 'detection',
|
||||
run_ocr: 'ocr',
|
||||
match_brands: 'resolver',
|
||||
}
|
||||
return map[stage] || stage
|
||||
emit('replay-result', result)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -211,6 +238,18 @@ function stageToOverrideKey(stage: string): string {
|
||||
<button class="sliders-reset" @click="resetDefaults" title="Reset to defaults">Reset</button>
|
||||
</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 class="sliders-list">
|
||||
@@ -245,20 +284,25 @@ function stageToOverrideKey(stage: string): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug overlay toggle -->
|
||||
<label class="slider-field bool-field debug-toggle">
|
||||
<input type="checkbox" v-model="debugEnabled" @change="onSliderChange" />
|
||||
<span class="field-label">Show edge overlays</span>
|
||||
</label>
|
||||
|
||||
<!-- Feedback -->
|
||||
<!-- 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' }}
|
||||
</button>
|
||||
<label class="auto-apply-toggle">
|
||||
<input type="checkbox" v-model="autoApply" />
|
||||
<span>Auto</span>
|
||||
</label>
|
||||
<span v-if="regionCount != null" class="region-count">
|
||||
{{ regionCount }} regions
|
||||
</span>
|
||||
<span v-if="execTimeMs != null" class="exec-time">
|
||||
{{ execTimeMs }}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -298,6 +342,34 @@ function stageToOverrideKey(stage: string): string {
|
||||
}
|
||||
.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 {
|
||||
color: var(--status-error);
|
||||
font-size: 10px;
|
||||
@@ -354,7 +426,6 @@ function stageToOverrideKey(stage: string): string {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Range slider styling */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@@ -388,11 +459,6 @@ input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
}
|
||||
|
||||
.debug-toggle {
|
||||
padding-top: var(--space-1);
|
||||
border-top: var(--panel-border);
|
||||
}
|
||||
|
||||
.sliders-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -415,8 +481,25 @@ input[type="checkbox"] {
|
||||
.apply-btn:hover { opacity: 0.85; }
|
||||
.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 {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.exec-time {
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import FrameRenderer 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'
|
||||
/** Debug overlay layers passed from parent (editor mode) */
|
||||
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
|
||||
const stageBoxes = ref<Record<string, FrameBBox[]>>({})
|
||||
@@ -117,14 +126,19 @@ function sourceToStage(source: string): string {
|
||||
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 result: FrameBBox[] = []
|
||||
// SSE boxes filtered by toggles
|
||||
for (const [stage, boxes] of Object.entries(stageBoxes.value)) {
|
||||
if (activeToggles.value.has(stage)) {
|
||||
result.push(...boxes)
|
||||
}
|
||||
}
|
||||
// Editor boxes (from local CV or server) — always shown
|
||||
if (props.editorBoxes?.length) {
|
||||
result.push(...props.editorBoxes)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { DataSource } from 'mpr-ui-framework'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
|
||||
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',
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user