This commit is contained in:
2026-03-28 00:59:59 -03:00
parent f6ef95ebea
commit 8a90436f33
7 changed files with 260 additions and 195 deletions

View File

@@ -138,7 +138,7 @@ function onJobStarted(newJobId: string) {
<!-- === BBOX EDITOR MODE === --> <!-- === BBOX EDITOR MODE === -->
<div v-else-if="pipeline.layoutMode === 'bbox_editor'" class="editor-layout"> <div v-else-if="pipeline.layoutMode === 'bbox_editor'" class="editor-layout">
<div class="editor-top"> <div class="editor-top">
<SplitPane direction="horizontal" :initial-size="210" size-mode="px" :min="210" :max="350" anchor="second"> <SplitPane direction="horizontal" :initial-size="245" size-mode="px" :min="245" :max="350" anchor="second">
<template #first> <template #first>
<FramePanel :source="source" :status="status" :overlays="editorOverlays" :frame-image="currentFrameImage" :editor-boxes="editorBoxes" /> <FramePanel :source="source" :status="status" :overlays="editorOverlays" :frame-image="currentFrameImage" :editor-boxes="editorBoxes" />
</template> </template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue' import { ref, onMounted, watch } from 'vue'
import { ParameterEditor, useEditorExecution } from 'mpr-ui-framework'
import type { ConfigField } from 'mpr-ui-framework'
import { import {
runEdgeDetection, runEdgeDetection,
runEdgeDetectionDebug, runEdgeDetectionDebug,
@@ -8,30 +10,13 @@ import {
} from '@/cv' } from '@/cv'
import type { EdgeDetectionParams } from '@/cv' import type { EdgeDetectionParams } from '@/cv'
interface ConfigField {
name: string
type: string
default: unknown
description: string
min: number | null
max: number | null
options: string[] | null
}
const props = defineProps<{ const props = defineProps<{
/** Stage name (e.g. "detect_edges") */
stage: string stage: string
/** Job ID (used for server mode fallback) */
jobId: string jobId: string
/** Currently displayed frame image (base64 JPEG) */
frameImage?: string | null frameImage?: string | null
/** Currently displayed frame sequence number */
frameRef?: number | null frameRef?: number | null
/** All checkpoint frames — when provided, Apply runs on the selection range */
frames?: Array<{ seq: number; jpeg_b64: string }> frames?: Array<{ seq: number; jpeg_b64: string }>
/** Index into frames[] for selection start (default 0) */
selectionStart?: number selectionStart?: number
/** Index into frames[] for selection end (default frames.length - 1) */
selectionEnd?: number selectionEnd?: number
}>() }>()
@@ -46,14 +31,10 @@ const emit = defineEmits<{
const fields = ref<ConfigField[]>([]) const fields = ref<ConfigField[]>([])
const values = ref<Record<string, unknown>>({}) const values = ref<Record<string, unknown>>({})
const loading = ref(false)
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 execMode = ref<'local' | 'server'>('local')
const execTimeMs = ref<number | null>(null) const processingIndex = ref<number | null>(null)
const processingIndex = ref<number | null>(null) // current frame index during multi-frame run
// Config field defaults for detect_edges (used when API is unavailable) // Config field defaults for detect_edges (used when API is unavailable)
const EDGE_DEFAULTS: ConfigField[] = [ const EDGE_DEFAULTS: ConfigField[] = [
@@ -67,8 +48,27 @@ const EDGE_DEFAULTS: ConfigField[] = [
{ name: 'edge_pair_min_distance', type: 'int', default: 15, description: 'Min pair distance (px)', min: 5, max: 200, options: null }, { name: 'edge_pair_min_distance', type: 'int', default: 15, description: 'Min pair distance (px)', min: 5, max: 200, options: null },
] ]
// --- Execution logic (detection-specific) ---
async function executeDetection() {
const hasFrames = props.frames && props.frames.length > 0
if (!props.frameImage && !hasFrames) return
if (execMode.value === 'local') {
await runLocal()
} else {
await runServer()
}
}
const {
loading, error, autoApply, execTimeMs,
apply: applyDetection, onParameterChange,
} = useEditorExecution(executeDetection)
// --- Init ---
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) {
@@ -85,26 +85,25 @@ onMounted(async () => {
values.value[f.name] = f.default values.value[f.name] = f.default
} }
// Auto-run on first frame if already available
if (props.frameImage) { if (props.frameImage) {
applyDetection() applyDetection()
} }
}) })
// Auto-run when frame arrives after mount (checkpoint load is async)
watch(() => props.frameImage, (newVal, oldVal) => { watch(() => props.frameImage, (newVal, oldVal) => {
if (newVal && !oldVal && fields.value.length > 0) { if (newVal && !oldVal && fields.value.length > 0) {
applyDetection() applyDetection()
} }
}) })
// Auto-run when selection range changes (strip handle drag)
watch([() => props.selectionStart, () => props.selectionEnd], () => { watch([() => props.selectionStart, () => props.selectionEnd], () => {
if (autoApply.value) onSliderChange() if (autoApply.value) onParameterChange()
}) })
const numericFields = computed(() => fields.value.filter(f => f.type === 'int' || f.type === 'float')) function onFieldUpdate(name: string, value: unknown) {
const boolFields = computed(() => fields.value.filter(f => f.type === 'bool')) values.value[name] = value
onParameterChange()
}
function resetDefaults() { function resetDefaults() {
for (const f of fields.value) { for (const f of fields.value) {
@@ -113,42 +112,9 @@ function resetDefaults() {
applyDetection() applyDetection()
} }
let debounceTimer: ReturnType<typeof setTimeout> | null = null // --- Local CV execution ---
function onSliderChange() {
if (!autoApply.value) return
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => applyDetection(), 150)
}
async function applyDetection() {
const hasFrames = props.frames && props.frames.length > 0
if (!props.frameImage && !hasFrames) {
error.value = 'No frame available'
return
}
loading.value = true
error.value = null
execTimeMs.value = null
try {
if (execMode.value === 'local') {
await runLocal()
} else {
await runServer()
}
} catch (e) {
error.value = `${execMode.value} failed: ${e}`
} finally {
loading.value = false
}
}
/** Browser-side CV — runs on current frame or selection range */
async function runLocal() { async function runLocal() {
const t0 = performance.now()
const params: Partial<EdgeDetectionParams> = { const params: Partial<EdgeDetectionParams> = {
cannyLow: values.value['edge_canny_low'] as number, cannyLow: values.value['edge_canny_low'] as number,
cannyHigh: values.value['edge_canny_high'] as number, cannyHigh: values.value['edge_canny_high'] as number,
@@ -159,7 +125,6 @@ async function runLocal() {
pairMinDistance: values.value['edge_pair_min_distance'] as number, pairMinDistance: values.value['edge_pair_min_distance'] as number,
} }
// Determine which frames to process
const targetFrames = props.frames && props.frames.length > 0 const targetFrames = props.frames && props.frames.length > 0
? props.frames.slice(props.selectionStart ?? 0, (props.selectionEnd ?? props.frames.length - 1) + 1) ? props.frames.slice(props.selectionStart ?? 0, (props.selectionEnd ?? props.frames.length - 1) + 1)
: [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage! }] : [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage! }]
@@ -195,19 +160,18 @@ async function runLocal() {
totalRegions += result.regions.length totalRegions += result.regions.length
regions_by_frame[frameKey] = result.regions regions_by_frame[frameKey] = result.regions
} }
// Emit incrementally so overlays appear per frame, not all at once
emit('replay-result', { regions_by_frame: { ...regions_by_frame }, debug: { ...debug }, frameWidth, frameHeight })
} }
processingIndex.value = null processingIndex.value = null
execTimeMs.value = Math.round(performance.now() - t0)
regionCount.value = totalRegions regionCount.value = totalRegions
emit('replay-result', { regions_by_frame, debug, frameWidth, frameHeight })
} }
/** Server-side CV — calls GPU box via proxy */ // --- Server CV execution ---
async function runServer() {
const t0 = performance.now()
async function runServer() {
const body: Record<string, unknown> = { image: props.frameImage } 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') {
@@ -231,7 +195,6 @@ async function runServer() {
} }
const data = await resp.json() const data = await resp.json()
execTimeMs.value = Math.round(performance.now() - t0)
regionCount.value = data.regions?.length ?? 0 regionCount.value = data.regions?.length ?? 0
const frameKey = String(props.frameRef ?? 0) const frameKey = String(props.frameRef ?? 0)
@@ -260,59 +223,24 @@ async function runServer() {
<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"> <div class="mode-toggle">
<button <button :class="['mode-btn', { active: execMode === 'local' }]" @click="execMode = 'local'">Local</button>
:class="['mode-btn', { active: execMode === 'local' }]" <button :class="['mode-btn', { active: execMode === 'server' }]" @click="execMode = 'server'">Server</button>
@click="execMode = 'local'"
>Local</button>
<button
:class="['mode-btn', { active: execMode === 'server' }]"
@click="execMode = 'server'"
>Server</button>
</div> </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">
<!-- Boolean fields --> <ParameterEditor
<label v-for="f in boolFields" :key="f.name" class="slider-field bool-field"> :fields="fields"
<input :values="values"
type="checkbox" @update="onFieldUpdate"
:checked="!!values[f.name]" @reset="resetDefaults"
@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> </div>
<!-- Footer -->
<div class="sliders-footer"> <div class="sliders-footer">
<button <button class="apply-btn" :disabled="loading || autoApply" @click="applyDetection">
class="apply-btn"
:disabled="loading || autoApply"
@click="applyDetection"
>
{{ loading ? 'Running...' : 'Apply' }} {{ loading ? 'Running...' : 'Apply' }}
</button> </button>
<label class="auto-apply-toggle"> <label class="auto-apply-toggle">
@@ -411,79 +339,6 @@ async function runServer() {
overflow-y: auto; 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);
}
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;
}
.sliders-footer { .sliders-footer {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -66,11 +66,13 @@ export function useCheckpointLoader(
currentFrameRef.value = frame.seq currentFrameRef.value = frame.seq
} }
const { checkpointStageFor } = useStageRegistry() const { stages, checkpointStageFor } = useStageRegistry()
// Auto-load checkpoint when entering editor mode // Auto-load checkpoint when entering editor mode.
// Also watches stages — the registry fetch is async, so on first load
// stages may be empty. When they arrive, re-evaluate.
watch( watch(
() => [pipeline.layoutMode, pipeline.editorStage, jobId.value] as const, () => [pipeline.layoutMode, pipeline.editorStage, jobId.value, stages.value.length] as const,
([mode, stage, job]) => { ([mode, stage, job]) => {
if (mode === 'bbox_editor' && stage && job) { if (mode === 'bbox_editor' && stage && job) {
const cpStage = checkpointStageFor(stage) const cpStage = checkpointStageFor(stage)

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed } from 'vue'
export interface ConfigField {
name: string
type: string
default: unknown
description: string
min: number | null
max: number | null
options: string[] | null
}
const props = defineProps<{
fields: ConfigField[]
values: Record<string, unknown>
}>()
const emit = defineEmits<{
'update': [name: string, value: unknown]
'reset': []
}>()
const numericFields = computed(() => props.fields.filter(f => f.type === 'int' || f.type === 'float'))
const boolFields = computed(() => props.fields.filter(f => f.type === 'bool'))
function onInput(name: string, value: unknown) {
emit('update', name, value)
}
</script>
<template>
<div class="param-editor">
<!-- Boolean fields -->
<label v-for="f in boolFields" :key="f.name" class="param-field bool-field">
<input
type="checkbox"
:checked="!!values[f.name]"
@change="(e) => onInput(f.name, (e.target as HTMLInputElement).checked)"
/>
<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="param-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) => onInput(f.name, Number((e.target as HTMLInputElement).value))"
/>
<div class="field-range">
<span>{{ f.min ?? 0 }}</span>
<span>{{ f.max ?? 500 }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.param-editor {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.param-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);
}
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;
}
</style>

View File

@@ -64,9 +64,11 @@ const isHorizontal = computed(() => props.direction === 'horizontal')
const sizedStyle = computed(() => { const sizedStyle = computed(() => {
if (props.sizeMode === 'px') { if (props.sizeMode === 'px') {
const sizeStr = size.value + 'px'
const minStr = props.min + 'px'
return isHorizontal.value return isHorizontal.value
? { width: size.value + 'px', flexShrink: '0' } ? { width: sizeStr, minWidth: minStr, flexShrink: '0' }
: { height: size.value + 'px', flexShrink: '0' } : { height: sizeStr, minHeight: minStr, flexShrink: '0' }
} }
return { flex: String(size.value) } return { flex: String(size.value) }
}) })

View File

@@ -0,0 +1,57 @@
import { ref } from 'vue'
export interface EditorExecutionOptions {
/** Debounce delay in ms for auto-apply. Default: 150 */
debounceMs?: number
}
/**
* Generic editor execution pattern — debounced apply with auto-apply toggle,
* loading/error/timing state tracking.
*
* The caller provides the actual execution function. This composable handles
* the orchestration: debounce, auto-apply, loading state, timing.
*/
export function useEditorExecution(
executeFn: () => Promise<void>,
options: EditorExecutionOptions = {},
) {
const debounceMs = options.debounceMs ?? 150
const loading = ref(false)
const error = ref<string | null>(null)
const autoApply = ref(true)
const execTimeMs = ref<number | null>(null)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function apply() {
loading.value = true
error.value = null
execTimeMs.value = null
const t0 = performance.now()
try {
await executeFn()
execTimeMs.value = Math.round(performance.now() - t0)
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
function onParameterChange() {
if (!autoApply.value) return
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => apply(), debounceMs)
}
return {
loading,
error,
autoApply,
execTimeMs,
apply,
onParameterChange,
}
}

View File

@@ -4,12 +4,16 @@ export { SSEDataSource } from './datasources/SSEDataSource'
export { StaticDataSource } from './datasources/StaticDataSource' export { StaticDataSource } from './datasources/StaticDataSource'
export { useDataSource } from './composables/useDataSource' export { useDataSource } from './composables/useDataSource'
export { useRegistry } from './composables/useRegistry' export { useRegistry } from './composables/useRegistry'
export { useEditorExecution } from './composables/useEditorExecution'
export type { EditorExecutionOptions } from './composables/useEditorExecution'
// Components // Components
export { default as Panel } from './components/Panel.vue' export { default as Panel } from './components/Panel.vue'
export { default as LayoutGrid } from './components/LayoutGrid.vue' export { default as LayoutGrid } from './components/LayoutGrid.vue'
export { default as ResizeHandle } from './components/ResizeHandle.vue' export { default as ResizeHandle } from './components/ResizeHandle.vue'
export { default as SplitPane } from './components/SplitPane.vue' export { default as SplitPane } from './components/SplitPane.vue'
export { default as ParameterEditor } from './components/ParameterEditor.vue'
export type { ConfigField } from './components/ParameterEditor.vue'
// Renderers // Renderers
export { default as LogRenderer } from './renderers/LogRenderer.vue' export { default as LogRenderer } from './renderers/LogRenderer.vue'