phase cv 0
This commit is contained in:
@@ -10,7 +10,9 @@ import BrandTablePanel from './panels/BrandTablePanel.vue'
|
||||
import TimelinePanel from './panels/TimelinePanel.vue'
|
||||
import CostStatsPanel from './panels/CostStatsPanel.vue'
|
||||
import SourceSelector from './panels/SourceSelector.vue'
|
||||
import StageConfigSliders from './components/StageConfigSliders.vue'
|
||||
import type { StatsUpdate, RunContext } from './types/sse-contract'
|
||||
import type { FrameOverlay } from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
||||
import { usePipelineStore } from './stores/pipeline'
|
||||
|
||||
const pipeline = usePipelineStore()
|
||||
@@ -21,9 +23,10 @@ const stats = ref<StatsUpdate | null>(null)
|
||||
const runContext = ref<RunContext | null>(null)
|
||||
const status = ref<'idle' | 'live' | 'processing' | 'error'>('idle')
|
||||
const logPanel = ref<{ clear: () => void } | null>(null)
|
||||
const sseConnected = ref(false)
|
||||
|
||||
// No job selected → open source selector
|
||||
if (!jobParam) {
|
||||
// No job selected and no hash route → open source selector
|
||||
if (!jobParam && !window.location.hash.replace(/^#\/?/, '')) {
|
||||
pipeline.openSourceSelector()
|
||||
}
|
||||
|
||||
@@ -35,7 +38,6 @@ const source = new SSEDataSource({
|
||||
|
||||
source.on<StatsUpdate>('stats_update', (e) => {
|
||||
stats.value = e
|
||||
// Capture run context from first event that carries it
|
||||
if (!runContext.value && e.run_id) {
|
||||
runContext.value = {
|
||||
run_id: (e as any).run_id,
|
||||
@@ -53,7 +55,7 @@ source.on<{ report?: { status?: string, error?: string } }>('job_complete', (e)
|
||||
|
||||
// Resizable splits
|
||||
const pipelineWidth = ref(320)
|
||||
const detectionsFlex = ref(3) // ratio for detections vs stats
|
||||
const detectionsFlex = ref(3)
|
||||
const viewerHeight = ref(240)
|
||||
const timelineFlex = ref(1)
|
||||
const tableFlex = ref(1)
|
||||
@@ -82,11 +84,19 @@ const statusMap: Record<string, 'idle' | 'live' | 'processing' | 'error'> = {
|
||||
live: 'live',
|
||||
error: 'error',
|
||||
}
|
||||
const checkStatus = () => { status.value = statusMap[source.status.value] ?? 'idle' }
|
||||
const checkStatus = () => {
|
||||
if (sseConnected.value) {
|
||||
status.value = statusMap[source.status.value] ?? 'idle'
|
||||
}
|
||||
}
|
||||
setInterval(checkStatus, 500)
|
||||
|
||||
if (jobId.value) {
|
||||
// Only connect SSE for live pipeline runs (no hash route = dashboard mode)
|
||||
// Scenario URLs use hash routing and load from checkpoint instead
|
||||
const isScenarioMode = pipeline.isEditing || pipeline.layoutMode !== 'normal'
|
||||
if (jobId.value && !isScenarioMode) {
|
||||
source.connect()
|
||||
sseConnected.value = true
|
||||
}
|
||||
|
||||
async function stopPipeline() {
|
||||
@@ -96,6 +106,37 @@ async function stopPipeline() {
|
||||
} catch { /* ignore — UI will see the cancel event via SSE */ }
|
||||
}
|
||||
|
||||
// Current frame image (base64) — tracked for the editor's direct GPU calls
|
||||
const currentFrameImage = ref<string | null>(null)
|
||||
const currentFrameRef = ref<number | null>(null)
|
||||
|
||||
source.on<{ frame_ref: number; jpeg_b64: string }>('frame_update', (e) => {
|
||||
currentFrameImage.value = e.jpeg_b64
|
||||
currentFrameRef.value = e.frame_ref
|
||||
})
|
||||
|
||||
// Debug overlays from replay-stage results
|
||||
const editorOverlays = ref<FrameOverlay[]>([])
|
||||
|
||||
function onReplayResult(result: {
|
||||
debug?: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
|
||||
}) {
|
||||
const overlays: FrameOverlay[] = []
|
||||
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) {
|
||||
if (firstDebug.edge_overlay_b64) {
|
||||
overlays.push({ src: firstDebug.edge_overlay_b64, label: 'Canny edges', visible: true, opacity: 0.4 })
|
||||
}
|
||||
if (firstDebug.lines_overlay_b64) {
|
||||
overlays.push({ src: firstDebug.lines_overlay_b64, label: 'Hough lines', visible: true, opacity: 0.5 })
|
||||
}
|
||||
}
|
||||
}
|
||||
editorOverlays.value = overlays
|
||||
}
|
||||
|
||||
function onJobStarted(newJobId: string) {
|
||||
jobId.value = newJobId
|
||||
// Reset UI state
|
||||
@@ -113,7 +154,7 @@ function onJobStarted(newJobId: string) {
|
||||
source.disconnect()
|
||||
source.setUrl(`/api/detect/stream/${newJobId}`)
|
||||
source.connect()
|
||||
// Switch to normal layout (reset sets it to normal already)
|
||||
sseConnected.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -131,7 +172,7 @@ function onJobStarted(newJobId: string) {
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="jobId && (status === 'live' || status === 'processing')"
|
||||
v-if="sseConnected && (status === 'live' || status === 'processing')"
|
||||
class="header-btn stop-btn"
|
||||
title="Stop pipeline"
|
||||
@click="stopPipeline"
|
||||
@@ -198,11 +239,17 @@ function onJobStarted(newJobId: string) {
|
||||
<Panel :title="`Region Editor — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
|
||||
<div class="editor-placeholder">
|
||||
<div class="editor-frame">
|
||||
<FramePanel :source="source" :status="status" />
|
||||
<FramePanel :source="source" :status="status" :overlays="editorOverlays" />
|
||||
</div>
|
||||
<div class="editor-tools">
|
||||
<p>Stage: <strong>{{ pipeline.editorStage }}</strong></p>
|
||||
<p>Draw polygons to define regions</p>
|
||||
<StageConfigSliders
|
||||
v-if="pipeline.editorStage"
|
||||
:stage="pipeline.editorStage"
|
||||
:job-id="jobId"
|
||||
:frame-image="currentFrameImage"
|
||||
:frame-ref="currentFrameRef"
|
||||
@replay-result="onReplayResult"
|
||||
/>
|
||||
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,10 +280,24 @@ 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="`Blobs — ${pipeline.editorStage?.replace(/_/g, ' ')}`" :status="status">
|
||||
<div class="blob-viewer">
|
||||
<div class="blob-placeholder">
|
||||
Blob viewer: crops, preprocessed images, OCR results for {{ pipeline.editorStage }}
|
||||
<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>
|
||||
@@ -473,11 +534,6 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.blob-viewer {
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.blob-placeholder {
|
||||
padding: var(--space-4);
|
||||
color: var(--text-dim);
|
||||
@@ -485,6 +541,42 @@ header h1 { font-size: var(--font-size-lg); font-weight: 600; }
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.overlay-controls {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-toggle input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
}
|
||||
|
||||
.overlay-label {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.opacity-slider {
|
||||
width: 80px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.opacity-value {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
/* Source selector */
|
||||
.source-selector {
|
||||
display: flex;
|
||||
|
||||
422
ui/detection-app/src/components/StageConfigSliders.vue
Normal file
422
ui/detection-app/src/components/StageConfigSliders.vue
Normal file
@@ -0,0 +1,422 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
interface ConfigField {
|
||||
name: string
|
||||
type: string // "bool" | "int" | "float" | "str"
|
||||
default: unknown
|
||||
description: string
|
||||
min: number | null
|
||||
max: number | null
|
||||
options: string[] | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Stage name (e.g. "detect_edges") */
|
||||
stage: string
|
||||
/** Job ID for replay-stage calls (used as fallback) */
|
||||
jobId: string
|
||||
/** Currently displayed frame image (base64 JPEG) — sent directly to GPU for fast feedback */
|
||||
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 }>
|
||||
}]
|
||||
}>()
|
||||
|
||||
const fields = ref<ConfigField[]>([])
|
||||
const values = ref<Record<string, unknown>>({})
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const regionCount = ref<number | null>(null)
|
||||
const debugEnabled = ref(true)
|
||||
|
||||
// Fetch stage config fields from API
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/config/stages/${props.stage}`)
|
||||
if (!resp.ok) {
|
||||
error.value = `Failed to load config: ${resp.status}`
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
fields.value = data.config_fields ?? []
|
||||
|
||||
// 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}`
|
||||
}
|
||||
})
|
||||
|
||||
const numericFields = computed(() => fields.value.filter(f => f.type === 'int' || f.type === 'float'))
|
||||
const boolFields = computed(() => fields.value.filter(f => f.type === 'bool'))
|
||||
|
||||
function resetDefaults() {
|
||||
for (const f of fields.value) {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
}
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function onSliderChange() {
|
||||
// Debounce — wait 300ms after last change before calling replay
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => applyReplay(), 300)
|
||||
}
|
||||
|
||||
async function applyReplay() {
|
||||
loading.value = true
|
||||
error.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'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
await callReplayStage()
|
||||
}
|
||||
|
||||
async function callGpuDirect() {
|
||||
const body: Record<string, unknown> = {
|
||||
image: props.frameImage,
|
||||
}
|
||||
// Pass current slider values as edge detection params
|
||||
for (const f of fields.value) {
|
||||
if (f.name !== 'enabled') {
|
||||
body[f.name] = values.value[f.name]
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = debugEnabled.value
|
||||
? '/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),
|
||||
})
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
const overrideKey = stageToOverrideKey(props.stage)
|
||||
const configOverrides = Object.keys(overrides).length > 0
|
||||
? { [overrideKey]: overrides }
|
||||
: null
|
||||
|
||||
const body = {
|
||||
job_id: props.jobId,
|
||||
stage: props.stage,
|
||||
frame_refs: props.frameRef != null ? [props.frameRef] : null,
|
||||
config_overrides: configOverrides,
|
||||
debug: debugEnabled.value,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sliders-panel">
|
||||
<div class="sliders-header">
|
||||
<span class="sliders-title">{{ stage.replace(/_/g, ' ') }}</span>
|
||||
<button class="sliders-reset" @click="resetDefaults" title="Reset to defaults">Reset</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="sliders-error">{{ error }}</div>
|
||||
|
||||
<div class="sliders-list">
|
||||
<!-- Boolean fields -->
|
||||
<label v-for="f in boolFields" :key="f.name" class="slider-field bool-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="!!values[f.name]"
|
||||
@change="(e) => { values[f.name] = (e.target as HTMLInputElement).checked; onSliderChange() }"
|
||||
/>
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/_/g, ' ') }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Numeric fields (range sliders) -->
|
||||
<div v-for="f in numericFields" :key="f.name" class="slider-field">
|
||||
<div class="field-header">
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/^edge_/, '').replace(/_/g, ' ') }}</span>
|
||||
<span class="field-value">{{ values[f.name] }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:min="f.min ?? 0"
|
||||
:max="f.max ?? 500"
|
||||
:step="f.type === 'float' ? 0.01 : 1"
|
||||
:value="values[f.name] as number"
|
||||
@input="(e) => { values[f.name] = Number((e.target as HTMLInputElement).value); onSliderChange() }"
|
||||
/>
|
||||
<div class="field-range">
|
||||
<span>{{ f.min ?? 0 }}</span>
|
||||
<span>{{ f.max ?? 500 }}</span>
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="sliders-footer">
|
||||
<button class="apply-btn" :disabled="loading" @click="applyReplay">
|
||||
{{ loading ? 'Running...' : 'Apply' }}
|
||||
</button>
|
||||
<span v-if="regionCount != null" class="region-count">
|
||||
{{ regionCount }} regions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sliders-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.sliders-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: var(--space-1);
|
||||
border-bottom: var(--panel-border);
|
||||
}
|
||||
|
||||
.sliders-title {
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.sliders-reset {
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sliders-reset:hover { background: var(--surface-2); }
|
||||
|
||||
.sliders-error {
|
||||
color: var(--status-error);
|
||||
font-size: 10px;
|
||||
padding: 4px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sliders-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.slider-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bool-field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
color: var(--text-primary);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Range slider styling */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--surface-3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
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;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-1);
|
||||
border-top: var(--panel-border);
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
background: #00bcd4;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 4px 12px;
|
||||
color: #000;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.apply-btn:hover { opacity: 0.85; }
|
||||
.apply-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.region-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,12 +2,14 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import FrameRenderer from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
||||
import type { FrameBBox } from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
||||
import type { FrameBBox, FrameOverlay } from 'mpr-ui-framework/src/renderers/FrameRenderer.vue'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
|
||||
const props = defineProps<{
|
||||
source: DataSource
|
||||
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||
/** Debug overlay layers passed from parent (editor mode) */
|
||||
overlays?: FrameOverlay[]
|
||||
}>()
|
||||
|
||||
const imageSrc = ref('')
|
||||
@@ -19,6 +21,7 @@ const stageStatus = ref<Record<string, string>>({})
|
||||
const activeToggles = ref<Set<string>>(new Set())
|
||||
|
||||
const STAGE_TABS = [
|
||||
{ key: 'detect_edges', label: 'Edges', color: '#00bcd4' },
|
||||
{ key: 'detect_objects', label: 'YOLO', color: '#f5a623' },
|
||||
{ key: 'preprocess', label: 'Prep', color: '#e0e0e0' },
|
||||
{ key: 'run_ocr', label: 'OCR', color: '#ff8c42' },
|
||||
@@ -166,7 +169,7 @@ function hasData(key: string): boolean {
|
||||
</button>
|
||||
</div>
|
||||
<div class="frame-content">
|
||||
<FrameRenderer :image-src="imageSrc" :boxes="visibleBoxes" />
|
||||
<FrameRenderer :image-src="imageSrc" :boxes="visibleBoxes" :overlays="overlays" />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/**
|
||||
* Pipeline store — run state, transport controls, checkpoint status.
|
||||
*
|
||||
* Layout is driven by URL hash:
|
||||
* #/ → normal dashboard
|
||||
* #/editor/<stage> → bbox/region editor for that stage
|
||||
* #/config/<stage> → stage config editor
|
||||
* #/source → source selector
|
||||
*
|
||||
* State shape defined in types/store-state.ts.
|
||||
* This file is just the Pinia binding.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { NodeState } from '../types/store-state'
|
||||
import type { CheckpointInfo } from '../types/sse-contract'
|
||||
|
||||
@@ -21,15 +26,65 @@ export const usePipelineStore = defineStore('pipeline', () => {
|
||||
const checkpoints = ref<CheckpointInfo[]>([])
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Layout mode
|
||||
const layoutMode = ref<string>('normal') // normal | bbox_editor | stage_editor | source_selector
|
||||
const editorStage = ref<string | null>(null) // which stage's editor is open
|
||||
// Layout mode — synced with URL hash
|
||||
const layoutMode = ref<string>('normal')
|
||||
const editorStage = ref<string | null>(null)
|
||||
|
||||
const isRunning = computed(() => status.value === 'running')
|
||||
const isPaused = computed(() => status.value === 'paused')
|
||||
const canReplay = computed(() => checkpoints.value.length > 0)
|
||||
const isEditing = computed(() => layoutMode.value !== 'normal')
|
||||
|
||||
// --- Hash routing ---
|
||||
|
||||
function parseHash(hash: string) {
|
||||
const path = hash.replace(/^#\/?/, '')
|
||||
if (!path || path === 'dashboard') {
|
||||
return { mode: 'normal', stage: null }
|
||||
}
|
||||
if (path === 'source') {
|
||||
return { mode: 'source_selector', stage: null }
|
||||
}
|
||||
const editorMatch = path.match(/^editor\/(.+)$/)
|
||||
if (editorMatch) {
|
||||
return { mode: 'bbox_editor', stage: editorMatch[1] }
|
||||
}
|
||||
const configMatch = path.match(/^config\/(.+)$/)
|
||||
if (configMatch) {
|
||||
return { mode: 'stage_editor', stage: configMatch[1] }
|
||||
}
|
||||
return { mode: 'normal', stage: null }
|
||||
}
|
||||
|
||||
function applyHash() {
|
||||
const { mode, stage } = parseHash(window.location.hash)
|
||||
layoutMode.value = mode
|
||||
editorStage.value = stage
|
||||
}
|
||||
|
||||
function updateHash() {
|
||||
let hash = '#/'
|
||||
if (layoutMode.value === 'bbox_editor' && editorStage.value) {
|
||||
hash = `#/editor/${editorStage.value}`
|
||||
} else if (layoutMode.value === 'stage_editor' && editorStage.value) {
|
||||
hash = `#/config/${editorStage.value}`
|
||||
} else if (layoutMode.value === 'source_selector') {
|
||||
hash = '#/source'
|
||||
}
|
||||
if (window.location.hash !== hash) {
|
||||
window.history.pushState(null, '', hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync hash → state on load and popstate (back/forward)
|
||||
applyHash()
|
||||
window.addEventListener('popstate', applyHash)
|
||||
|
||||
// Sync state → hash when layout changes
|
||||
watch([layoutMode, editorStage], updateHash)
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
function setJob(id: string) {
|
||||
jobId.value = id
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface BoundingBoxEvent {
|
||||
label: string;
|
||||
resolved_brand: string | null;
|
||||
source: string | null;
|
||||
stage: string | null;
|
||||
}
|
||||
|
||||
export interface BrandSummary {
|
||||
@@ -47,6 +48,7 @@ export interface GraphUpdate {
|
||||
export interface StatsUpdate {
|
||||
frames_extracted: number;
|
||||
frames_after_scene_filter: number;
|
||||
cv_regions_detected: number;
|
||||
regions_detected: number;
|
||||
regions_resolved_by_ocr: number;
|
||||
regions_escalated_to_local_vlm: number;
|
||||
@@ -104,6 +106,8 @@ export interface RunContext {
|
||||
|
||||
export interface CheckpointInfo {
|
||||
stage: string;
|
||||
is_scenario: boolean;
|
||||
scenario_label: string;
|
||||
}
|
||||
|
||||
export interface ReplayRequest {
|
||||
|
||||
@@ -53,7 +53,19 @@ export interface PreprocessingConfigOverrides {
|
||||
contrast: boolean | null;
|
||||
}
|
||||
|
||||
export interface RegionAnalysisConfigOverrides {
|
||||
enabled: boolean | null;
|
||||
edge_canny_low: number | null;
|
||||
edge_canny_high: number | null;
|
||||
edge_hough_threshold: number | null;
|
||||
edge_hough_min_length: number | null;
|
||||
edge_hough_max_gap: number | null;
|
||||
edge_pair_max_distance: number | null;
|
||||
edge_pair_min_distance: number | null;
|
||||
}
|
||||
|
||||
export interface ConfigOverrides {
|
||||
region_analysis: RegionAnalysisConfigOverrides | null;
|
||||
detection: DetectionConfigOverrides | null;
|
||||
ocr: OCRConfigOverrides | null;
|
||||
resolver: ResolverConfigOverrides | null;
|
||||
|
||||
@@ -14,11 +14,22 @@ export interface FrameBBox {
|
||||
ocr_text?: string | null
|
||||
}
|
||||
|
||||
export interface FrameOverlay {
|
||||
/** Base64 JPEG image (same dimensions as main image) */
|
||||
src: string
|
||||
label: string
|
||||
visible: boolean
|
||||
/** Opacity 0-1, default 0.5 */
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Base64 JPEG image */
|
||||
imageSrc: string
|
||||
/** Bounding boxes to overlay */
|
||||
boxes: FrameBBox[]
|
||||
/** Debug overlay layers (edge images, line visualizations, etc.) */
|
||||
overlays?: FrameOverlay[]
|
||||
}>()
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
@@ -44,6 +55,10 @@ function draw() {
|
||||
ctx.clearRect(0, 0, cvs.width, cvs.height)
|
||||
ctx.drawImage(img, dx, dy, img.width * scale, img.height * scale)
|
||||
|
||||
// Draw debug overlays (edge images, line visualizations)
|
||||
drawOverlays(ctx, dx, dy, img.width * scale, img.height * scale)
|
||||
|
||||
// Draw bounding boxes on top
|
||||
for (const box of props.boxes) {
|
||||
const bx = dx + box.x * scale
|
||||
const by = dy + box.y * scale
|
||||
@@ -53,7 +68,6 @@ function draw() {
|
||||
const color = sourceColor(box)
|
||||
const resolved = box.resolved_brand || box.ocr_text
|
||||
|
||||
// Box outline only — no labels, no percentages
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 2
|
||||
if (!resolved) {
|
||||
@@ -66,6 +80,29 @@ function draw() {
|
||||
img.src = `data:image/jpeg;base64,${props.imageSrc}`
|
||||
}
|
||||
|
||||
/** Pending overlay images that need async loading */
|
||||
const overlayCache = new Map<string, HTMLImageElement>()
|
||||
|
||||
function drawOverlays(ctx: CanvasRenderingContext2D, dx: number, dy: number, dw: number, dh: number) {
|
||||
const layers = props.overlays ?? []
|
||||
for (const layer of layers) {
|
||||
if (!layer.visible || !layer.src) continue
|
||||
|
||||
const cached = overlayCache.get(layer.src)
|
||||
if (cached && cached.complete) {
|
||||
ctx.globalAlpha = layer.opacity ?? 0.5
|
||||
ctx.drawImage(cached, dx, dy, dw, dh)
|
||||
ctx.globalAlpha = 1.0
|
||||
} else if (!cached) {
|
||||
// Load async, redraw when ready
|
||||
const overlay = new window.Image()
|
||||
overlay.onload = () => draw()
|
||||
overlay.src = `data:image/jpeg;base64,${layer.src}`
|
||||
overlayCache.set(layer.src, overlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
yolo: '#f5a623', // yellow — raw detection
|
||||
ocr: '#ff8c42', // orange — text extracted
|
||||
@@ -75,9 +112,19 @@ const SOURCE_COLORS: Record<string, string> = {
|
||||
unresolved: '#e05252', // red — nothing matched
|
||||
}
|
||||
|
||||
// CV region labels — distinct from source-based colors
|
||||
const REGION_COLORS: Record<string, string> = {
|
||||
edge_region: '#00bcd4', // cyan
|
||||
contour_region: '#ffd54f', // yellow
|
||||
color_region: '#e040fb', // magenta
|
||||
candidate: '#4caf50', // green — passed readability
|
||||
rejected: '#e05252', // red — failed readability
|
||||
}
|
||||
|
||||
function sourceColor(box: FrameBBox): string {
|
||||
if (REGION_COLORS[box.label]) return REGION_COLORS[box.label]
|
||||
if (box.resolved_brand) return SOURCE_COLORS.ocr_matched
|
||||
if (box.source && box.source in SOURCE_COLORS) return SOURCE_COLORS[box.source]
|
||||
if (box.source && SOURCE_COLORS[box.source]) return SOURCE_COLORS[box.source]
|
||||
return confidenceColor(box.confidence)
|
||||
}
|
||||
|
||||
@@ -87,7 +134,7 @@ function confidenceColor(conf: number): string {
|
||||
return 'var(--conf-low)'
|
||||
}
|
||||
|
||||
watch(() => [props.imageSrc, props.boxes], () => nextTick(draw), { deep: true })
|
||||
watch(() => [props.imageSrc, props.boxes, props.overlays], () => nextTick(draw), { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(draw)
|
||||
|
||||
@@ -21,7 +21,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const regionStageSet = computed(() => new Set(props.regionStages ?? [
|
||||
'detect_objects', 'run_ocr', 'match_brands', 'escalate_vlm', 'escalate_cloud',
|
||||
'detect_edges', 'detect_objects', 'run_ocr', 'match_brands', 'escalate_vlm', 'escalate_cloud',
|
||||
]))
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
|
||||
Reference in New Issue
Block a user