phase 4
This commit is contained in:
4
ui/detection-app/.gitignore
vendored
Normal file
4
ui/detection-app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
public/opencv.js
|
||||
public/opencv_js.wasm
|
||||
@@ -10,14 +10,15 @@
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5",
|
||||
"@techstark/opencv-js": "4.12.0-release.1",
|
||||
"mpr-ui-framework": "link:../framework",
|
||||
"pinia": "^2.2",
|
||||
"mpr-ui-framework": "link:../framework"
|
||||
"vue": "^3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5",
|
||||
"typescript": "^5.6",
|
||||
"vite": "^6",
|
||||
"@vitejs/plugin-vue": "^5",
|
||||
"vue-tsc": "^2"
|
||||
}
|
||||
}
|
||||
|
||||
8
ui/detection-app/pnpm-lock.yaml
generated
8
ui/detection-app/pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@techstark/opencv-js':
|
||||
specifier: 4.12.0-release.1
|
||||
version: 4.12.0-release.1
|
||||
mpr-ui-framework:
|
||||
specifier: link:../framework
|
||||
version: link:../framework
|
||||
@@ -334,6 +337,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@techstark/opencv-js@4.12.0-release.1':
|
||||
resolution: {integrity: sha512-LtTaph9v/HqLPXEg3m1xs2h7QJh10pUpuDT0nj8g77lelWnTwwQrehtd+fXElLOdrkqc4Fea6Z/sJBvEJLYPfw==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -735,6 +741,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@techstark/opencv-js@4.12.0-release.1': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@6.4.1)(vue@3.5.30(typescript@5.9.3))':
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Panel, ResizeHandle, SplitPane } from 'mpr-ui-framework'
|
||||
import 'mpr-ui-framework/src/tokens.css'
|
||||
import LogPanel from './panels/LogPanel.vue'
|
||||
@@ -10,7 +10,7 @@ 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 StageConfig from './components/StageConfig.vue'
|
||||
import FrameStrip from './components/FrameStrip.vue'
|
||||
import { usePipelineStore } from './stores/pipeline'
|
||||
import { useSSEConnection } from './composables/useSSEConnection'
|
||||
@@ -41,9 +41,14 @@ const {
|
||||
// Editor overlays + CV result accumulation
|
||||
const {
|
||||
editorOverlays, editorBoxes,
|
||||
updateDisplayForFrame, onReplayResult,
|
||||
updateDisplayForFrame, onReplayResult, setActiveStage,
|
||||
} = useEditorState(currentFrameRef)
|
||||
|
||||
// Set active stage when editor opens
|
||||
watch(() => pipeline.editorStage, (stage) => {
|
||||
if (stage) setActiveStage(stage)
|
||||
}, { immediate: true })
|
||||
|
||||
// Wire checkpoint frame change to editor display update
|
||||
function setCheckpointFrame(index: number) {
|
||||
cpSetFrame(index)
|
||||
@@ -178,7 +183,7 @@ function onJobStarted(newJobId: string, opts?: { pauseAfterStage?: boolean }) {
|
||||
</template>
|
||||
<template #second>
|
||||
<div class="editor-sliders">
|
||||
<StageConfigSliders
|
||||
<StageConfig
|
||||
v-if="pipeline.editorStage"
|
||||
:stage="pipeline.editorStage"
|
||||
:job-id="jobId"
|
||||
@@ -206,20 +211,18 @@ function onJobStarted(newJobId: string, opts?: { pauseAfterStage?: boolean }) {
|
||||
|
||||
<div class="editor-bottom">
|
||||
<div class="overlay-controls">
|
||||
<template v-if="editorOverlays.length > 0">
|
||||
<label v-for="(overlay, idx) in editorOverlays" :key="idx" class="overlay-toggle">
|
||||
<input type="checkbox" v-model="overlay.visible" />
|
||||
<span class="overlay-label">{{ overlay.label }}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0" max="1" step="0.05"
|
||||
:value="overlay.opacity ?? 0.5"
|
||||
@input="(e: Event) => overlay.opacity = Number((e.target as HTMLInputElement).value)"
|
||||
class="opacity-slider"
|
||||
/>
|
||||
<span class="opacity-value">{{ Math.round((overlay.opacity ?? 0.5) * 100) }}%</span>
|
||||
</label>
|
||||
</template>
|
||||
<label v-for="(overlay, idx) in editorOverlays" :key="idx" class="overlay-toggle">
|
||||
<input type="checkbox" v-model="overlay.visible" />
|
||||
<span class="overlay-label">{{ overlay.label }}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0" max="1" step="0.05"
|
||||
:value="overlay.opacity ?? 0.5"
|
||||
@input="(e: Event) => overlay.opacity = Number((e.target as HTMLInputElement).value)"
|
||||
class="opacity-slider"
|
||||
/>
|
||||
<span class="opacity-value">{{ Math.round((overlay.opacity ?? 0.5) * 100) }}%</span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="editor-close" @click="pipeline.closeEditor()">✕ Close</button>
|
||||
</div>
|
||||
|
||||
634
ui/detection-app/src/components/StageConfig.vue
Normal file
634
ui/detection-app/src/components/StageConfig.vue
Normal file
@@ -0,0 +1,634 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ParameterEditor, useEditorExecution } from 'mpr-ui-framework'
|
||||
import type { ConfigField } from 'mpr-ui-framework'
|
||||
import {
|
||||
runEdgeDetectionTs,
|
||||
runEdgeDetectionTsDebug,
|
||||
runEdgeDetection,
|
||||
runEdgeDetectionDebug,
|
||||
runSegmentationDebug,
|
||||
b64ToImageData,
|
||||
imageDataToB64,
|
||||
} from '@/cv'
|
||||
import { getCV } from '@/cv/opencv'
|
||||
import type { EdgeDetectionParams, SegmentationParams } from '@/cv'
|
||||
|
||||
export interface StageResult {
|
||||
regions_by_frame?: Record<string, unknown[]>
|
||||
overlays_by_frame?: Record<string, Record<string, string>>
|
||||
stats_by_frame?: Record<string, Record<string, number>>
|
||||
frameWidth?: number
|
||||
frameHeight?: number
|
||||
}
|
||||
|
||||
type ExecMode = 'ts' | 'wasm' | 'server'
|
||||
|
||||
const props = defineProps<{
|
||||
stage: string
|
||||
jobId: string
|
||||
frameImage?: string | null
|
||||
frameRef?: number | null
|
||||
frames?: Array<{ seq: number; jpeg_b64: string }>
|
||||
selectionStart?: number
|
||||
selectionEnd?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'replay-result': [result: StageResult]
|
||||
}>()
|
||||
|
||||
const fields = ref<ConfigField[]>([])
|
||||
const values = ref<Record<string, unknown>>({})
|
||||
const statusText = ref<string | null>(null)
|
||||
const debugEnabled = ref(true)
|
||||
const processingIndex = ref<number | null>(null)
|
||||
|
||||
// --- Execution mode ---
|
||||
// detect_edges: ts (default) → wasm (when ready) → server
|
||||
// field_segmentation: wasm (when ready, else server) → server
|
||||
// other: server only
|
||||
|
||||
const HAS_TS = ['detect_edges']
|
||||
const HAS_WASM = ['detect_edges', 'field_segmentation']
|
||||
|
||||
const wasmReady = ref(false)
|
||||
const wasmLoading = ref(false)
|
||||
|
||||
const defaultMode = computed<ExecMode>(() => {
|
||||
if (HAS_TS.includes(props.stage)) return 'ts'
|
||||
return 'server'
|
||||
})
|
||||
|
||||
const execMode = ref<ExecMode>(defaultMode.value)
|
||||
|
||||
const availableModes = computed<{ mode: ExecMode; label: string; disabled: boolean }[]>(() => {
|
||||
const modes: { mode: ExecMode; label: string; disabled: boolean }[] = []
|
||||
if (HAS_TS.includes(props.stage)) {
|
||||
modes.push({ mode: 'ts', label: 'TS', disabled: false })
|
||||
}
|
||||
if (HAS_WASM.includes(props.stage)) {
|
||||
modes.push({
|
||||
mode: 'wasm',
|
||||
label: wasmLoading.value ? 'WASM...' : 'WASM',
|
||||
disabled: !wasmReady.value,
|
||||
})
|
||||
}
|
||||
modes.push({ mode: 'server', label: 'Server', disabled: false })
|
||||
return modes
|
||||
})
|
||||
|
||||
// Load WASM on demand — only when user selects WASM mode
|
||||
async function loadWasmIfNeeded() {
|
||||
if (wasmReady.value || wasmLoading.value) return
|
||||
wasmLoading.value = true
|
||||
const cv = await getCV()
|
||||
wasmLoading.value = false
|
||||
if (cv) wasmReady.value = true
|
||||
}
|
||||
|
||||
function onModeClick(mode: ExecMode) {
|
||||
if (mode === 'wasm' && !wasmReady.value) {
|
||||
loadWasmIfNeeded().then(() => {
|
||||
if (wasmReady.value) execMode.value = 'wasm'
|
||||
})
|
||||
return
|
||||
}
|
||||
execMode.value = mode
|
||||
}
|
||||
|
||||
// --- Execution dispatch ---
|
||||
|
||||
type StageExecutors = {
|
||||
ts?: () => Promise<void>
|
||||
wasm?: () => Promise<void>
|
||||
server: () => Promise<void>
|
||||
}
|
||||
|
||||
const STAGE_EXECUTORS: Record<string, StageExecutors> = {
|
||||
detect_edges: {
|
||||
ts: () => runEdgesLocalTs(),
|
||||
wasm: () => runEdgesLocalWasm(),
|
||||
server: () => runEdgesServer(),
|
||||
},
|
||||
field_segmentation: {
|
||||
wasm: () => runSegmentationLocalWasm(),
|
||||
server: () => runSegmentationServer(),
|
||||
},
|
||||
}
|
||||
|
||||
async function executeDetection() {
|
||||
const hasFrames = props.frames && props.frames.length > 0
|
||||
if (!props.frameImage && !hasFrames) return
|
||||
|
||||
const executors = STAGE_EXECUTORS[props.stage]
|
||||
if (!executors) {
|
||||
await runGenericServer()
|
||||
return
|
||||
}
|
||||
|
||||
// Build fallback chain based on current mode
|
||||
const chain: (() => Promise<void>)[] = []
|
||||
|
||||
if (execMode.value === 'ts' && executors.ts) {
|
||||
chain.push(executors.ts)
|
||||
}
|
||||
if (execMode.value === 'wasm' && executors.wasm && wasmReady.value) {
|
||||
chain.push(executors.wasm)
|
||||
}
|
||||
// Server is always the final fallback
|
||||
chain.push(executors.server)
|
||||
|
||||
// If chain is empty (shouldn't happen), server
|
||||
if (chain.length === 0) chain.push(executors.server)
|
||||
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
try {
|
||||
await chain[i]()
|
||||
return
|
||||
} catch (e) {
|
||||
const isLast = i === chain.length - 1
|
||||
if (isLast) throw e
|
||||
console.warn(`[StageConfig] ${execMode.value} failed, trying next:`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
loading, error, autoApply, execTimeMs,
|
||||
apply: applyDetection, onParameterChange,
|
||||
} = useEditorExecution(executeDetection)
|
||||
|
||||
// --- Init ---
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/config/stages/${props.stage}`)
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
fields.value = data.config_fields ?? []
|
||||
}
|
||||
} catch { /* use empty fields */ }
|
||||
|
||||
for (const f of fields.value) {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
|
||||
if (props.frameImage) {
|
||||
applyDetection()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.frameImage, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal && fields.value.length > 0) {
|
||||
applyDetection()
|
||||
}
|
||||
})
|
||||
|
||||
watch([() => props.selectionStart, () => props.selectionEnd], () => {
|
||||
if (autoApply.value) onParameterChange()
|
||||
})
|
||||
|
||||
function onFieldUpdate(name: string, value: unknown) {
|
||||
values.value[name] = value
|
||||
onParameterChange()
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
for (const f of fields.value) {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
applyDetection()
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function buildConfigBody(): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {}
|
||||
for (const f of fields.value) {
|
||||
if (f.name !== 'enabled') {
|
||||
body[f.name] = values.value[f.name]
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function getTargetFrames() {
|
||||
if (props.frames && props.frames.length > 0) {
|
||||
return props.frames.slice(
|
||||
props.selectionStart ?? 0,
|
||||
(props.selectionEnd ?? props.frames.length - 1) + 1,
|
||||
)
|
||||
}
|
||||
return [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage! }]
|
||||
}
|
||||
|
||||
function edgeParams(): Partial<EdgeDetectionParams> {
|
||||
return {
|
||||
cannyLow: values.value['edge_canny_low'] as number,
|
||||
cannyHigh: values.value['edge_canny_high'] as number,
|
||||
houghThreshold: values.value['edge_hough_threshold'] as number,
|
||||
houghMinLength: values.value['edge_hough_min_length'] as number,
|
||||
houghMaxGap: values.value['edge_hough_max_gap'] as number,
|
||||
pairMaxDistance: values.value['edge_pair_max_distance'] as number,
|
||||
pairMinDistance: values.value['edge_pair_min_distance'] as number,
|
||||
}
|
||||
}
|
||||
|
||||
function segmentationParams(): Partial<SegmentationParams> {
|
||||
return {
|
||||
hueLow: values.value['hue_low'] as number,
|
||||
hueHigh: values.value['hue_high'] as number,
|
||||
satLow: values.value['sat_low'] as number,
|
||||
satHigh: values.value['sat_high'] as number,
|
||||
valLow: values.value['val_low'] as number,
|
||||
valHigh: values.value['val_high'] as number,
|
||||
morphKernel: values.value['morph_kernel'] as number,
|
||||
minAreaRatio: values.value['min_area_ratio'] as number,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edge detection: pure TS (always available) ---
|
||||
|
||||
async function runEdgesLocalTs() {
|
||||
const params = edgeParams()
|
||||
const targetFrames = getTargetFrames()
|
||||
const regions_by_frame: Record<string, unknown[]> = {}
|
||||
const overlays_by_frame: Record<string, Record<string, string>> = {}
|
||||
let totalRegions = 0
|
||||
let frameWidth = 0
|
||||
let frameHeight = 0
|
||||
|
||||
for (let i = 0; i < targetFrames.length; i++) {
|
||||
processingIndex.value = i
|
||||
const frame = targetFrames[i]
|
||||
const imageData = await b64ToImageData(frame.jpeg_b64)
|
||||
frameWidth = imageData.width
|
||||
frameHeight = imageData.height
|
||||
const frameKey = String(frame.seq)
|
||||
|
||||
if (debugEnabled.value) {
|
||||
const result = runEdgeDetectionTsDebug(imageData, params)
|
||||
totalRegions += result.regions.length
|
||||
const edgeB64 = await imageDataToB64(result.edgeImageData)
|
||||
const linesB64 = await imageDataToB64(result.linesImageData)
|
||||
regions_by_frame[frameKey] = result.regions
|
||||
overlays_by_frame[frameKey] = { edge_overlay_b64: edgeB64, lines_overlay_b64: linesB64 }
|
||||
} else {
|
||||
const result = runEdgeDetectionTs(imageData, params)
|
||||
totalRegions += result.regions.length
|
||||
regions_by_frame[frameKey] = result.regions
|
||||
}
|
||||
|
||||
emit('replay-result', {
|
||||
regions_by_frame: { ...regions_by_frame },
|
||||
overlays_by_frame: { ...overlays_by_frame },
|
||||
frameWidth, frameHeight,
|
||||
})
|
||||
}
|
||||
|
||||
processingIndex.value = null
|
||||
statusText.value = `${totalRegions} regions`
|
||||
}
|
||||
|
||||
// --- Edge detection: WASM ---
|
||||
|
||||
async function runEdgesLocalWasm() {
|
||||
const params = edgeParams()
|
||||
const targetFrames = getTargetFrames()
|
||||
const regions_by_frame: Record<string, unknown[]> = {}
|
||||
const overlays_by_frame: Record<string, Record<string, string>> = {}
|
||||
let totalRegions = 0
|
||||
let frameWidth = 0
|
||||
let frameHeight = 0
|
||||
|
||||
for (let i = 0; i < targetFrames.length; i++) {
|
||||
processingIndex.value = i
|
||||
const frame = targetFrames[i]
|
||||
const imageData = await b64ToImageData(frame.jpeg_b64)
|
||||
frameWidth = imageData.width
|
||||
frameHeight = imageData.height
|
||||
const frameKey = String(frame.seq)
|
||||
|
||||
if (debugEnabled.value) {
|
||||
const result = await runEdgeDetectionDebug(imageData, params)
|
||||
totalRegions += result.regions.length
|
||||
const edgeB64 = await imageDataToB64(result.edgeImageData)
|
||||
const linesB64 = await imageDataToB64(result.linesImageData)
|
||||
regions_by_frame[frameKey] = result.regions
|
||||
overlays_by_frame[frameKey] = { edge_overlay_b64: edgeB64, lines_overlay_b64: linesB64 }
|
||||
} else {
|
||||
const result = await runEdgeDetection(imageData, params)
|
||||
totalRegions += result.regions.length
|
||||
regions_by_frame[frameKey] = result.regions
|
||||
}
|
||||
|
||||
emit('replay-result', {
|
||||
regions_by_frame: { ...regions_by_frame },
|
||||
overlays_by_frame: { ...overlays_by_frame },
|
||||
frameWidth, frameHeight,
|
||||
})
|
||||
}
|
||||
|
||||
processingIndex.value = null
|
||||
statusText.value = `${totalRegions} regions`
|
||||
}
|
||||
|
||||
// --- Edge detection: server ---
|
||||
|
||||
async function runEdgesServer() {
|
||||
const body = buildConfigBody()
|
||||
body.image = props.frameImage
|
||||
|
||||
const endpoint = debugEnabled.value
|
||||
? '/api/detect/gpu/detect_edges/debug'
|
||||
: '/api/detect/gpu/detect_edges'
|
||||
|
||||
const resp = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!resp.ok) throw new Error(await resp.text())
|
||||
|
||||
const data = await resp.json()
|
||||
const frameKey = String(props.frameRef ?? 0)
|
||||
|
||||
const result: StageResult = {
|
||||
regions_by_frame: { [frameKey]: data.regions ?? [] },
|
||||
overlays_by_frame: {},
|
||||
}
|
||||
|
||||
if (data.edge_overlay_b64 || data.lines_overlay_b64) {
|
||||
result.overlays_by_frame = {
|
||||
[frameKey]: {
|
||||
edge_overlay_b64: data.edge_overlay_b64 ?? '',
|
||||
lines_overlay_b64: data.lines_overlay_b64 ?? '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
statusText.value = `${data.regions?.length ?? 0} regions`
|
||||
emit('replay-result', result)
|
||||
}
|
||||
|
||||
// --- Field segmentation: WASM ---
|
||||
|
||||
async function runSegmentationLocalWasm() {
|
||||
const params = segmentationParams()
|
||||
const targetFrames = getTargetFrames()
|
||||
const overlays_by_frame: Record<string, Record<string, string>> = {}
|
||||
const stats_by_frame: Record<string, Record<string, number>> = {}
|
||||
let frameWidth = 0
|
||||
let frameHeight = 0
|
||||
|
||||
for (let i = 0; i < targetFrames.length; i++) {
|
||||
processingIndex.value = i
|
||||
const frame = targetFrames[i]
|
||||
const imageData = await b64ToImageData(frame.jpeg_b64)
|
||||
frameWidth = imageData.width
|
||||
frameHeight = imageData.height
|
||||
const frameKey = String(frame.seq)
|
||||
|
||||
const result = await runSegmentationDebug(imageData, params)
|
||||
const overlayB64 = await imageDataToB64(result.overlayImageData)
|
||||
overlays_by_frame[frameKey] = { mask_overlay_b64: overlayB64 }
|
||||
stats_by_frame[frameKey] = { coverage: result.coverage }
|
||||
|
||||
emit('replay-result', {
|
||||
overlays_by_frame: { ...overlays_by_frame },
|
||||
stats_by_frame: { ...stats_by_frame },
|
||||
frameWidth, frameHeight,
|
||||
})
|
||||
}
|
||||
|
||||
processingIndex.value = null
|
||||
const coverages = Object.values(stats_by_frame).map(s => s.coverage)
|
||||
const avg = coverages.reduce((a, b) => a + b, 0) / Math.max(coverages.length, 1)
|
||||
statusText.value = `${Math.round(avg * 100)}% coverage`
|
||||
}
|
||||
|
||||
// --- Field segmentation: server ---
|
||||
|
||||
async function runSegmentationServer() {
|
||||
const targetFrames = getTargetFrames()
|
||||
const overlays_by_frame: Record<string, Record<string, string>> = {}
|
||||
const stats_by_frame: Record<string, Record<string, number>> = {}
|
||||
|
||||
for (let i = 0; i < targetFrames.length; i++) {
|
||||
processingIndex.value = i
|
||||
const frame = targetFrames[i]
|
||||
const frameKey = String(frame.seq)
|
||||
|
||||
const body = buildConfigBody()
|
||||
body.image = frame.jpeg_b64
|
||||
|
||||
const resp = await fetch('/api/detect/gpu/segment_field/debug', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!resp.ok) throw new Error(await resp.text())
|
||||
|
||||
const data = await resp.json()
|
||||
if (data.mask_overlay_b64) {
|
||||
overlays_by_frame[frameKey] = { mask_overlay_b64: data.mask_overlay_b64 }
|
||||
}
|
||||
stats_by_frame[frameKey] = { coverage: data.coverage ?? 0 }
|
||||
|
||||
emit('replay-result', {
|
||||
overlays_by_frame: { ...overlays_by_frame },
|
||||
stats_by_frame: { ...stats_by_frame },
|
||||
})
|
||||
}
|
||||
|
||||
processingIndex.value = null
|
||||
const coverages = Object.values(stats_by_frame).map(s => s.coverage)
|
||||
const avg = coverages.reduce((a, b) => a + b, 0) / Math.max(coverages.length, 1)
|
||||
statusText.value = `${Math.round(avg * 100)}% coverage`
|
||||
}
|
||||
|
||||
// --- Generic fallback ---
|
||||
|
||||
async function runGenericServer() {
|
||||
statusText.value = 'No executor for this 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 class="mode-toggle">
|
||||
<button
|
||||
v-for="m in availableModes" :key="m.mode"
|
||||
:class="['mode-btn', { active: execMode === m.mode, waiting: m.mode === 'wasm' && wasmLoading }]"
|
||||
:disabled="m.disabled && m.mode !== 'wasm'"
|
||||
@click="onModeClick(m.mode)"
|
||||
>{{ m.label }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="sliders-error">{{ error }}</div>
|
||||
|
||||
<div class="sliders-list">
|
||||
<ParameterEditor
|
||||
:fields="fields"
|
||||
:values="values"
|
||||
@update="onFieldUpdate"
|
||||
@reset="resetDefaults"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sliders-footer">
|
||||
<button :class="['apply-btn', { waiting: loading }]" :disabled="loading || autoApply" @click="applyDetection">
|
||||
{{ loading ? 'Running...' : 'Apply' }}
|
||||
</button>
|
||||
<label class="auto-apply-toggle">
|
||||
<input type="checkbox" v-model="autoApply" />
|
||||
<span>Auto</span>
|
||||
</label>
|
||||
<span v-if="processingIndex != null && frames && frames.length > 1" class="region-count">
|
||||
{{ processingIndex + 1 }}/{{ frames.length }}
|
||||
</span>
|
||||
<span v-else-if="statusText" class="region-count">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<span v-if="execTimeMs != null" class="exec-time">
|
||||
{{ execTimeMs }}ms
|
||||
</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); }
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
flex: 1;
|
||||
padding: 3px 8px;
|
||||
border: none;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.mode-btn.active {
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mode-btn:hover:not(.active):not(:disabled) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.mode-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.auto-apply-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-apply-toggle input { accent-color: #00bcd4; }
|
||||
|
||||
.region-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.exec-time {
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,385 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ParameterEditor, useEditorExecution } from 'mpr-ui-framework'
|
||||
import type { ConfigField } from 'mpr-ui-framework'
|
||||
import {
|
||||
runEdgeDetection,
|
||||
runEdgeDetectionDebug,
|
||||
b64ToImageData,
|
||||
imageDataToB64,
|
||||
} from '@/cv'
|
||||
import type { EdgeDetectionParams } from '@/cv'
|
||||
|
||||
const props = defineProps<{
|
||||
stage: string
|
||||
jobId: string
|
||||
frameImage?: string | null
|
||||
frameRef?: number | null
|
||||
frames?: Array<{ seq: number; jpeg_b64: string }>
|
||||
selectionStart?: number
|
||||
selectionEnd?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'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 }>
|
||||
frameWidth?: number
|
||||
frameHeight?: number
|
||||
}]
|
||||
}>()
|
||||
|
||||
const fields = ref<ConfigField[]>([])
|
||||
const values = ref<Record<string, unknown>>({})
|
||||
const regionCount = ref<number | null>(null)
|
||||
const debugEnabled = ref(true)
|
||||
const execMode = ref<'local' | 'server'>('local')
|
||||
const processingIndex = ref<number | null>(null)
|
||||
|
||||
// Config field defaults for detect_edges (used when API is unavailable)
|
||||
const EDGE_DEFAULTS: ConfigField[] = [
|
||||
{ name: 'enabled', type: 'bool', default: true, description: 'Enable edge detection', min: null, max: null, options: null },
|
||||
{ name: 'edge_canny_low', type: 'int', default: 50, description: 'Canny low threshold', min: 0, max: 255, options: null },
|
||||
{ name: 'edge_canny_high', type: 'int', default: 150, description: 'Canny high threshold', min: 0, max: 255, options: null },
|
||||
{ name: 'edge_hough_threshold', type: 'int', default: 80, description: 'Hough accumulator threshold', min: 1, max: 500, options: null },
|
||||
{ name: 'edge_hough_min_length', type: 'int', default: 100, description: 'Min line length (px)', min: 10, max: 2000, options: null },
|
||||
{ name: 'edge_hough_max_gap', type: 'int', default: 10, description: 'Max line gap (px)', min: 1, max: 100, options: null },
|
||||
{ name: 'edge_pair_max_distance', type: 'int', default: 200, description: 'Max pair distance (px)', min: 10, max: 500, options: null },
|
||||
{ name: 'edge_pair_min_distance', type: 'int', default: 15, description: 'Min pair distance (px)', min: 5, max: 200, options: null },
|
||||
]
|
||||
|
||||
// --- 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 () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/config/stages/${props.stage}`)
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
fields.value = data.config_fields ?? []
|
||||
} else {
|
||||
fields.value = EDGE_DEFAULTS
|
||||
}
|
||||
} catch {
|
||||
fields.value = EDGE_DEFAULTS
|
||||
}
|
||||
|
||||
for (const f of fields.value) {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
|
||||
if (props.frameImage) {
|
||||
applyDetection()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.frameImage, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal && fields.value.length > 0) {
|
||||
applyDetection()
|
||||
}
|
||||
})
|
||||
|
||||
watch([() => props.selectionStart, () => props.selectionEnd], () => {
|
||||
if (autoApply.value) onParameterChange()
|
||||
})
|
||||
|
||||
function onFieldUpdate(name: string, value: unknown) {
|
||||
values.value[name] = value
|
||||
onParameterChange()
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
for (const f of fields.value) {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
applyDetection()
|
||||
}
|
||||
|
||||
// --- Local CV execution ---
|
||||
|
||||
async function runLocal() {
|
||||
const params: Partial<EdgeDetectionParams> = {
|
||||
cannyLow: values.value['edge_canny_low'] as number,
|
||||
cannyHigh: values.value['edge_canny_high'] as number,
|
||||
houghThreshold: values.value['edge_hough_threshold'] as number,
|
||||
houghMinLength: values.value['edge_hough_min_length'] as number,
|
||||
houghMaxGap: values.value['edge_hough_max_gap'] as number,
|
||||
pairMaxDistance: values.value['edge_pair_max_distance'] as number,
|
||||
pairMinDistance: values.value['edge_pair_min_distance'] as number,
|
||||
}
|
||||
|
||||
const targetFrames = props.frames && props.frames.length > 0
|
||||
? props.frames.slice(props.selectionStart ?? 0, (props.selectionEnd ?? props.frames.length - 1) + 1)
|
||||
: [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage! }]
|
||||
|
||||
const regions_by_frame: Record<string, unknown[]> = {}
|
||||
const debug: Record<string, { edge_overlay_b64: string; lines_overlay_b64: string; horizontal_count: number; pair_count: number }> = {}
|
||||
let totalRegions = 0
|
||||
let frameWidth = 0
|
||||
let frameHeight = 0
|
||||
|
||||
for (let i = 0; i < targetFrames.length; i++) {
|
||||
processingIndex.value = i
|
||||
const frame = targetFrames[i]
|
||||
const imageData = await b64ToImageData(frame.jpeg_b64)
|
||||
frameWidth = imageData.width
|
||||
frameHeight = imageData.height
|
||||
const frameKey = String(frame.seq)
|
||||
|
||||
if (debugEnabled.value) {
|
||||
const result = await runEdgeDetectionDebug(imageData, params)
|
||||
totalRegions += result.regions.length
|
||||
const edgeB64 = await imageDataToB64(result.edgeImageData)
|
||||
const linesB64 = await imageDataToB64(result.linesImageData)
|
||||
regions_by_frame[frameKey] = result.regions
|
||||
debug[frameKey] = {
|
||||
edge_overlay_b64: edgeB64,
|
||||
lines_overlay_b64: linesB64,
|
||||
horizontal_count: result.horizontalCount,
|
||||
pair_count: result.pairCount,
|
||||
}
|
||||
} else {
|
||||
const result = await runEdgeDetection(imageData, params)
|
||||
totalRegions += result.regions.length
|
||||
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
|
||||
regionCount.value = totalRegions
|
||||
}
|
||||
|
||||
// --- Server CV execution ---
|
||||
|
||||
async function runServer() {
|
||||
const body: Record<string, unknown> = { image: props.frameImage }
|
||||
for (const f of fields.value) {
|
||||
if (f.name !== 'enabled') {
|
||||
body[f.name] = values.value[f.name]
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = debugEnabled.value
|
||||
? '/api/detect/gpu/detect_edges/debug'
|
||||
: '/api/detect/gpu/detect_edges'
|
||||
|
||||
const resp = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text()
|
||||
throw new Error(detail)
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
regionCount.value = data.regions?.length ?? 0
|
||||
|
||||
const frameKey = String(props.frameRef ?? 0)
|
||||
const result: any = {
|
||||
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)
|
||||
}
|
||||
</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 class="mode-toggle">
|
||||
<button :class="['mode-btn', { active: execMode === 'local' }]" @click="execMode = 'local'">Local</button>
|
||||
<button :class="['mode-btn', { active: execMode === 'server' }]" @click="execMode = 'server'">Server</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="sliders-error">{{ error }}</div>
|
||||
|
||||
<div class="sliders-list">
|
||||
<ParameterEditor
|
||||
:fields="fields"
|
||||
:values="values"
|
||||
@update="onFieldUpdate"
|
||||
@reset="resetDefaults"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sliders-footer">
|
||||
<button class="apply-btn" :disabled="loading || autoApply" @click="applyDetection">
|
||||
{{ loading ? 'Running...' : 'Apply' }}
|
||||
</button>
|
||||
<label class="auto-apply-toggle">
|
||||
<input type="checkbox" v-model="autoApply" />
|
||||
<span>Auto</span>
|
||||
</label>
|
||||
<span v-if="processingIndex != null && frames && frames.length > 1" class="region-count">
|
||||
{{ processingIndex + 1 }}/{{ frames.length }}
|
||||
</span>
|
||||
<span v-else-if="regionCount != null" class="region-count">
|
||||
{{ regionCount }} regions
|
||||
</span>
|
||||
<span v-if="execTimeMs != null" class="exec-time">
|
||||
{{ execTimeMs }}ms
|
||||
</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); }
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
flex: 1;
|
||||
padding: 3px 8px;
|
||||
border: none;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.mode-btn.active {
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mode-btn:hover:not(.active) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sliders-error {
|
||||
color: var(--status-error);
|
||||
font-size: 10px;
|
||||
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;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.auto-apply-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-apply-toggle input { accent-color: #00bcd4; }
|
||||
|
||||
.region-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.exec-time {
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -29,15 +29,37 @@ export function useCheckpointLoader(
|
||||
stripSelEndOverride.value ?? Math.max(0, checkpointFrames.value.length - 1),
|
||||
)
|
||||
|
||||
// Cache job_id → timeline_id mappings
|
||||
const timelineCache = new Map<string, string>()
|
||||
|
||||
// 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 resolveTimelineId(job: string): Promise<string | null> {
|
||||
if (timelineCache.has(job)) return timelineCache.get(job)!
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/timeline/${job}`)
|
||||
if (!resp.ok) return null
|
||||
const data = await resp.json()
|
||||
const tid = data.timeline_id
|
||||
if (tid) timelineCache.set(job, tid)
|
||||
return tid
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCheckpoint(job: string, stage: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/detect/checkpoints/${job}/${stage}`)
|
||||
// Resolve timeline_id from job_id
|
||||
const timelineId = await resolveTimelineId(job)
|
||||
const lookupId = timelineId ?? job
|
||||
|
||||
const resp = await fetch(`/api/detect/checkpoints/${lookupId}/${stage}`)
|
||||
if (!resp.ok) return
|
||||
|
||||
const data = await resp.json()
|
||||
@@ -69,8 +91,6 @@ export function useCheckpointLoader(
|
||||
const { stages, checkpointStageFor } = useStageRegistry()
|
||||
|
||||
// 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(
|
||||
() => [pipeline.layoutMode, pipeline.editorStage, jobId.value, stages.value.length] as const,
|
||||
([mode, stage, job]) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { FrameOverlay, FrameBBox } from 'mpr-ui-framework'
|
||||
import { matchTracks, renderTracksToImageData, imageDataToPngB64 } from '@/cv'
|
||||
import type { StageResult } from '@/components/StageConfig.vue'
|
||||
|
||||
export type RegionBox = {
|
||||
x: number
|
||||
@@ -15,83 +15,80 @@ export type RegionBox = {
|
||||
export function useEditorState(currentFrameRef: Ref<number | null>) {
|
||||
const editorOverlays = ref<FrameOverlay[]>([])
|
||||
const editorBoxes = ref<FrameBBox[]>([])
|
||||
const activeStage = ref<string | null>(null)
|
||||
|
||||
const allFrameRegions = ref<Record<number, RegionBox[]>>({})
|
||||
const allFrameDebug = ref<Record<number, { edge_overlay_b64: string; lines_overlay_b64: string }>>({})
|
||||
const allFrameOverlays = ref<Record<number, Record<string, string>>>({})
|
||||
const allFrameStats = ref<Record<number, Record<string, number>>>({})
|
||||
const frameDimensions = ref<{ w: number; h: number } | null>(null)
|
||||
|
||||
// Overlay definitions per stage — maps overlay keys to display labels
|
||||
const STAGE_OVERLAYS: Record<string, { key: string; label: string; defaultOpacity: number; srcFormat?: 'jpeg' | 'png' }[]> = {
|
||||
detect_edges: [
|
||||
{ key: 'edge_overlay_b64', label: 'Canny edges', defaultOpacity: 0.25 },
|
||||
{ key: 'lines_overlay_b64', label: 'Hough lines', defaultOpacity: 0.25 },
|
||||
],
|
||||
field_segmentation: [
|
||||
{ key: 'mask_overlay_b64', label: 'Field mask', defaultOpacity: 0.5, srcFormat: 'png' },
|
||||
],
|
||||
}
|
||||
|
||||
function updateDisplayForFrame(seq: number) {
|
||||
const stage = activeStage.value ?? 'detect_edges'
|
||||
|
||||
// Boxes — only for stages that produce regions
|
||||
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',
|
||||
label: r.label ?? 'region',
|
||||
stage,
|
||||
}))
|
||||
|
||||
const debug = allFrameDebug.value[seq]
|
||||
if (debug) {
|
||||
// Overlays — driven by stage overlay definitions
|
||||
const overlayData = allFrameOverlays.value[seq]
|
||||
const overlayDefs = STAGE_OVERLAYS[stage] ?? []
|
||||
|
||||
if (overlayData && overlayDefs.length > 0) {
|
||||
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 })
|
||||
for (const def of overlayDefs) {
|
||||
const src = overlayData[def.key]
|
||||
if (!src) continue
|
||||
const existing = editorOverlays.value.find(o => o.label === def.label)
|
||||
overlays.push({
|
||||
src,
|
||||
label: def.label,
|
||||
visible: existing?.visible ?? true,
|
||||
opacity: existing?.opacity ?? def.defaultOpacity,
|
||||
srcFormat: def.srcFormat,
|
||||
})
|
||||
}
|
||||
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)
|
||||
} else if (overlayDefs.length === 0) {
|
||||
editorOverlays.value = []
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}) {
|
||||
function onReplayResult(result: StageResult) {
|
||||
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
|
||||
allFrameRegions.value[Number(seqStr)] = regions as RegionBox[]
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
if (result.overlays_by_frame) {
|
||||
for (const [seqStr, overlayMap] of Object.entries(result.overlays_by_frame)) {
|
||||
allFrameOverlays.value[Number(seqStr)] = overlayMap
|
||||
}
|
||||
}
|
||||
|
||||
if (result.stats_by_frame) {
|
||||
for (const [seqStr, stats] of Object.entries(result.stats_by_frame)) {
|
||||
allFrameStats.value[Number(seqStr)] = stats
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +96,30 @@ export function useEditorState(currentFrameRef: Ref<number | null>) {
|
||||
updateDisplayForFrame(currentSeq)
|
||||
}
|
||||
|
||||
function resetEditorState() {
|
||||
function setActiveStage(stage: string) {
|
||||
activeStage.value = stage
|
||||
// Clear accumulated state when switching stages
|
||||
allFrameRegions.value = {}
|
||||
allFrameDebug.value = {}
|
||||
allFrameOverlays.value = {}
|
||||
allFrameStats.value = {}
|
||||
frameDimensions.value = null
|
||||
editorBoxes.value = []
|
||||
|
||||
// Initialize overlay controls from stage definitions (visible before any results)
|
||||
const defs = STAGE_OVERLAYS[stage] ?? []
|
||||
editorOverlays.value = defs.map(def => ({
|
||||
src: '',
|
||||
label: def.label,
|
||||
visible: true,
|
||||
opacity: def.defaultOpacity,
|
||||
}))
|
||||
}
|
||||
|
||||
function resetEditorState() {
|
||||
activeStage.value = null
|
||||
allFrameRegions.value = {}
|
||||
allFrameOverlays.value = {}
|
||||
allFrameStats.value = {}
|
||||
frameDimensions.value = null
|
||||
editorOverlays.value = []
|
||||
editorBoxes.value = []
|
||||
@@ -110,11 +128,14 @@ export function useEditorState(currentFrameRef: Ref<number | null>) {
|
||||
return {
|
||||
editorOverlays,
|
||||
editorBoxes,
|
||||
activeStage,
|
||||
allFrameRegions,
|
||||
allFrameDebug,
|
||||
allFrameOverlays,
|
||||
allFrameStats,
|
||||
frameDimensions,
|
||||
updateDisplayForFrame,
|
||||
onReplayResult,
|
||||
setActiveStage,
|
||||
resetEditorState,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Edge detection — TypeScript port of gpu/models/cv/edges.py
|
||||
* Edge detection — OpenCV WASM version.
|
||||
*
|
||||
* 1:1 with the Python version. Same algorithm, same parameters,
|
||||
* same output format. Runs in the browser, no network.
|
||||
* 1:1 with gpu/models/cv/edges.py. Same algorithm, same parameters,
|
||||
* same output format. Uses cv.Canny() and cv.HoughLinesP() via WASM.
|
||||
*/
|
||||
|
||||
import { toGrayscale, canny } from './imageOps'
|
||||
import { houghLinesP, type LineSegment } from './hough'
|
||||
import { getCV } from './opencv'
|
||||
|
||||
export interface EdgeRegion {
|
||||
x: number
|
||||
@@ -32,15 +31,243 @@ export interface EdgeDetectionResult {
|
||||
}
|
||||
|
||||
export interface EdgeDetectionDebugResult extends EdgeDetectionResult {
|
||||
edgeImageData: ImageData // Canny output for overlay
|
||||
linesImageData: ImageData // Frame with Hough lines drawn
|
||||
edgeImageData: ImageData
|
||||
linesImageData: ImageData
|
||||
horizontalCount: number
|
||||
pairCount: number
|
||||
}
|
||||
|
||||
type HLine = { xMin: number; xMax: number; yMid: number; length: number }
|
||||
|
||||
/** Set a pixel on ImageData with bounds check */
|
||||
const DEFAULT_PARAMS: EdgeDetectionParams = {
|
||||
cannyLow: 50,
|
||||
cannyHigh: 150,
|
||||
houghThreshold: 80,
|
||||
houghMinLength: 100,
|
||||
houghMaxGap: 10,
|
||||
pairMaxDistance: 200,
|
||||
pairMinDistance: 15,
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect edges in an RGBA ImageData using OpenCV WASM.
|
||||
*/
|
||||
export async function detectEdges(
|
||||
imageData: ImageData,
|
||||
params: Partial<EdgeDetectionParams> = {},
|
||||
): Promise<EdgeDetectionResult> {
|
||||
const cv = await getCV()
|
||||
if (!cv) throw new Error('OpenCV WASM not available')
|
||||
const p = { ...DEFAULT_PARAMS, ...params }
|
||||
const { width, height } = imageData
|
||||
|
||||
const src = cv.matFromImageData(imageData)
|
||||
const gray = new cv.Mat()
|
||||
const edges = new cv.Mat()
|
||||
|
||||
try {
|
||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY)
|
||||
cv.Canny(gray, edges, p.cannyLow, p.cannyHigh)
|
||||
|
||||
const lines = new cv.Mat()
|
||||
try {
|
||||
cv.HoughLinesP(edges, lines, 1, Math.PI / 180, p.houghThreshold, p.houghMinLength, p.houghMaxGap)
|
||||
const horizontals = filterHorizontal(lines)
|
||||
if (horizontals.length < 2) return { regions: [] }
|
||||
|
||||
const pairs = findLinePairs(horizontals, p.pairMinDistance, p.pairMaxDistance)
|
||||
const regions = pairsToBoxes(pairs, width, height)
|
||||
return { regions }
|
||||
} finally {
|
||||
lines.delete()
|
||||
}
|
||||
} finally {
|
||||
src.delete()
|
||||
gray.delete()
|
||||
edges.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect edges with debug visualizations using OpenCV WASM.
|
||||
*/
|
||||
export async function detectEdgesDebug(
|
||||
imageData: ImageData,
|
||||
params: Partial<EdgeDetectionParams> = {},
|
||||
): Promise<EdgeDetectionDebugResult> {
|
||||
const cv = await getCV()
|
||||
if (!cv) throw new Error('OpenCV WASM not available')
|
||||
const p = { ...DEFAULT_PARAMS, ...params }
|
||||
const { width, height, data } = imageData
|
||||
|
||||
const src = cv.matFromImageData(imageData)
|
||||
const gray = new cv.Mat()
|
||||
const edges = new cv.Mat()
|
||||
|
||||
try {
|
||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY)
|
||||
cv.Canny(gray, edges, p.cannyLow, p.cannyHigh)
|
||||
|
||||
// Edge overlay — white edges on black
|
||||
const edgeRgba = new cv.Mat()
|
||||
try {
|
||||
cv.cvtColor(edges, edgeRgba, cv.COLOR_GRAY2RGBA)
|
||||
const edgeImageData = new ImageData(
|
||||
new Uint8ClampedArray(edgeRgba.data),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
|
||||
const lines = new cv.Mat()
|
||||
try {
|
||||
cv.HoughLinesP(edges, lines, 1, Math.PI / 180, p.houghThreshold, p.houghMinLength, p.houghMaxGap)
|
||||
const horizontals = filterHorizontal(lines)
|
||||
|
||||
// Lines overlay — darken original, draw lines
|
||||
const linesImageData = new ImageData(new Uint8ClampedArray(data), width, height)
|
||||
for (let i = 0; i < linesImageData.data.length; i += 4) {
|
||||
linesImageData.data[i] = Math.round(linesImageData.data[i] * 0.3)
|
||||
linesImageData.data[i + 1] = Math.round(linesImageData.data[i + 1] * 0.3)
|
||||
linesImageData.data[i + 2] = Math.round(linesImageData.data[i + 2] * 0.3)
|
||||
}
|
||||
|
||||
// All Hough lines in red
|
||||
for (let i = 0; i < lines.rows; i++) {
|
||||
const x1 = lines.data32S[i * 4]
|
||||
const y1 = lines.data32S[i * 4 + 1]
|
||||
const x2 = lines.data32S[i * 4 + 2]
|
||||
const y2 = lines.data32S[i * 4 + 3]
|
||||
drawLineThick(linesImageData, x1, y1, x2, y2, 255, 50, 50, 2)
|
||||
}
|
||||
|
||||
// Horizontal lines in cyan
|
||||
for (const h of horizontals) {
|
||||
drawLineThick(linesImageData, Math.round(h.xMin), Math.round(h.yMid), Math.round(h.xMax), Math.round(h.yMid), 0, 255, 255, 3)
|
||||
}
|
||||
|
||||
const pairs = horizontals.length >= 2
|
||||
? findLinePairs(horizontals, p.pairMinDistance, p.pairMaxDistance)
|
||||
: []
|
||||
|
||||
// Paired lines in green
|
||||
for (const [top, bottom] of pairs) {
|
||||
drawLineThick(linesImageData, Math.round(top.xMin), Math.round(top.yMid), Math.round(top.xMax), Math.round(top.yMid), 0, 255, 0, 4)
|
||||
drawLineThick(linesImageData, Math.round(bottom.xMin), Math.round(bottom.yMid), Math.round(bottom.xMax), Math.round(bottom.yMid), 0, 255, 0, 4)
|
||||
}
|
||||
|
||||
const regions = pairsToBoxes(pairs, width, height)
|
||||
|
||||
return {
|
||||
regions,
|
||||
edgeImageData,
|
||||
linesImageData,
|
||||
horizontalCount: horizontals.length,
|
||||
pairCount: pairs.length,
|
||||
}
|
||||
} finally {
|
||||
lines.delete()
|
||||
}
|
||||
} finally {
|
||||
edgeRgba.delete()
|
||||
}
|
||||
} finally {
|
||||
src.delete()
|
||||
gray.delete()
|
||||
edges.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Line analysis (same logic as Python version) ---
|
||||
|
||||
function filterHorizontal(lines: any, maxAngleDeg: number = 10): HLine[] {
|
||||
const maxSlope = Math.tan((maxAngleDeg * Math.PI) / 180)
|
||||
const result: HLine[] = []
|
||||
|
||||
for (let i = 0; i < lines.rows; i++) {
|
||||
const x1 = lines.data32S[i * 4]
|
||||
const y1 = lines.data32S[i * 4 + 1]
|
||||
const x2 = lines.data32S[i * 4 + 2]
|
||||
const y2 = lines.data32S[i * 4 + 3]
|
||||
const dx = x2 - x1
|
||||
if (dx === 0) continue
|
||||
const slope = Math.abs((y2 - y1) / dx)
|
||||
if (slope <= maxSlope) {
|
||||
const yMid = (y1 + y2) / 2
|
||||
const xMin = Math.min(x1, x2)
|
||||
const xMax = Math.max(x1, x2)
|
||||
const length = Math.sqrt(dx * dx + (y2 - y1) ** 2)
|
||||
result.push({ xMin, xMax, yMid, length })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function findLinePairs(
|
||||
horizontals: HLine[],
|
||||
minDistance: number,
|
||||
maxDistance: number,
|
||||
): [HLine, HLine][] {
|
||||
const sorted = [...horizontals].sort((a, b) => a.yMid - b.yMid)
|
||||
const pairs: [HLine, HLine][] = []
|
||||
const used = new Set<number>()
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (used.has(i)) continue
|
||||
const top = sorted[i]
|
||||
|
||||
for (let j = i + 1; j < sorted.length; j++) {
|
||||
if (used.has(j)) continue
|
||||
const bottom = sorted[j]
|
||||
const yGap = bottom.yMid - top.yMid
|
||||
|
||||
if (yGap < minDistance) continue
|
||||
if (yGap > maxDistance) break
|
||||
|
||||
const overlapStart = Math.max(top.xMin, bottom.xMin)
|
||||
const overlapEnd = Math.min(top.xMax, bottom.xMax)
|
||||
const overlap = overlapEnd - overlapStart
|
||||
const shorterLength = Math.min(top.xMax - top.xMin, bottom.xMax - bottom.xMin)
|
||||
|
||||
if (shorterLength > 0 && overlap / shorterLength >= 0.5) {
|
||||
pairs.push([top, bottom])
|
||||
used.add(i)
|
||||
used.add(j)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
function pairsToBoxes(pairs: [HLine, HLine][], frameWidth: number, frameHeight: number): EdgeRegion[] {
|
||||
const regions: EdgeRegion[] = []
|
||||
for (const [top, bottom] of pairs) {
|
||||
const x = Math.max(0, Math.min(top.xMin, bottom.xMin))
|
||||
const y = Math.max(0, top.yMid)
|
||||
const x2 = Math.min(frameWidth, Math.max(top.xMax, bottom.xMax))
|
||||
const y2 = Math.min(frameHeight, bottom.yMid)
|
||||
const w = x2 - x
|
||||
const h = y2 - y
|
||||
|
||||
if (w < 20 || h < 5) continue
|
||||
|
||||
const avgLineLength = (top.length + bottom.length) / 2
|
||||
const coverage = Math.min(1.0, avgLineLength / Math.max(w, 1))
|
||||
|
||||
regions.push({
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
w: Math.round(w),
|
||||
h: Math.round(h),
|
||||
confidence: Math.round(coverage * 1000) / 1000,
|
||||
label: 'edge_region',
|
||||
})
|
||||
}
|
||||
return regions
|
||||
}
|
||||
|
||||
// --- Drawing helpers ---
|
||||
|
||||
function setPixel(img: ImageData, x: number, y: number, r: number, g: number, b: number) {
|
||||
if (x >= 0 && x < img.width && y >= 0 && y < img.height) {
|
||||
const p = (y * img.width + x) * 4
|
||||
@@ -51,7 +278,6 @@ function setPixel(img: ImageData, x: number, y: number, r: number, g: number, b:
|
||||
}
|
||||
}
|
||||
|
||||
/** Bresenham line drawing with thickness */
|
||||
function drawLineThick(
|
||||
img: ImageData,
|
||||
x0: number, y0: number, x1: number, y1: number,
|
||||
@@ -77,202 +303,3 @@ function drawLineThick(
|
||||
if (e2 < dx) { err += dx; y0 += sy }
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS: EdgeDetectionParams = {
|
||||
cannyLow: 50,
|
||||
cannyHigh: 150,
|
||||
houghThreshold: 80,
|
||||
houghMinLength: 100,
|
||||
houghMaxGap: 10,
|
||||
pairMaxDistance: 200,
|
||||
pairMinDistance: 15,
|
||||
}
|
||||
|
||||
/** Filter to near-horizontal lines (within 10 degrees) */
|
||||
function filterHorizontal(lines: LineSegment[], maxAngleDeg: number = 10): HLine[] {
|
||||
const maxSlope = Math.tan((maxAngleDeg * Math.PI) / 180)
|
||||
const result: HLine[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const dx = line.x2 - line.x1
|
||||
if (dx === 0) continue
|
||||
const slope = Math.abs((line.y2 - line.y1) / dx)
|
||||
if (slope <= maxSlope) {
|
||||
const yMid = (line.y1 + line.y2) / 2
|
||||
const xMin = Math.min(line.x1, line.x2)
|
||||
const xMax = Math.max(line.x1, line.x2)
|
||||
const length = Math.sqrt(dx * dx + (line.y2 - line.y1) ** 2)
|
||||
result.push({ xMin, xMax, yMid, length })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Find pairs of horizontal lines that could be top/bottom of a hoarding */
|
||||
function findLinePairs(
|
||||
horizontals: HLine[],
|
||||
minDistance: number,
|
||||
maxDistance: number,
|
||||
): [HLine, HLine][] {
|
||||
const sorted = [...horizontals].sort((a, b) => a.yMid - b.yMid)
|
||||
const pairs: [HLine, HLine][] = []
|
||||
const used = new Set<number>()
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (used.has(i)) continue
|
||||
const top = sorted[i]
|
||||
|
||||
for (let j = i + 1; j < sorted.length; j++) {
|
||||
if (used.has(j)) continue
|
||||
const bottom = sorted[j]
|
||||
const yGap = bottom.yMid - top.yMid
|
||||
|
||||
if (yGap < minDistance) continue
|
||||
if (yGap > maxDistance) break
|
||||
|
||||
// Check horizontal overlap (50% of shorter line)
|
||||
const overlapStart = Math.max(top.xMin, bottom.xMin)
|
||||
const overlapEnd = Math.min(top.xMax, bottom.xMax)
|
||||
const overlap = overlapEnd - overlapStart
|
||||
const shorterLength = Math.min(top.xMax - top.xMin, bottom.xMax - bottom.xMin)
|
||||
|
||||
if (shorterLength > 0 && overlap / shorterLength >= 0.5) {
|
||||
pairs.push([top, bottom])
|
||||
used.add(i)
|
||||
used.add(j)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
/** Convert a line pair to a bounding box */
|
||||
function pairToBox(
|
||||
top: HLine,
|
||||
bottom: HLine,
|
||||
frameWidth: number,
|
||||
frameHeight: number,
|
||||
): EdgeRegion | null {
|
||||
const x = Math.max(0, Math.min(top.xMin, bottom.xMin))
|
||||
const y = Math.max(0, top.yMid)
|
||||
const x2 = Math.min(frameWidth, Math.max(top.xMax, bottom.xMax))
|
||||
const y2 = Math.min(frameHeight, bottom.yMid)
|
||||
const w = x2 - x
|
||||
const h = y2 - y
|
||||
|
||||
if (w < 20 || h < 5) return null
|
||||
|
||||
const avgLineLength = (top.length + bottom.length) / 2
|
||||
const coverage = Math.min(1.0, avgLineLength / Math.max(w, 1))
|
||||
|
||||
return {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
w: Math.round(w),
|
||||
h: Math.round(h),
|
||||
confidence: Math.round(coverage * 1000) / 1000,
|
||||
label: 'edge_region',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect edges in an RGBA ImageData.
|
||||
*
|
||||
* Equivalent to gpu/models/cv/edges.py detect_edges()
|
||||
*/
|
||||
export function detectEdges(
|
||||
imageData: ImageData,
|
||||
params: Partial<EdgeDetectionParams> = {},
|
||||
): EdgeDetectionResult {
|
||||
const p = { ...DEFAULT_PARAMS, ...params }
|
||||
const { width, height } = imageData
|
||||
|
||||
const gray = toGrayscale(imageData.data, width, height)
|
||||
const edges = canny(gray, width, height, p.cannyLow, p.cannyHigh)
|
||||
|
||||
const rawLines = houghLinesP(edges, width, height, p.houghThreshold, p.houghMinLength, p.houghMaxGap)
|
||||
const horizontals = filterHorizontal(rawLines)
|
||||
|
||||
if (horizontals.length < 2) return { regions: [] }
|
||||
|
||||
const pairs = findLinePairs(horizontals, p.pairMinDistance, p.pairMaxDistance)
|
||||
const regions: EdgeRegion[] = []
|
||||
for (const [top, bottom] of pairs) {
|
||||
const box = pairToBox(top, bottom, width, height)
|
||||
if (box) regions.push(box)
|
||||
}
|
||||
|
||||
return { regions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect edges with debug visualizations.
|
||||
*
|
||||
* Equivalent to gpu/models/cv/edges.py detect_edges_debug()
|
||||
*/
|
||||
export function detectEdgesDebug(
|
||||
imageData: ImageData,
|
||||
params: Partial<EdgeDetectionParams> = {},
|
||||
): EdgeDetectionDebugResult {
|
||||
const p = { ...DEFAULT_PARAMS, ...params }
|
||||
const { width, height, data } = imageData
|
||||
|
||||
const gray = toGrayscale(data, width, height)
|
||||
const edges = canny(gray, width, height, p.cannyLow, p.cannyHigh)
|
||||
|
||||
// Edge overlay — white edges on black
|
||||
const edgeImageData = new ImageData(width, height)
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const px = i * 4
|
||||
edgeImageData.data[px] = edges[i]
|
||||
edgeImageData.data[px + 1] = edges[i]
|
||||
edgeImageData.data[px + 2] = edges[i]
|
||||
edgeImageData.data[px + 3] = 255
|
||||
}
|
||||
|
||||
const rawLines = houghLinesP(edges, width, height, p.houghThreshold, p.houghMinLength, p.houghMaxGap)
|
||||
const horizontals = filterHorizontal(rawLines)
|
||||
|
||||
// Lines overlay — darken original frame so lines pop, then draw
|
||||
const linesImageData = new ImageData(new Uint8ClampedArray(data), width, height)
|
||||
for (let i = 0; i < linesImageData.data.length; i += 4) {
|
||||
linesImageData.data[i] = Math.round(linesImageData.data[i] * 0.3)
|
||||
linesImageData.data[i + 1] = Math.round(linesImageData.data[i + 1] * 0.3)
|
||||
linesImageData.data[i + 2] = Math.round(linesImageData.data[i + 2] * 0.3)
|
||||
}
|
||||
|
||||
// Draw all Hough lines in red (3px thick)
|
||||
for (const line of rawLines) {
|
||||
drawLineThick(linesImageData, line.x1, line.y1, line.x2, line.y2, 255, 50, 50, 2)
|
||||
}
|
||||
|
||||
// Draw horizontal lines in cyan (3px thick)
|
||||
for (const h of horizontals) {
|
||||
drawLineThick(linesImageData, Math.round(h.xMin), Math.round(h.yMid), Math.round(h.xMax), Math.round(h.yMid), 0, 255, 255, 3)
|
||||
}
|
||||
|
||||
const pairs = horizontals.length >= 2
|
||||
? findLinePairs(horizontals, p.pairMinDistance, p.pairMaxDistance)
|
||||
: []
|
||||
|
||||
// Draw paired lines in bright green (4px thick)
|
||||
for (const [top, bottom] of pairs) {
|
||||
drawLineThick(linesImageData, Math.round(top.xMin), Math.round(top.yMid), Math.round(top.xMax), Math.round(top.yMid), 0, 255, 0, 4)
|
||||
drawLineThick(linesImageData, Math.round(bottom.xMin), Math.round(bottom.yMid), Math.round(bottom.xMax), Math.round(bottom.yMid), 0, 255, 0, 4)
|
||||
}
|
||||
|
||||
const regions: EdgeRegion[] = []
|
||||
for (const [top, bottom] of pairs) {
|
||||
const box = pairToBox(top, bottom, width, height)
|
||||
if (box) regions.push(box)
|
||||
}
|
||||
|
||||
return {
|
||||
regions,
|
||||
edgeImageData,
|
||||
linesImageData,
|
||||
horizontalCount: horizontals.length,
|
||||
pairCount: pairs.length,
|
||||
}
|
||||
}
|
||||
|
||||
278
ui/detection-app/src/cv/edgesTs.ts
Normal file
278
ui/detection-app/src/cv/edgesTs.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Edge detection — TypeScript port of gpu/models/cv/edges.py
|
||||
*
|
||||
* 1:1 with the Python version. Same algorithm, same parameters,
|
||||
* same output format. Runs in the browser, no network.
|
||||
*/
|
||||
|
||||
import { toGrayscale, canny } from './imageOps'
|
||||
import { houghLinesP, type LineSegment } from './hough'
|
||||
|
||||
export interface EdgeRegion {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
confidence: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface EdgeDetectionParams {
|
||||
cannyLow: number
|
||||
cannyHigh: number
|
||||
houghThreshold: number
|
||||
houghMinLength: number
|
||||
houghMaxGap: number
|
||||
pairMaxDistance: number
|
||||
pairMinDistance: number
|
||||
}
|
||||
|
||||
export interface EdgeDetectionResult {
|
||||
regions: EdgeRegion[]
|
||||
}
|
||||
|
||||
export interface EdgeDetectionDebugResult extends EdgeDetectionResult {
|
||||
edgeImageData: ImageData // Canny output for overlay
|
||||
linesImageData: ImageData // Frame with Hough lines drawn
|
||||
horizontalCount: number
|
||||
pairCount: number
|
||||
}
|
||||
|
||||
type HLine = { xMin: number; xMax: number; yMid: number; length: number }
|
||||
|
||||
/** Set a pixel on ImageData with bounds check */
|
||||
function setPixel(img: ImageData, x: number, y: number, r: number, g: number, b: number) {
|
||||
if (x >= 0 && x < img.width && y >= 0 && y < img.height) {
|
||||
const p = (y * img.width + x) * 4
|
||||
img.data[p] = r
|
||||
img.data[p + 1] = g
|
||||
img.data[p + 2] = b
|
||||
img.data[p + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
/** Bresenham line drawing with thickness */
|
||||
function drawLineThick(
|
||||
img: ImageData,
|
||||
x0: number, y0: number, x1: number, y1: number,
|
||||
r: number, g: number, b: number,
|
||||
thickness: number = 1,
|
||||
) {
|
||||
const dx = Math.abs(x1 - x0)
|
||||
const dy = Math.abs(y1 - y0)
|
||||
const sx = x0 < x1 ? 1 : -1
|
||||
const sy = y0 < y1 ? 1 : -1
|
||||
let err = dx - dy
|
||||
const half = Math.floor(thickness / 2)
|
||||
|
||||
while (true) {
|
||||
for (let oy = -half; oy <= half; oy++) {
|
||||
for (let ox = -half; ox <= half; ox++) {
|
||||
setPixel(img, x0 + ox, y0 + oy, r, g, b)
|
||||
}
|
||||
}
|
||||
if (x0 === x1 && y0 === y1) break
|
||||
const e2 = 2 * err
|
||||
if (e2 > -dy) { err -= dy; x0 += sx }
|
||||
if (e2 < dx) { err += dx; y0 += sy }
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS: EdgeDetectionParams = {
|
||||
cannyLow: 50,
|
||||
cannyHigh: 150,
|
||||
houghThreshold: 80,
|
||||
houghMinLength: 100,
|
||||
houghMaxGap: 10,
|
||||
pairMaxDistance: 200,
|
||||
pairMinDistance: 15,
|
||||
}
|
||||
|
||||
/** Filter to near-horizontal lines (within 10 degrees) */
|
||||
function filterHorizontal(lines: LineSegment[], maxAngleDeg: number = 10): HLine[] {
|
||||
const maxSlope = Math.tan((maxAngleDeg * Math.PI) / 180)
|
||||
const result: HLine[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const dx = line.x2 - line.x1
|
||||
if (dx === 0) continue
|
||||
const slope = Math.abs((line.y2 - line.y1) / dx)
|
||||
if (slope <= maxSlope) {
|
||||
const yMid = (line.y1 + line.y2) / 2
|
||||
const xMin = Math.min(line.x1, line.x2)
|
||||
const xMax = Math.max(line.x1, line.x2)
|
||||
const length = Math.sqrt(dx * dx + (line.y2 - line.y1) ** 2)
|
||||
result.push({ xMin, xMax, yMid, length })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Find pairs of horizontal lines that could be top/bottom of a hoarding */
|
||||
function findLinePairs(
|
||||
horizontals: HLine[],
|
||||
minDistance: number,
|
||||
maxDistance: number,
|
||||
): [HLine, HLine][] {
|
||||
const sorted = [...horizontals].sort((a, b) => a.yMid - b.yMid)
|
||||
const pairs: [HLine, HLine][] = []
|
||||
const used = new Set<number>()
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (used.has(i)) continue
|
||||
const top = sorted[i]
|
||||
|
||||
for (let j = i + 1; j < sorted.length; j++) {
|
||||
if (used.has(j)) continue
|
||||
const bottom = sorted[j]
|
||||
const yGap = bottom.yMid - top.yMid
|
||||
|
||||
if (yGap < minDistance) continue
|
||||
if (yGap > maxDistance) break
|
||||
|
||||
// Check horizontal overlap (50% of shorter line)
|
||||
const overlapStart = Math.max(top.xMin, bottom.xMin)
|
||||
const overlapEnd = Math.min(top.xMax, bottom.xMax)
|
||||
const overlap = overlapEnd - overlapStart
|
||||
const shorterLength = Math.min(top.xMax - top.xMin, bottom.xMax - bottom.xMin)
|
||||
|
||||
if (shorterLength > 0 && overlap / shorterLength >= 0.5) {
|
||||
pairs.push([top, bottom])
|
||||
used.add(i)
|
||||
used.add(j)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
/** Convert a line pair to a bounding box */
|
||||
function pairToBox(
|
||||
top: HLine,
|
||||
bottom: HLine,
|
||||
frameWidth: number,
|
||||
frameHeight: number,
|
||||
): EdgeRegion | null {
|
||||
const x = Math.max(0, Math.min(top.xMin, bottom.xMin))
|
||||
const y = Math.max(0, top.yMid)
|
||||
const x2 = Math.min(frameWidth, Math.max(top.xMax, bottom.xMax))
|
||||
const y2 = Math.min(frameHeight, bottom.yMid)
|
||||
const w = x2 - x
|
||||
const h = y2 - y
|
||||
|
||||
if (w < 20 || h < 5) return null
|
||||
|
||||
const avgLineLength = (top.length + bottom.length) / 2
|
||||
const coverage = Math.min(1.0, avgLineLength / Math.max(w, 1))
|
||||
|
||||
return {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
w: Math.round(w),
|
||||
h: Math.round(h),
|
||||
confidence: Math.round(coverage * 1000) / 1000,
|
||||
label: 'edge_region',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect edges in an RGBA ImageData.
|
||||
*
|
||||
* Equivalent to gpu/models/cv/edges.py detect_edges()
|
||||
*/
|
||||
export function detectEdges(
|
||||
imageData: ImageData,
|
||||
params: Partial<EdgeDetectionParams> = {},
|
||||
): EdgeDetectionResult {
|
||||
const p = { ...DEFAULT_PARAMS, ...params }
|
||||
const { width, height } = imageData
|
||||
|
||||
const gray = toGrayscale(imageData.data, width, height)
|
||||
const edges = canny(gray, width, height, p.cannyLow, p.cannyHigh)
|
||||
|
||||
const rawLines = houghLinesP(edges, width, height, p.houghThreshold, p.houghMinLength, p.houghMaxGap)
|
||||
const horizontals = filterHorizontal(rawLines)
|
||||
|
||||
if (horizontals.length < 2) return { regions: [] }
|
||||
|
||||
const pairs = findLinePairs(horizontals, p.pairMinDistance, p.pairMaxDistance)
|
||||
const regions: EdgeRegion[] = []
|
||||
for (const [top, bottom] of pairs) {
|
||||
const box = pairToBox(top, bottom, width, height)
|
||||
if (box) regions.push(box)
|
||||
}
|
||||
|
||||
return { regions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect edges with debug visualizations.
|
||||
*
|
||||
* Equivalent to gpu/models/cv/edges.py detect_edges_debug()
|
||||
*/
|
||||
export function detectEdgesDebug(
|
||||
imageData: ImageData,
|
||||
params: Partial<EdgeDetectionParams> = {},
|
||||
): EdgeDetectionDebugResult {
|
||||
const p = { ...DEFAULT_PARAMS, ...params }
|
||||
const { width, height, data } = imageData
|
||||
|
||||
const gray = toGrayscale(data, width, height)
|
||||
const edges = canny(gray, width, height, p.cannyLow, p.cannyHigh)
|
||||
|
||||
// Edge overlay — white edges on black
|
||||
const edgeImageData = new ImageData(width, height)
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const px = i * 4
|
||||
edgeImageData.data[px] = edges[i]
|
||||
edgeImageData.data[px + 1] = edges[i]
|
||||
edgeImageData.data[px + 2] = edges[i]
|
||||
edgeImageData.data[px + 3] = 255
|
||||
}
|
||||
|
||||
const rawLines = houghLinesP(edges, width, height, p.houghThreshold, p.houghMinLength, p.houghMaxGap)
|
||||
const horizontals = filterHorizontal(rawLines)
|
||||
|
||||
// Lines overlay — darken original frame so lines pop, then draw
|
||||
const linesImageData = new ImageData(new Uint8ClampedArray(data), width, height)
|
||||
for (let i = 0; i < linesImageData.data.length; i += 4) {
|
||||
linesImageData.data[i] = Math.round(linesImageData.data[i] * 0.3)
|
||||
linesImageData.data[i + 1] = Math.round(linesImageData.data[i + 1] * 0.3)
|
||||
linesImageData.data[i + 2] = Math.round(linesImageData.data[i + 2] * 0.3)
|
||||
}
|
||||
|
||||
// Draw all Hough lines in red (3px thick)
|
||||
for (const line of rawLines) {
|
||||
drawLineThick(linesImageData, line.x1, line.y1, line.x2, line.y2, 255, 50, 50, 2)
|
||||
}
|
||||
|
||||
// Draw horizontal lines in cyan (3px thick)
|
||||
for (const h of horizontals) {
|
||||
drawLineThick(linesImageData, Math.round(h.xMin), Math.round(h.yMid), Math.round(h.xMax), Math.round(h.yMid), 0, 255, 255, 3)
|
||||
}
|
||||
|
||||
const pairs = horizontals.length >= 2
|
||||
? findLinePairs(horizontals, p.pairMinDistance, p.pairMaxDistance)
|
||||
: []
|
||||
|
||||
// Draw paired lines in bright green (4px thick)
|
||||
for (const [top, bottom] of pairs) {
|
||||
drawLineThick(linesImageData, Math.round(top.xMin), Math.round(top.yMid), Math.round(top.xMax), Math.round(top.yMid), 0, 255, 0, 4)
|
||||
drawLineThick(linesImageData, Math.round(bottom.xMin), Math.round(bottom.yMid), Math.round(bottom.xMax), Math.round(bottom.yMid), 0, 255, 0, 4)
|
||||
}
|
||||
|
||||
const regions: EdgeRegion[] = []
|
||||
for (const [top, bottom] of pairs) {
|
||||
const box = pairToBox(top, bottom, width, height)
|
||||
if (box) regions.push(box)
|
||||
}
|
||||
|
||||
return {
|
||||
regions,
|
||||
edgeImageData,
|
||||
linesImageData,
|
||||
horizontalCount: horizontals.length,
|
||||
pairCount: pairs.length,
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,77 @@
|
||||
/**
|
||||
* Browser-side CV — public API.
|
||||
*
|
||||
* Runs edge detection directly on the main thread.
|
||||
* Pure TypeScript, no WASM, no dependencies.
|
||||
* ~10-50ms per 1080p frame — fast enough for slider feedback.
|
||||
* Three execution backends for edge detection:
|
||||
* - TS: pure TypeScript (always available, ~10-50ms per 1080p frame)
|
||||
* - WASM: OpenCV.js via WebAssembly (async load, same perf, same API as GPU server)
|
||||
* - Server: GPU box over HTTP
|
||||
*
|
||||
* TODO: Move to Web Worker when processing larger batches.
|
||||
*
|
||||
* Usage:
|
||||
* import { runEdgeDetection, runEdgeDetectionDebug } from '@/cv'
|
||||
* const result = await runEdgeDetection(imageData, params)
|
||||
* Field segmentation only has WASM + Server (no TS port).
|
||||
*/
|
||||
|
||||
import { detectEdges, detectEdgesDebug, type EdgeRegion, type EdgeDetectionParams } from './edges'
|
||||
// WASM versions (edges + segmentation)
|
||||
import { detectEdges, detectEdgesDebug } from './edges'
|
||||
import { segmentField, segmentFieldDebug } from './segmentation'
|
||||
|
||||
export type { EdgeRegion, EdgeDetectionParams } from './edges'
|
||||
export type { EdgeDetectionResult, EdgeDetectionDebugResult } from './edges'
|
||||
// Pure TS versions (edges only — the original fallback)
|
||||
import { detectEdges as detectEdgesTs, detectEdgesDebug as detectEdgesTsDebug } from './edgesTs'
|
||||
|
||||
export type { EdgeRegion, EdgeDetectionParams } from './edgesTs'
|
||||
export type { EdgeDetectionResult, EdgeDetectionDebugResult } from './edgesTs'
|
||||
export type { SegmentationParams, SegmentationResult, SegmentationDebugResult } from './segmentation'
|
||||
export { matchTracks, renderTracksToImageData } from './tracks'
|
||||
export type { Track, TrackPoint } from './tracks'
|
||||
|
||||
/** Run edge detection. Returns bounding boxes. */
|
||||
// --- Edge detection: pure TS (always works, no async load) ---
|
||||
|
||||
export function runEdgeDetectionTs(
|
||||
imageData: ImageData,
|
||||
params: Partial<import('./edgesTs').EdgeDetectionParams> = {},
|
||||
) {
|
||||
return detectEdgesTs(imageData, params)
|
||||
}
|
||||
|
||||
export function runEdgeDetectionTsDebug(
|
||||
imageData: ImageData,
|
||||
params: Partial<import('./edgesTs').EdgeDetectionParams> = {},
|
||||
) {
|
||||
return detectEdgesTsDebug(imageData, params)
|
||||
}
|
||||
|
||||
// --- Edge detection: WASM (needs async opencv load) ---
|
||||
|
||||
export async function runEdgeDetection(
|
||||
imageData: ImageData,
|
||||
params: Partial<EdgeDetectionParams> = {},
|
||||
): Promise<{ regions: EdgeRegion[] }> {
|
||||
params: Partial<import('./edges').EdgeDetectionParams> = {},
|
||||
) {
|
||||
return detectEdges(imageData, params)
|
||||
}
|
||||
|
||||
/** Run edge detection with debug overlays. Returns boxes + visualization ImageData. */
|
||||
export async function runEdgeDetectionDebug(
|
||||
imageData: ImageData,
|
||||
params: Partial<EdgeDetectionParams> = {},
|
||||
): Promise<{
|
||||
regions: EdgeRegion[]
|
||||
edgeImageData: ImageData
|
||||
linesImageData: ImageData
|
||||
horizontalCount: number
|
||||
pairCount: number
|
||||
}> {
|
||||
params: Partial<import('./edges').EdgeDetectionParams> = {},
|
||||
) {
|
||||
return detectEdgesDebug(imageData, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 JPEG string to ImageData.
|
||||
*
|
||||
* Used to convert the checkpoint frame (base64) into ImageData
|
||||
* that the CV functions can process.
|
||||
*/
|
||||
// --- Segmentation: WASM only ---
|
||||
|
||||
export async function runSegmentation(
|
||||
imageData: ImageData,
|
||||
params: Partial<import('./segmentation').SegmentationParams> = {},
|
||||
) {
|
||||
return segmentField(imageData, params)
|
||||
}
|
||||
|
||||
export async function runSegmentationDebug(
|
||||
imageData: ImageData,
|
||||
params: Partial<import('./segmentation').SegmentationParams> = {},
|
||||
) {
|
||||
return segmentFieldDebug(imageData, params)
|
||||
}
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
export function b64ToImageData(b64: string): Promise<ImageData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
@@ -61,12 +86,6 @@ export function b64ToImageData(b64: string): Promise<ImageData> {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode ImageData to base64 PNG string (preserves transparency).
|
||||
*
|
||||
* Used for overlays that need a transparent background (e.g. motion tracks).
|
||||
* Pair with srcFormat: 'png' on the FrameOverlay.
|
||||
*/
|
||||
export async function imageDataToPngB64(imageData: ImageData): Promise<string> {
|
||||
const canvas = new OffscreenCanvas(imageData.width, imageData.height)
|
||||
const ctx = canvas.getContext('2d')!
|
||||
@@ -81,12 +100,6 @@ export async function imageDataToPngB64(imageData: ImageData): Promise<string> {
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode ImageData to base64 JPEG string.
|
||||
*
|
||||
* Used to convert debug overlay ImageData back to base64
|
||||
* for the FrameRenderer overlays prop.
|
||||
*/
|
||||
export async function imageDataToB64(imageData: ImageData): Promise<string> {
|
||||
const canvas = new OffscreenCanvas(imageData.width, imageData.height)
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
84
ui/detection-app/src/cv/opencv.ts
Normal file
84
ui/detection-app/src/cv/opencv.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* OpenCV WASM loader — split build.
|
||||
*
|
||||
* public/opencv.js — 146KB JS loader (stripped of embedded WASM)
|
||||
* public/opencv_js.wasm — 3.1MB WASM binary (custom build: imgproc + core only)
|
||||
*
|
||||
* The JS parses instantly. The WASM compiles asynchronously in the
|
||||
* background — no main thread freeze.
|
||||
*/
|
||||
|
||||
let cvInstance: any = null
|
||||
let cvPromise: Promise<any | null> | null = null
|
||||
let loadFailed = false
|
||||
let loading = false
|
||||
|
||||
export async function getCV(): Promise<any> {
|
||||
if (cvInstance) return cvInstance
|
||||
if (loadFailed) return null
|
||||
|
||||
if (!cvPromise) {
|
||||
cvPromise = loadCV()
|
||||
}
|
||||
|
||||
return cvPromise
|
||||
}
|
||||
|
||||
export function isCVLoaded(): boolean {
|
||||
return cvInstance != null
|
||||
}
|
||||
|
||||
export function isCVLoading(): boolean {
|
||||
return loading
|
||||
}
|
||||
|
||||
export function isCVFailed(): boolean {
|
||||
return loadFailed
|
||||
}
|
||||
|
||||
async function loadCV(): Promise<any | null> {
|
||||
loading = true
|
||||
try {
|
||||
const base = import.meta.env.BASE_URL ?? '/'
|
||||
|
||||
// Module config must exist before script loads — the UMD wrapper reads it
|
||||
;(globalThis as any).Module = {
|
||||
locateFile: (path: string) => `${base}${path}`,
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = `${base}opencv.js`
|
||||
script.async = true
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('opencv.js load timed out'))
|
||||
}, 10000)
|
||||
|
||||
script.onload = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
script.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('Failed to load opencv.js'))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
// OpenCV 4.12 UMD wrapper calls the factory and assigns the readyPromise to window.cv
|
||||
const cvReady = (globalThis as any).cv
|
||||
if (!cvReady) throw new Error('cv not on globalThis after script load')
|
||||
|
||||
cvInstance = await cvReady
|
||||
|
||||
loading = false
|
||||
return cvInstance
|
||||
} catch (e) {
|
||||
loading = false
|
||||
loadFailed = true
|
||||
cvPromise = null
|
||||
console.warn('[opencv] Load failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
212
ui/detection-app/src/cv/segmentation.ts
Normal file
212
ui/detection-app/src/cv/segmentation.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Field segmentation — OpenCV WASM version.
|
||||
*
|
||||
* 1:1 with gpu/models/cv/segmentation.py. HSV green mask +
|
||||
* morphology + contour → pitch boundary detection.
|
||||
*/
|
||||
|
||||
import { getCV } from './opencv'
|
||||
|
||||
export interface SegmentationParams {
|
||||
hueLow: number
|
||||
hueHigh: number
|
||||
satLow: number
|
||||
satHigh: number
|
||||
valLow: number
|
||||
valHigh: number
|
||||
morphKernel: number
|
||||
minAreaRatio: number
|
||||
}
|
||||
|
||||
export interface SegmentationResult {
|
||||
boundary: [number, number][]
|
||||
coverage: number
|
||||
maskImageData: ImageData
|
||||
}
|
||||
|
||||
export interface SegmentationDebugResult extends SegmentationResult {
|
||||
overlayImageData: ImageData
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS: SegmentationParams = {
|
||||
hueLow: 30,
|
||||
hueHigh: 85,
|
||||
satLow: 30,
|
||||
satHigh: 255,
|
||||
valLow: 30,
|
||||
valHigh: 255,
|
||||
morphKernel: 15,
|
||||
minAreaRatio: 0.05,
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the pitch area using HSV green thresholding.
|
||||
*/
|
||||
export async function segmentField(
|
||||
imageData: ImageData,
|
||||
params: Partial<SegmentationParams> = {},
|
||||
): Promise<SegmentationResult> {
|
||||
const cv = await getCV()
|
||||
if (!cv) throw new Error('OpenCV WASM not available')
|
||||
const p = { ...DEFAULT_PARAMS, ...params }
|
||||
const { width, height } = imageData
|
||||
|
||||
const src = cv.matFromImageData(imageData)
|
||||
const rgb = new cv.Mat()
|
||||
const hsv = new cv.Mat()
|
||||
const mask = new cv.Mat()
|
||||
|
||||
try {
|
||||
// ImageData is RGBA, convert to RGB then HSV
|
||||
cv.cvtColor(src, rgb, cv.COLOR_RGBA2RGB)
|
||||
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV)
|
||||
|
||||
const lower = new cv.Mat(1, 1, cv.CV_8UC3, new cv.Scalar(p.hueLow, p.satLow, p.valLow))
|
||||
const upper = new cv.Mat(1, 1, cv.CV_8UC3, new cv.Scalar(p.hueHigh, p.satHigh, p.valHigh))
|
||||
|
||||
try {
|
||||
cv.inRange(hsv, lower, upper, mask)
|
||||
} finally {
|
||||
lower.delete()
|
||||
upper.delete()
|
||||
}
|
||||
|
||||
// Morphology — close then open
|
||||
const k = p.morphKernel
|
||||
const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(k, k))
|
||||
try {
|
||||
cv.morphologyEx(mask, mask, cv.MORPH_CLOSE, kernel)
|
||||
cv.morphologyEx(mask, mask, cv.MORPH_OPEN, kernel)
|
||||
} finally {
|
||||
kernel.delete()
|
||||
}
|
||||
|
||||
// Find contours
|
||||
const contours = new cv.MatVector()
|
||||
const hierarchy = new cv.Mat()
|
||||
try {
|
||||
cv.findContours(mask, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
const minArea = p.minAreaRatio * height * width
|
||||
let boundary: [number, number][] = []
|
||||
let coverage = 0
|
||||
|
||||
// Find the largest contour above min area
|
||||
let bestContour: any = null
|
||||
let bestArea = 0
|
||||
|
||||
for (let i = 0; i < contours.size(); i++) {
|
||||
const c = contours.get(i)
|
||||
const area = cv.contourArea(c)
|
||||
if (area >= minArea && area > bestArea) {
|
||||
bestContour = c
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
|
||||
if (bestContour) {
|
||||
coverage = bestArea / (height * width)
|
||||
|
||||
// Extract boundary points
|
||||
for (let j = 0; j < bestContour.rows; j++) {
|
||||
boundary.push([bestContour.data32S[j * 2], bestContour.data32S[j * 2 + 1]])
|
||||
}
|
||||
|
||||
// Refine mask to just the largest contour
|
||||
mask.setTo(new cv.Scalar(0))
|
||||
const contourVec = new cv.MatVector()
|
||||
contourVec.push_back(bestContour)
|
||||
try {
|
||||
cv.drawContours(mask, contourVec, -1, new cv.Scalar(255), cv.FILLED)
|
||||
} finally {
|
||||
contourVec.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// Convert mask to RGBA ImageData
|
||||
const maskRgba = new cv.Mat()
|
||||
try {
|
||||
cv.cvtColor(mask, maskRgba, cv.COLOR_GRAY2RGBA)
|
||||
const maskImageData = new ImageData(
|
||||
new Uint8ClampedArray(maskRgba.data),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
|
||||
return { boundary, coverage, maskImageData }
|
||||
} finally {
|
||||
maskRgba.delete()
|
||||
}
|
||||
} finally {
|
||||
contours.delete()
|
||||
hierarchy.delete()
|
||||
}
|
||||
} finally {
|
||||
src.delete()
|
||||
rgb.delete()
|
||||
hsv.delete()
|
||||
mask.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as segmentField but includes a blended overlay for the editor.
|
||||
*/
|
||||
export async function segmentFieldDebug(
|
||||
imageData: ImageData,
|
||||
params: Partial<SegmentationParams> = {},
|
||||
): Promise<SegmentationDebugResult> {
|
||||
const cv = await getCV()
|
||||
if (!cv) throw new Error('OpenCV WASM not available')
|
||||
const result = await segmentField(imageData, params)
|
||||
const { width, height } = imageData
|
||||
|
||||
// Build green overlay blended with original frame
|
||||
const src = cv.matFromImageData(imageData)
|
||||
const rgb = new cv.Mat()
|
||||
const overlay = new cv.Mat(height, width, cv.CV_8UC3, new cv.Scalar(0, 0, 0))
|
||||
const maskGray = new cv.Mat()
|
||||
|
||||
try {
|
||||
cv.cvtColor(src, rgb, cv.COLOR_RGBA2RGB)
|
||||
|
||||
// Recreate mask from result maskImageData
|
||||
const maskSrc = cv.matFromImageData(result.maskImageData)
|
||||
try {
|
||||
cv.cvtColor(maskSrc, maskGray, cv.COLOR_RGBA2GRAY)
|
||||
} finally {
|
||||
maskSrc.delete()
|
||||
}
|
||||
|
||||
// Green overlay where mask > 0
|
||||
overlay.setTo(new cv.Scalar(0, 255, 0), maskGray)
|
||||
|
||||
// Blend: 70% original + 30% overlay
|
||||
const blended = new cv.Mat()
|
||||
try {
|
||||
cv.addWeighted(rgb, 0.7, overlay, 0.3, 0, blended)
|
||||
|
||||
// Convert to RGBA ImageData
|
||||
const blendedRgba = new cv.Mat()
|
||||
try {
|
||||
cv.cvtColor(blended, blendedRgba, cv.COLOR_RGB2RGBA)
|
||||
const overlayImageData = new ImageData(
|
||||
new Uint8ClampedArray(blendedRgba.data),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
|
||||
return { ...result, overlayImageData }
|
||||
} finally {
|
||||
blendedRgba.delete()
|
||||
}
|
||||
} finally {
|
||||
blended.delete()
|
||||
}
|
||||
} finally {
|
||||
src.delete()
|
||||
rgb.delete()
|
||||
overlay.delete()
|
||||
maskGray.delete()
|
||||
}
|
||||
}
|
||||
121
ui/detection-app/src/cv/wasmBridge.ts
Normal file
121
ui/detection-app/src/cv/wasmBridge.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* WASM Worker Bridge — runs OpenCV operations in a Web Worker.
|
||||
*
|
||||
* The worker loads opencv.js (~10MB) in its own thread.
|
||||
* Main thread stays responsive during WASM compilation.
|
||||
*
|
||||
* Lazy-creates the worker on first call. Sends ImageData +
|
||||
* params, gets back results via transferable buffers.
|
||||
*/
|
||||
|
||||
import type { EdgeDetectionParams, EdgeDetectionResult, EdgeDetectionDebugResult } from './edges'
|
||||
import type { SegmentationParams, SegmentationDebugResult } from './segmentation'
|
||||
|
||||
let worker: Worker | null = null
|
||||
let messageId = 0
|
||||
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>()
|
||||
let initPromise: Promise<boolean> | null = null
|
||||
let ready = false
|
||||
let failed = false
|
||||
|
||||
function getWorker(): Worker {
|
||||
if (!worker) {
|
||||
worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
|
||||
worker.onmessage = (event) => {
|
||||
const { id, type, error: errMsg, ...data } = event.data
|
||||
const handler = pending.get(id)
|
||||
if (!handler) return
|
||||
pending.delete(id)
|
||||
|
||||
if (type === 'error') {
|
||||
handler.reject(new Error(errMsg ?? 'Worker error'))
|
||||
} else {
|
||||
handler.resolve(data)
|
||||
}
|
||||
}
|
||||
worker.onerror = (event) => {
|
||||
// Reject all pending
|
||||
for (const [, handler] of pending) {
|
||||
handler.reject(new Error(event.message ?? 'Worker crashed'))
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
}
|
||||
return worker
|
||||
}
|
||||
|
||||
function postMessage(type: string, imageData: ImageData, params: Record<string, unknown>): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++messageId
|
||||
pending.set(id, { resolve, reject })
|
||||
getWorker().postMessage({ id, type, imageData, params }, [imageData.data.buffer])
|
||||
})
|
||||
}
|
||||
|
||||
/** Initialize the worker + load WASM. Returns true if ready. */
|
||||
export async function initWasm(): Promise<boolean> {
|
||||
if (ready) return true
|
||||
if (failed) return false
|
||||
if (initPromise) return initPromise
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
// Send a ping — the worker will load opencv.js on first message
|
||||
const result = await postMessage('ping', new ImageData(1, 1), {})
|
||||
ready = true
|
||||
return true
|
||||
} catch {
|
||||
failed = true
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
export function isWasmReady(): boolean {
|
||||
return ready
|
||||
}
|
||||
|
||||
export function isWasmFailed(): boolean {
|
||||
return failed
|
||||
}
|
||||
|
||||
// --- Edge detection ---
|
||||
|
||||
export async function detectEdgesWasm(
|
||||
imageData: ImageData,
|
||||
params: Partial<EdgeDetectionParams>,
|
||||
): Promise<EdgeDetectionResult> {
|
||||
const data = await postMessage('detect_edges', imageData, params as Record<string, unknown>)
|
||||
return { regions: data.regions }
|
||||
}
|
||||
|
||||
export async function detectEdgesWasmDebug(
|
||||
imageData: ImageData,
|
||||
params: Partial<EdgeDetectionParams>,
|
||||
): Promise<EdgeDetectionDebugResult> {
|
||||
const data = await postMessage('detect_edges_debug', imageData, params as Record<string, unknown>)
|
||||
return {
|
||||
regions: data.regions,
|
||||
edgeImageData: data.edgeImageData,
|
||||
linesImageData: data.linesImageData,
|
||||
horizontalCount: data.horizontalCount,
|
||||
pairCount: data.pairCount,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Segmentation ---
|
||||
|
||||
export async function segmentFieldWasmDebug(
|
||||
imageData: ImageData,
|
||||
params: Partial<SegmentationParams>,
|
||||
): Promise<SegmentationDebugResult> {
|
||||
const data = await postMessage('segment_field_debug', imageData, params as Record<string, unknown>)
|
||||
return {
|
||||
boundary: data.boundary,
|
||||
coverage: data.coverage,
|
||||
maskImageData: data.maskImageData,
|
||||
overlayImageData: data.overlayImageData,
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
/**
|
||||
* CV Web Worker — runs edge detection off the main thread.
|
||||
* CV Web Worker — runs CV operations off the main thread.
|
||||
*
|
||||
* Message protocol:
|
||||
* Main → Worker: { type: 'detect_edges', imageData: ImageData, params: {...} }
|
||||
* Main → Worker: { type: 'detect_edges_debug', imageData: ImageData, params: {...} }
|
||||
* Worker → Main: { type: 'result', regions: [...] }
|
||||
* Worker → Main: { type: 'debug_result', regions: [...], edgeImageData, linesImageData, horizontalCount, pairCount }
|
||||
* Worker → Main: { type: 'error', message: string }
|
||||
* Main → Worker: { type: 'detect_edges', imageData, params }
|
||||
* Main → Worker: { type: 'detect_edges_debug', imageData, params }
|
||||
* Main → Worker: { type: 'segment_field', imageData, params }
|
||||
* Main → Worker: { type: 'segment_field_debug', imageData, params }
|
||||
* Worker → Main: { type: 'result', ... }
|
||||
* Worker → Main: { type: 'error', message }
|
||||
*/
|
||||
|
||||
import { detectEdges, detectEdgesDebug, type EdgeDetectionParams } from './edges'
|
||||
import { detectEdges, detectEdgesDebug } from './edges'
|
||||
import { segmentField, segmentFieldDebug } from './segmentation'
|
||||
|
||||
self.onmessage = (event: MessageEvent) => {
|
||||
self.onmessage = async (event: MessageEvent) => {
|
||||
const { type, imageData, params } = event.data
|
||||
|
||||
try {
|
||||
if (type === 'detect_edges') {
|
||||
const result = detectEdges(imageData, params)
|
||||
const result = await detectEdges(imageData, params)
|
||||
self.postMessage({ type: 'result', regions: result.regions })
|
||||
} else if (type === 'detect_edges_debug') {
|
||||
const result = detectEdgesDebug(imageData, params)
|
||||
const result = await detectEdgesDebug(imageData, params)
|
||||
self.postMessage({
|
||||
type: 'debug_result',
|
||||
regions: result.regions,
|
||||
@@ -28,10 +30,31 @@ self.onmessage = (event: MessageEvent) => {
|
||||
horizontalCount: result.horizontalCount,
|
||||
pairCount: result.pairCount,
|
||||
}, [
|
||||
// Transfer ownership of the backing buffers for zero-copy
|
||||
result.edgeImageData.data.buffer,
|
||||
result.linesImageData.data.buffer,
|
||||
])
|
||||
} else if (type === 'segment_field') {
|
||||
const result = await segmentField(imageData, params)
|
||||
self.postMessage({
|
||||
type: 'result',
|
||||
boundary: result.boundary,
|
||||
coverage: result.coverage,
|
||||
maskImageData: result.maskImageData,
|
||||
}, [
|
||||
result.maskImageData.data.buffer,
|
||||
])
|
||||
} else if (type === 'segment_field_debug') {
|
||||
const result = await segmentFieldDebug(imageData, params)
|
||||
self.postMessage({
|
||||
type: 'debug_result',
|
||||
boundary: result.boundary,
|
||||
coverage: result.coverage,
|
||||
maskImageData: result.maskImageData,
|
||||
overlayImageData: result.overlayImageData,
|
||||
}, [
|
||||
result.maskImageData.data.buffer,
|
||||
result.overlayImageData.data.buffer,
|
||||
])
|
||||
} else {
|
||||
self.postMessage({ type: 'error', message: `Unknown message type: ${type}` })
|
||||
}
|
||||
|
||||
@@ -139,3 +139,19 @@ export interface RetryResponse {
|
||||
task_id: string;
|
||||
job_id: string;
|
||||
}
|
||||
|
||||
export interface RunRequest {
|
||||
video_path: string;
|
||||
profile_name: string;
|
||||
source_asset_id: string;
|
||||
checkpoint: boolean;
|
||||
skip_vlm: boolean;
|
||||
skip_cloud: boolean;
|
||||
log_level: string;
|
||||
}
|
||||
|
||||
export interface RunResponse {
|
||||
status: string;
|
||||
job_id: string;
|
||||
video_path: string;
|
||||
}
|
||||
|
||||
1
ui/detection-app/src/vite-env.d.ts
vendored
Normal file
1
ui/detection-app/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user