phase 4
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { SSEDataSource, Panel, ResizeHandle } from 'mpr-ui-framework'
|
import { Panel, ResizeHandle } from 'mpr-ui-framework'
|
||||||
import type { FrameOverlay, FrameBBox } from 'mpr-ui-framework'
|
|
||||||
import { matchTracks, renderTracksToImageData, imageDataToPngB64 } from '@/cv'
|
|
||||||
import 'mpr-ui-framework/src/tokens.css'
|
import 'mpr-ui-framework/src/tokens.css'
|
||||||
import LogPanel from './panels/LogPanel.vue'
|
import LogPanel from './panels/LogPanel.vue'
|
||||||
import FunnelPanel from './panels/FunnelPanel.vue'
|
import FunnelPanel from './panels/FunnelPanel.vue'
|
||||||
@@ -14,46 +12,46 @@ import CostStatsPanel from './panels/CostStatsPanel.vue'
|
|||||||
import SourceSelector from './panels/SourceSelector.vue'
|
import SourceSelector from './panels/SourceSelector.vue'
|
||||||
import StageConfigSliders from './components/StageConfigSliders.vue'
|
import StageConfigSliders from './components/StageConfigSliders.vue'
|
||||||
import FrameStrip from './components/FrameStrip.vue'
|
import FrameStrip from './components/FrameStrip.vue'
|
||||||
import type { StatsUpdate, RunContext } from './types/sse-contract'
|
|
||||||
import { usePipelineStore } from './stores/pipeline'
|
import { usePipelineStore } from './stores/pipeline'
|
||||||
|
import { useSSEConnection } from './composables/useSSEConnection'
|
||||||
|
import { useCheckpointLoader } from './composables/useCheckpointLoader'
|
||||||
|
import { useEditorState } from './composables/useEditorState'
|
||||||
|
|
||||||
const pipeline = usePipelineStore()
|
const pipeline = usePipelineStore()
|
||||||
|
|
||||||
const jobParam = new URLSearchParams(window.location.search).get('job')
|
|
||||||
const jobId = ref(jobParam || '')
|
|
||||||
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 logPanel = ref<{ clear: () => void } | null>(null)
|
||||||
const sseConnected = ref(false)
|
|
||||||
|
|
||||||
// No job selected and no hash route → open source selector
|
// SSE connection + pipeline status
|
||||||
if (!jobParam && !window.location.hash.replace(/^#\/?/, '')) {
|
const {
|
||||||
pipeline.openSourceSelector()
|
jobId, stats, runContext, status, sseConnected, source,
|
||||||
|
stopPipeline, onJobStarted: sseJobStarted,
|
||||||
|
} = useSSEConnection()
|
||||||
|
|
||||||
|
// Checkpoint frames + navigation
|
||||||
|
const {
|
||||||
|
currentFrameImage, currentFrameRef,
|
||||||
|
checkpointFrames, checkpointFrameIndex,
|
||||||
|
stripSelStart, stripSelEnd, stripSelEndOverride,
|
||||||
|
setCheckpointFrame: cpSetFrame,
|
||||||
|
} = useCheckpointLoader(jobId, source)
|
||||||
|
|
||||||
|
// Editor overlays + CV result accumulation
|
||||||
|
const {
|
||||||
|
editorOverlays, editorBoxes,
|
||||||
|
updateDisplayForFrame, onReplayResult,
|
||||||
|
} = useEditorState(currentFrameRef)
|
||||||
|
|
||||||
|
// Wire checkpoint frame change to editor display update
|
||||||
|
function setCheckpointFrame(index: number) {
|
||||||
|
cpSetFrame(index)
|
||||||
|
const frame = checkpointFrames.value[index]
|
||||||
|
if (frame) updateDisplayForFrame(frame.seq)
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = new SSEDataSource({
|
// Wire job start to clear log panel
|
||||||
id: 'detect-stream',
|
function onJobStarted(newJobId: string) {
|
||||||
url: jobId.value ? `/api/detect/stream/${jobId.value}` : '',
|
logPanel.value?.clear()
|
||||||
eventTypes: ['graph_update', 'stats_update', 'frame_update', 'detection', 'log', 'job_complete', 'waiting'],
|
sseJobStarted(newJobId)
|
||||||
})
|
|
||||||
|
|
||||||
source.on<StatsUpdate>('stats_update', (e) => {
|
|
||||||
stats.value = e
|
|
||||||
if (!runContext.value && e.run_id) {
|
|
||||||
runContext.value = {
|
|
||||||
run_id: e.run_id!,
|
|
||||||
parent_job_id: e.parent_job_id!,
|
|
||||||
run_type: e.run_type ?? 'initial',
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
source.on<{ report?: { status?: string, error?: string } }>('job_complete', (e) => {
|
|
||||||
if (e.report?.status === 'failed') {
|
|
||||||
status.value = 'error'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Resizable splits
|
// Resizable splits
|
||||||
const pipelineWidth = ref(320)
|
const pipelineWidth = ref(320)
|
||||||
@@ -61,6 +59,7 @@ const detectionsFlex = ref(3)
|
|||||||
const viewerHeight = ref(240)
|
const viewerHeight = ref(240)
|
||||||
const timelineFlex = ref(1)
|
const timelineFlex = ref(1)
|
||||||
const tableFlex = ref(1)
|
const tableFlex = ref(1)
|
||||||
|
const slidersWidth = ref(210)
|
||||||
|
|
||||||
function onPipelineResize(delta: number) {
|
function onPipelineResize(delta: number) {
|
||||||
pipelineWidth.value = Math.max(200, Math.min(500, pipelineWidth.value + delta))
|
pipelineWidth.value = Math.max(200, Math.min(500, pipelineWidth.value + delta))
|
||||||
@@ -80,247 +79,9 @@ function onTimelineResize(delta: number) {
|
|||||||
tableFlex.value = Math.max(0.3, Math.min(3, tableFlex.value - shift))
|
tableFlex.value = Math.max(0.3, Math.min(3, tableFlex.value - shift))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editor sliders sidebar width — drag right = shrink sliders (grow frame)
|
|
||||||
const slidersWidth = ref(210)
|
|
||||||
function onSlidersResize(delta: number) {
|
function onSlidersResize(delta: number) {
|
||||||
slidersWidth.value = Math.max(210, Math.min(350, slidersWidth.value - delta))
|
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',
|
|
||||||
live: 'live',
|
|
||||||
error: 'error',
|
|
||||||
}
|
|
||||||
const checkStatus = () => {
|
|
||||||
if (sseConnected.value) {
|
|
||||||
status.value = statusMap[source.status.value] ?? 'idle'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setInterval(checkStatus, 500)
|
|
||||||
|
|
||||||
// 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() {
|
|
||||||
if (!jobId.value) return
|
|
||||||
try {
|
|
||||||
await fetch(`/api/detect/stop/${jobId.value}`, { method: 'POST' })
|
|
||||||
} 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)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// Frame strip selection (indices into checkpointFrames)
|
|
||||||
const stripSelStart = ref(0)
|
|
||||||
const stripSelEnd = computed(() =>
|
|
||||||
stripSelEndOverride.value ?? Math.max(0, checkpointFrames.value.length - 1),
|
|
||||||
)
|
|
||||||
const stripSelEndOverride = ref<number | null>(null)
|
|
||||||
|
|
||||||
// Per-frame CV results — accumulated across all processed frames
|
|
||||||
const allFrameRegions = ref<Record<number, Array<{ x: number; y: number; w: number; h: number; confidence: number; label: string }>>>({})
|
|
||||||
const allFrameDebug = ref<Record<number, { edge_overlay_b64: string; lines_overlay_b64: string }>>({})
|
|
||||||
const frameDimensions = ref<{ w: number; h: number } | null>(null)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset accumulated CV results and strip selection for this checkpoint
|
|
||||||
allFrameRegions.value = {}
|
|
||||||
allFrameDebug.value = {}
|
|
||||||
frameDimensions.value = null
|
|
||||||
stripSelStart.value = 0
|
|
||||||
stripSelEndOverride.value = null
|
|
||||||
|
|
||||||
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
|
|
||||||
updateDisplayForFrame(frame.seq)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDisplayForFrame(seq: number) {
|
|
||||||
// Update boxes for this frame from accumulated results
|
|
||||||
const regions = allFrameRegions.value[seq] ?? []
|
|
||||||
editorBoxes.value = regions.map(r => ({
|
|
||||||
x: r.x, y: r.y, w: r.w, h: r.h,
|
|
||||||
confidence: r.confidence,
|
|
||||||
label: r.label ?? 'edge_region',
|
|
||||||
stage: 'detect_edges',
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Update Canny/Hough overlays for this frame (preserving visibility/opacity)
|
|
||||||
const debug = allFrameDebug.value[seq]
|
|
||||||
if (debug) {
|
|
||||||
const overlays: FrameOverlay[] = []
|
|
||||||
if (debug.edge_overlay_b64) {
|
|
||||||
const existing = editorOverlays.value.find(o => o.label === 'Canny edges')
|
|
||||||
overlays.push({ src: debug.edge_overlay_b64, label: 'Canny edges', visible: existing?.visible ?? true, opacity: existing?.opacity ?? 0.25 })
|
|
||||||
}
|
|
||||||
if (debug.lines_overlay_b64) {
|
|
||||||
const existing = editorOverlays.value.find(o => o.label === 'Hough lines')
|
|
||||||
overlays.push({ src: debug.lines_overlay_b64, label: 'Hough lines', visible: existing?.visible ?? true, opacity: existing?.opacity ?? 0.25 })
|
|
||||||
}
|
|
||||||
// Re-append track overlay if it exists
|
|
||||||
const trackOverlay = editorOverlays.value.find(o => o.label === 'Motion tracks')
|
|
||||||
if (trackOverlay) overlays.push(trackOverlay)
|
|
||||||
editorOverlays.value = overlays
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-render track overlay with updated currentSeq
|
|
||||||
if (Object.keys(allFrameRegions.value).length >= 2 && frameDimensions.value) {
|
|
||||||
updateTrackOverlay(seq)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateTrackOverlay(currentSeq: number) {
|
|
||||||
const dims = frameDimensions.value
|
|
||||||
if (!dims || Object.keys(allFrameRegions.value).length < 2) return
|
|
||||||
const tracks = matchTracks(allFrameRegions.value)
|
|
||||||
const imageData = renderTracksToImageData(tracks, dims.w, dims.h, currentSeq)
|
|
||||||
const b64 = await imageDataToPngB64(imageData)
|
|
||||||
const existing = editorOverlays.value.find(o => o.label === 'Motion tracks')
|
|
||||||
const trackOverlay: FrameOverlay = {
|
|
||||||
src: b64,
|
|
||||||
label: 'Motion tracks',
|
|
||||||
visible: existing?.visible ?? true,
|
|
||||||
opacity: existing?.opacity ?? 0.9,
|
|
||||||
srcFormat: 'png',
|
|
||||||
}
|
|
||||||
editorOverlays.value = [
|
|
||||||
...editorOverlays.value.filter(o => o.label !== 'Motion tracks'),
|
|
||||||
trackOverlay,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<FrameBBox[]>([])
|
|
||||||
|
|
||||||
type RegionBox = { x: number; y: number; w: number; h: number; confidence: number; label: string }
|
|
||||||
|
|
||||||
function onReplayResult(result: {
|
|
||||||
regions_by_frame?: Record<string, RegionBox[]>
|
|
||||||
debug?: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
|
|
||||||
frameWidth?: number
|
|
||||||
frameHeight?: number
|
|
||||||
}) {
|
|
||||||
// Store frame dimensions for track overlay rendering
|
|
||||||
if (result.frameWidth && result.frameHeight) {
|
|
||||||
frameDimensions.value = { w: result.frameWidth, h: result.frameHeight }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge incoming per-frame regions into accumulated store
|
|
||||||
if (result.regions_by_frame) {
|
|
||||||
for (const [seqStr, regions] of Object.entries(result.regions_by_frame)) {
|
|
||||||
allFrameRegions.value[Number(seqStr)] = regions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge incoming per-frame debug overlays into accumulated store
|
|
||||||
if (result.debug) {
|
|
||||||
for (const [seqStr, dbg] of Object.entries(result.debug)) {
|
|
||||||
allFrameDebug.value[Number(seqStr)] = {
|
|
||||||
edge_overlay_b64: dbg.edge_overlay_b64,
|
|
||||||
lines_overlay_b64: dbg.lines_overlay_b64,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update display for the currently shown frame
|
|
||||||
const currentSeq = currentFrameRef.value ?? 0
|
|
||||||
updateDisplayForFrame(currentSeq)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onJobStarted(newJobId: string) {
|
|
||||||
jobId.value = newJobId
|
|
||||||
// Reset UI state
|
|
||||||
stats.value = null
|
|
||||||
runContext.value = null
|
|
||||||
status.value = 'processing'
|
|
||||||
logPanel.value?.clear()
|
|
||||||
pipeline.reset()
|
|
||||||
pipeline.setStatus('running')
|
|
||||||
// Update URL without reload
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.searchParams.set('job', newJobId)
|
|
||||||
window.history.pushState({}, '', url.toString())
|
|
||||||
// Connect SSE to new job
|
|
||||||
source.disconnect()
|
|
||||||
source.setUrl(`/api/detect/stream/${newJobId}`)
|
|
||||||
source.connect()
|
|
||||||
sseConnected.value = true
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
98
ui/detection-app/src/composables/useCheckpointLoader.ts
Normal file
98
ui/detection-app/src/composables/useCheckpointLoader.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { DataSource } from 'mpr-ui-framework'
|
||||||
|
import { usePipelineStore } from '../stores/pipeline'
|
||||||
|
|
||||||
|
interface CheckpointFrame {
|
||||||
|
seq: number
|
||||||
|
timestamp: number
|
||||||
|
jpeg_b64: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCheckpointLoader(
|
||||||
|
jobId: Ref<string>,
|
||||||
|
source: DataSource,
|
||||||
|
) {
|
||||||
|
const pipeline = usePipelineStore()
|
||||||
|
|
||||||
|
const currentFrameImage = ref<string | null>(null)
|
||||||
|
const currentFrameRef = ref<number | null>(null)
|
||||||
|
|
||||||
|
const checkpointFrames = ref<CheckpointFrame[]>([])
|
||||||
|
const checkpointFrameIndex = ref(0)
|
||||||
|
const checkpointStage = ref<string | null>(null)
|
||||||
|
|
||||||
|
const stripSelStart = ref(0)
|
||||||
|
const stripSelEndOverride = ref<number | null>(null)
|
||||||
|
const stripSelEnd = computed(() =>
|
||||||
|
stripSelEndOverride.value ?? Math.max(0, checkpointFrames.value.length - 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track current frame from SSE
|
||||||
|
source.on<{ frame_ref: number; jpeg_b64: string }>('frame_update', (e) => {
|
||||||
|
currentFrameImage.value = e.jpeg_b64
|
||||||
|
currentFrameRef.value = e.frame_ref
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if (checkpointFrames.value.length > 0) {
|
||||||
|
checkpointFrameIndex.value = 0
|
||||||
|
const first = checkpointFrames.value[0]
|
||||||
|
currentFrameImage.value = first.jpeg_b64
|
||||||
|
currentFrameRef.value = first.seq
|
||||||
|
}
|
||||||
|
|
||||||
|
stripSelStart.value = 0
|
||||||
|
stripSelEndOverride.value = null
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-load checkpoint when entering editor mode
|
||||||
|
watch(
|
||||||
|
() => [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 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentFrameImage,
|
||||||
|
currentFrameRef,
|
||||||
|
checkpointFrames,
|
||||||
|
checkpointFrameIndex,
|
||||||
|
checkpointStage,
|
||||||
|
stripSelStart,
|
||||||
|
stripSelEnd,
|
||||||
|
stripSelEndOverride,
|
||||||
|
loadCheckpoint,
|
||||||
|
setCheckpointFrame,
|
||||||
|
}
|
||||||
|
}
|
||||||
120
ui/detection-app/src/composables/useEditorState.ts
Normal file
120
ui/detection-app/src/composables/useEditorState.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { FrameOverlay, FrameBBox } from 'mpr-ui-framework'
|
||||||
|
import { matchTracks, renderTracksToImageData, imageDataToPngB64 } from '@/cv'
|
||||||
|
|
||||||
|
export type RegionBox = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
confidence: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorState(currentFrameRef: Ref<number | null>) {
|
||||||
|
const editorOverlays = ref<FrameOverlay[]>([])
|
||||||
|
const editorBoxes = ref<FrameBBox[]>([])
|
||||||
|
|
||||||
|
const allFrameRegions = ref<Record<number, RegionBox[]>>({})
|
||||||
|
const allFrameDebug = ref<Record<number, { edge_overlay_b64: string; lines_overlay_b64: string }>>({})
|
||||||
|
const frameDimensions = ref<{ w: number; h: number } | null>(null)
|
||||||
|
|
||||||
|
function updateDisplayForFrame(seq: number) {
|
||||||
|
const regions = allFrameRegions.value[seq] ?? []
|
||||||
|
editorBoxes.value = regions.map(r => ({
|
||||||
|
x: r.x, y: r.y, w: r.w, h: r.h,
|
||||||
|
confidence: r.confidence,
|
||||||
|
label: r.label ?? 'edge_region',
|
||||||
|
stage: 'detect_edges',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const debug = allFrameDebug.value[seq]
|
||||||
|
if (debug) {
|
||||||
|
const overlays: FrameOverlay[] = []
|
||||||
|
if (debug.edge_overlay_b64) {
|
||||||
|
const existing = editorOverlays.value.find(o => o.label === 'Canny edges')
|
||||||
|
overlays.push({ src: debug.edge_overlay_b64, label: 'Canny edges', visible: existing?.visible ?? true, opacity: existing?.opacity ?? 0.25 })
|
||||||
|
}
|
||||||
|
if (debug.lines_overlay_b64) {
|
||||||
|
const existing = editorOverlays.value.find(o => o.label === 'Hough lines')
|
||||||
|
overlays.push({ src: debug.lines_overlay_b64, label: 'Hough lines', visible: existing?.visible ?? true, opacity: existing?.opacity ?? 0.25 })
|
||||||
|
}
|
||||||
|
const trackOverlay = editorOverlays.value.find(o => o.label === 'Motion tracks')
|
||||||
|
if (trackOverlay) overlays.push(trackOverlay)
|
||||||
|
editorOverlays.value = overlays
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(allFrameRegions.value).length >= 2 && frameDimensions.value) {
|
||||||
|
updateTrackOverlay(seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTrackOverlay(currentSeq: number) {
|
||||||
|
const dims = frameDimensions.value
|
||||||
|
if (!dims || Object.keys(allFrameRegions.value).length < 2) return
|
||||||
|
const tracks = matchTracks(allFrameRegions.value)
|
||||||
|
const imageData = renderTracksToImageData(tracks, dims.w, dims.h, currentSeq)
|
||||||
|
const b64 = await imageDataToPngB64(imageData)
|
||||||
|
const existing = editorOverlays.value.find(o => o.label === 'Motion tracks')
|
||||||
|
const trackOverlay: FrameOverlay = {
|
||||||
|
src: b64,
|
||||||
|
label: 'Motion tracks',
|
||||||
|
visible: existing?.visible ?? true,
|
||||||
|
opacity: existing?.opacity ?? 0.9,
|
||||||
|
srcFormat: 'png',
|
||||||
|
}
|
||||||
|
editorOverlays.value = [
|
||||||
|
...editorOverlays.value.filter(o => o.label !== 'Motion tracks'),
|
||||||
|
trackOverlay,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReplayResult(result: {
|
||||||
|
regions_by_frame?: Record<string, RegionBox[]>
|
||||||
|
debug?: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }>
|
||||||
|
frameWidth?: number
|
||||||
|
frameHeight?: number
|
||||||
|
}) {
|
||||||
|
if (result.frameWidth && result.frameHeight) {
|
||||||
|
frameDimensions.value = { w: result.frameWidth, h: result.frameHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.regions_by_frame) {
|
||||||
|
for (const [seqStr, regions] of Object.entries(result.regions_by_frame)) {
|
||||||
|
allFrameRegions.value[Number(seqStr)] = regions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.debug) {
|
||||||
|
for (const [seqStr, dbg] of Object.entries(result.debug)) {
|
||||||
|
allFrameDebug.value[Number(seqStr)] = {
|
||||||
|
edge_overlay_b64: dbg.edge_overlay_b64,
|
||||||
|
lines_overlay_b64: dbg.lines_overlay_b64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSeq = currentFrameRef.value ?? 0
|
||||||
|
updateDisplayForFrame(currentSeq)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditorState() {
|
||||||
|
allFrameRegions.value = {}
|
||||||
|
allFrameDebug.value = {}
|
||||||
|
frameDimensions.value = null
|
||||||
|
editorOverlays.value = []
|
||||||
|
editorBoxes.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
editorOverlays,
|
||||||
|
editorBoxes,
|
||||||
|
allFrameRegions,
|
||||||
|
allFrameDebug,
|
||||||
|
frameDimensions,
|
||||||
|
updateDisplayForFrame,
|
||||||
|
onReplayResult,
|
||||||
|
resetEditorState,
|
||||||
|
}
|
||||||
|
}
|
||||||
106
ui/detection-app/src/composables/useSSEConnection.ts
Normal file
106
ui/detection-app/src/composables/useSSEConnection.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { SSEDataSource } from 'mpr-ui-framework'
|
||||||
|
import type { DataSource } from 'mpr-ui-framework'
|
||||||
|
import type { StatsUpdate, RunContext } from '../types/sse-contract'
|
||||||
|
import { usePipelineStore } from '../stores/pipeline'
|
||||||
|
|
||||||
|
type AppStatus = 'idle' | 'live' | 'processing' | 'error'
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, AppStatus> = {
|
||||||
|
idle: 'idle',
|
||||||
|
connecting: 'processing',
|
||||||
|
live: 'live',
|
||||||
|
error: 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSSEConnection() {
|
||||||
|
const pipeline = usePipelineStore()
|
||||||
|
|
||||||
|
const jobParam = new URLSearchParams(window.location.search).get('job')
|
||||||
|
const jobId = ref(jobParam || '')
|
||||||
|
const stats = ref<StatsUpdate | null>(null)
|
||||||
|
const runContext = ref<RunContext | null>(null)
|
||||||
|
const status = ref<AppStatus>('idle')
|
||||||
|
const sseConnected = ref(false)
|
||||||
|
|
||||||
|
// No job selected and no hash route → open source selector
|
||||||
|
if (!jobParam && !window.location.hash.replace(/^#\/?/, '')) {
|
||||||
|
pipeline.openSourceSelector()
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = new SSEDataSource({
|
||||||
|
id: 'detect-stream',
|
||||||
|
url: jobId.value ? `/api/detect/stream/${jobId.value}` : '',
|
||||||
|
eventTypes: ['graph_update', 'stats_update', 'frame_update', 'detection', 'log', 'job_complete', 'waiting'],
|
||||||
|
})
|
||||||
|
|
||||||
|
source.on<StatsUpdate>('stats_update', (e) => {
|
||||||
|
stats.value = e
|
||||||
|
if (!runContext.value && e.run_id) {
|
||||||
|
runContext.value = {
|
||||||
|
run_id: e.run_id!,
|
||||||
|
parent_job_id: e.parent_job_id!,
|
||||||
|
run_type: e.run_type ?? 'initial',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
source.on<{ report?: { status?: string; error?: string } }>('job_complete', (e) => {
|
||||||
|
if (e.report?.status === 'failed') {
|
||||||
|
status.value = 'error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reactive status sync — replaces setInterval polling
|
||||||
|
watch(
|
||||||
|
() => source.status.value,
|
||||||
|
(sourceStatus) => {
|
||||||
|
if (sseConnected.value) {
|
||||||
|
status.value = STATUS_MAP[sourceStatus] ?? 'idle'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only connect SSE for live pipeline runs
|
||||||
|
const isScenarioMode = pipeline.isEditing || pipeline.layoutMode !== 'normal'
|
||||||
|
if (jobId.value && !isScenarioMode) {
|
||||||
|
source.connect()
|
||||||
|
sseConnected.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopPipeline() {
|
||||||
|
if (!jobId.value) return
|
||||||
|
try {
|
||||||
|
await fetch(`/api/detect/stop/${jobId.value}`, { method: 'POST' })
|
||||||
|
} catch { /* ignore — UI will see the cancel event via SSE */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onJobStarted(newJobId: string) {
|
||||||
|
jobId.value = newJobId
|
||||||
|
stats.value = null
|
||||||
|
runContext.value = null
|
||||||
|
status.value = 'processing'
|
||||||
|
pipeline.reset()
|
||||||
|
pipeline.setStatus('running')
|
||||||
|
// Update URL without reload
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set('job', newJobId)
|
||||||
|
window.history.pushState({}, '', url.toString())
|
||||||
|
// Connect SSE to new job
|
||||||
|
source.disconnect()
|
||||||
|
source.setUrl(`/api/detect/stream/${newJobId}`)
|
||||||
|
source.connect()
|
||||||
|
sseConnected.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobId,
|
||||||
|
stats,
|
||||||
|
runContext,
|
||||||
|
status,
|
||||||
|
sseConnected,
|
||||||
|
source: source as DataSource,
|
||||||
|
stopPipeline,
|
||||||
|
onJobStarted,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user