phase cv 0

This commit is contained in:
2026-03-26 22:22:35 -03:00
parent beb0416280
commit 65814b5b9e
46 changed files with 2962 additions and 268 deletions

View File

@@ -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;

View 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>

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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)

View File

@@ -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> = {