phase 5: edge transforms, soleprint-ui rename, infra fixes
- pipeline edge transforms: stages can declare accepted_transforms, edges carry a transform dict, runner injects per-stage and nodes apply (e.g. invert_mask before edge detection); editable from UI via PUT /config/edge-transform - rename mpr-ui-framework -> soleprint-ui (now an external package synced via .spr from /home/mariano/wdir/spr); add @vue-flow/core and uplot to detection-app so linked package resolves them - Tiltfile guards kubectl context, k8s commands pin --context kind-mpr - kind-config: gateway on hostPort 30080 (Caddy fronts mpr.local.ar) - modelgen: pyproject.toml, .spr marker, dict default_factory support
This commit is contained in:
@@ -11,8 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@techstark/opencv-js": "4.12.0-release.1",
|
||||
"mpr-ui-framework": "link:../framework",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"soleprint-ui": "link:../framework",
|
||||
"pinia": "^2.2",
|
||||
"uplot": "^1.6",
|
||||
"vue": "^3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
6
ui/detection-app/pnpm-lock.yaml
generated
6
ui/detection-app/pnpm-lock.yaml
generated
@@ -11,12 +11,12 @@ importers:
|
||||
'@techstark/opencv-js':
|
||||
specifier: 4.12.0-release.1
|
||||
version: 4.12.0-release.1
|
||||
mpr-ui-framework:
|
||||
specifier: link:../framework
|
||||
version: link:../framework
|
||||
pinia:
|
||||
specifier: ^2.2
|
||||
version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))
|
||||
soleprint-ui:
|
||||
specifier: link:../framework
|
||||
version: link:../framework
|
||||
vue:
|
||||
specifier: ^3.5
|
||||
version: 3.5.30(typescript@5.9.3)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Panel, ResizeHandle, SplitPane } from 'mpr-ui-framework'
|
||||
import 'mpr-ui-framework/src/tokens.css'
|
||||
import { Panel, ResizeHandle, SplitPane } from 'soleprint-ui'
|
||||
import 'soleprint-ui/src/tokens.css'
|
||||
import LogPanel from './panels/LogPanel.vue'
|
||||
import FunnelPanel from './panels/FunnelPanel.vue'
|
||||
import PipelineGraphPanel from './panels/PipelineGraphPanel.vue'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { FrameOverlay } from 'mpr-ui-framework'
|
||||
import type { FrameOverlay } from 'soleprint-ui'
|
||||
|
||||
defineProps<{
|
||||
overlays: FrameOverlay[]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 { ParameterEditor, useEditorExecution } from 'soleprint-ui'
|
||||
import type { ConfigField } from 'soleprint-ui'
|
||||
import {
|
||||
runEdgeDetectionTs,
|
||||
runEdgeDetectionTsDebug,
|
||||
@@ -40,6 +40,9 @@ const emit = defineEmits<{
|
||||
|
||||
const fields = ref<ConfigField[]>([])
|
||||
const values = ref<Record<string, unknown>>({})
|
||||
const transforms = ref<{ key: string; type: string; default: unknown; label: string; description: string }[]>([])
|
||||
const transformValues = ref<Record<string, unknown>>({})
|
||||
const edgeTargets = ref<string[]>([])
|
||||
const statusText = ref<string | null>(null)
|
||||
const debugEnabled = ref(true)
|
||||
const processingIndex = ref<number | null>(null)
|
||||
@@ -159,6 +162,98 @@ const {
|
||||
apply: applyDetection, onParameterChange,
|
||||
} = useEditorExecution(executeDetection)
|
||||
|
||||
/**
|
||||
* Load cached overlays from DB stage output + S3 overlay cache.
|
||||
* Returns true if data was found and emitted, false if nothing cached.
|
||||
*/
|
||||
async function loadCachedOverlays(): Promise<boolean> {
|
||||
if (!props.jobId || !props.frames?.length) return false
|
||||
|
||||
try {
|
||||
// Load job detail to get stage outputs (inline overlays like field masks)
|
||||
const jobResp = await fetch(`/api/detect/jobs/${props.jobId}`)
|
||||
if (!jobResp.ok) return false
|
||||
const jobDetail = await jobResp.json()
|
||||
const stageOutput = jobDetail.stage_outputs?.[props.stage] ?? {}
|
||||
|
||||
// Check for inline overlay data (e.g. mask_overlays_by_frame)
|
||||
const overlays_by_frame: Record<string, Record<string, string>> = {}
|
||||
const regions_by_frame: Record<string, unknown[]> = {}
|
||||
let foundData = false
|
||||
|
||||
// Find box data
|
||||
for (const [key, val] of Object.entries(stageOutput)) {
|
||||
if (key.endsWith('_by_frame') && !key.includes('overlay') && typeof val === 'object') {
|
||||
for (const [seq, boxes] of Object.entries(val as Record<string, unknown>)) {
|
||||
if (Array.isArray(boxes)) {
|
||||
regions_by_frame[seq] = boxes
|
||||
foundData = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find inline overlay data
|
||||
for (const [key, val] of Object.entries(stageOutput)) {
|
||||
if (key.includes('overlay') && key.endsWith('_by_frame') && typeof val === 'object') {
|
||||
for (const [seq, b64] of Object.entries(val as Record<string, string>)) {
|
||||
if (!overlays_by_frame[seq]) overlays_by_frame[seq] = {}
|
||||
const overlayKey = key.replace('s_by_frame', '_b64')
|
||||
overlays_by_frame[seq][overlayKey] = b64
|
||||
foundData = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also try S3 overlay cache for each frame
|
||||
if (!foundData || Object.keys(overlays_by_frame).length === 0) {
|
||||
const { timelineId } = await import('../stores/pipeline').then(m => m.usePipelineStore())
|
||||
if (timelineId) {
|
||||
for (const frame of props.frames) {
|
||||
try {
|
||||
const oResp = await fetch(`/api/detect/overlays/${timelineId}/${props.jobId}/${props.stage}/${frame.seq}`)
|
||||
if (oResp.ok) {
|
||||
const oData = await oResp.json()
|
||||
if (oData.overlays && Object.keys(oData.overlays).length > 0) {
|
||||
overlays_by_frame[String(frame.seq)] = oData.overlays
|
||||
foundData = true
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundData) {
|
||||
emit('replay-result', {
|
||||
regions_by_frame: Object.keys(regions_by_frame).length > 0 ? regions_by_frame : undefined,
|
||||
overlays_by_frame: Object.keys(overlays_by_frame).length > 0 ? overlays_by_frame : undefined,
|
||||
})
|
||||
return true
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function onTransformChange(key: string, value: unknown) {
|
||||
transformValues.value[key] = value
|
||||
// Save to all outgoing edges from this stage
|
||||
const transform = { ...transformValues.value }
|
||||
for (const target of edgeTargets.value) {
|
||||
fetch('/api/detect/config/edge-transform', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile_name: 'soccer_broadcast',
|
||||
source_stage: props.stage,
|
||||
target_stage: target,
|
||||
transform,
|
||||
}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -167,15 +262,49 @@ onMounted(async () => {
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
fields.value = data.config_fields ?? []
|
||||
transforms.value = data.accepted_transforms ?? []
|
||||
}
|
||||
} catch { /* use empty fields */ }
|
||||
|
||||
for (const f of fields.value) {
|
||||
values.value[f.name] = f.default
|
||||
}
|
||||
for (const t of transforms.value) {
|
||||
transformValues.value[t.key] = t.default
|
||||
}
|
||||
|
||||
// Load current edge transform values from pipeline config
|
||||
// Transforms are on outgoing edges (source === this stage)
|
||||
if (transforms.value.length > 0) {
|
||||
try {
|
||||
const pResp = await fetch(`/api/detect/config/profiles/soccer_broadcast/pipeline`)
|
||||
if (pResp.ok) {
|
||||
const pipeline = await pResp.json()
|
||||
const edges = pipeline.edges ?? []
|
||||
// Find outgoing edges from this stage and merge their transforms
|
||||
for (const edge of edges) {
|
||||
if (edge.source === props.stage && edge.transform) {
|
||||
for (const [k, v] of Object.entries(edge.transform)) {
|
||||
if (k in transformValues.value) {
|
||||
transformValues.value[k] = v
|
||||
}
|
||||
}
|
||||
// Remember which targets we're connected to
|
||||
if (!edgeTargets.value.includes(edge.target)) {
|
||||
edgeTargets.value.push(edge.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (props.frameImage) {
|
||||
applyDetection()
|
||||
// Try loading cached overlays first (from stage output or S3)
|
||||
const loaded = await loadCachedOverlays()
|
||||
if (!loaded) {
|
||||
applyDetection()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -220,7 +349,10 @@ function getTargetFrames() {
|
||||
(props.selectionEnd ?? props.frames.length - 1) + 1,
|
||||
)
|
||||
}
|
||||
return [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage! }]
|
||||
if (props.frameImage) {
|
||||
return [{ seq: props.frameRef ?? 0, jpeg_b64: props.frameImage }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function edgeParams(): Partial<EdgeDetectionParams> {
|
||||
@@ -482,6 +614,20 @@ async function runGenericServer() {
|
||||
@update="onFieldUpdate"
|
||||
@reset="resetDefaults"
|
||||
/>
|
||||
|
||||
<div v-if="transforms.length > 0" class="transform-section">
|
||||
<div class="transform-header">Connection</div>
|
||||
<label v-for="t in transforms" :key="t.key" class="transform-option">
|
||||
<input
|
||||
v-if="t.type === 'bool'"
|
||||
type="checkbox"
|
||||
:checked="!!transformValues[t.key]"
|
||||
@change="onTransformChange(t.key, !transformValues[t.key])"
|
||||
/>
|
||||
<span class="transform-label">{{ t.label || t.key }}</span>
|
||||
<span v-if="t.description" class="transform-desc">{{ t.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sliders-footer">
|
||||
@@ -631,4 +777,43 @@ async function runGenericServer() {
|
||||
font-size: 9px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.transform-section {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--surface-3);
|
||||
}
|
||||
|
||||
.transform-header {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.transform-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.transform-option input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.transform-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.transform-desc {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import type { DataSource } from 'soleprint-ui'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
import { useStageRegistry } from './useStageRegistry'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { FrameOverlay, FrameBBox } from 'mpr-ui-framework'
|
||||
import type { FrameOverlay, FrameBBox } from 'soleprint-ui'
|
||||
import type { StageResult } from '@/components/StageConfig.vue'
|
||||
|
||||
export type RegionBox = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { SSEDataSource } from 'mpr-ui-framework'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import { SSEDataSource } from 'soleprint-ui'
|
||||
import type { DataSource } from 'soleprint-ui'
|
||||
import type { StatsUpdate, RunContext } from '../types/sse-contract'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
|
||||
@@ -24,8 +24,12 @@ export function useSSEConnection() {
|
||||
const sseConnected = ref(false)
|
||||
|
||||
// No job selected and no hash route → open source selector
|
||||
if (!jobParam && !window.location.hash.replace(/^#\/?/, '')) {
|
||||
// Job selected but no hash route → go to compare (dashboard is for live runs)
|
||||
const hashPath = window.location.hash.replace(/^#\/?/, '')
|
||||
if (!jobParam && !hashPath) {
|
||||
pipeline.openSourceSelector()
|
||||
} else if (jobParam && !hashPath) {
|
||||
pipeline.openCompare()
|
||||
}
|
||||
|
||||
// Resolve timeline_id from job on init
|
||||
@@ -137,6 +141,10 @@ export function useSSEConnection() {
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
paused.value = data.status === 'paused'
|
||||
// Stop polling if the job is no longer active
|
||||
if (data.status === 'idle') {
|
||||
stopStatusPoll()
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRegistry } from 'mpr-ui-framework'
|
||||
import { useRegistry } from 'soleprint-ui'
|
||||
|
||||
export interface StageConfigField {
|
||||
name: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Panel, TableRenderer } from 'mpr-ui-framework'
|
||||
import type { TableColumn, DataSource } from 'mpr-ui-framework'
|
||||
import { Panel, TableRenderer } from 'soleprint-ui'
|
||||
import type { TableColumn, DataSource } from 'soleprint-ui'
|
||||
|
||||
const props = defineProps<{
|
||||
source: DataSource
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { Panel, SplitPane, FrameRenderer } from 'mpr-ui-framework'
|
||||
import type { FrameBBox, FrameOverlay } from 'mpr-ui-framework'
|
||||
import { Panel, SplitPane, FrameRenderer } from 'soleprint-ui'
|
||||
import type { FrameBBox, FrameOverlay } from 'soleprint-ui'
|
||||
import type { Job, Checkpoint } from '@common/types/generated'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
import { useStageRegistry } from '../composables/useStageRegistry'
|
||||
@@ -222,12 +222,23 @@ function refreshOverlays() {
|
||||
watch([currentSeq, selectedStage], refreshOverlays)
|
||||
|
||||
// Sync overlay visibility/opacity from A controls → B overlays
|
||||
// Also share src if one side has it and the other doesn't
|
||||
watch(overlaysA, (aList) => {
|
||||
for (const a of aList) {
|
||||
const b = overlaysB.value.find(o => o.label === a.label)
|
||||
if (b) {
|
||||
b.visible = a.visible
|
||||
b.opacity = a.opacity
|
||||
if (a.src && !b.src) b.src = a.src
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
watch(overlaysB, (bList) => {
|
||||
for (const b of bList) {
|
||||
const a = overlaysA.value.find(o => o.label === b.label)
|
||||
if (a) {
|
||||
if (b.src && !a.src) a.src = b.src
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import { Panel } from 'soleprint-ui'
|
||||
import type { DataSource } from 'soleprint-ui'
|
||||
import type { StatsUpdate, Detection } from '../types/sse-contract'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Panel, FrameRenderer } from 'mpr-ui-framework'
|
||||
import type { FrameBBox, FrameOverlay, DataSource } from 'mpr-ui-framework'
|
||||
import { Panel, FrameRenderer } from 'soleprint-ui'
|
||||
import type { FrameBBox, FrameOverlay, DataSource } from 'soleprint-ui'
|
||||
|
||||
const props = defineProps<{
|
||||
source: DataSource
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Panel, TimeSeriesRenderer } from 'mpr-ui-framework'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import { Panel, TimeSeriesRenderer } from 'soleprint-ui'
|
||||
import type { DataSource } from 'soleprint-ui'
|
||||
import type { StatsUpdate } from '../types/sse-contract'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Panel, LogRenderer } from 'mpr-ui-framework'
|
||||
import type { LogEntry, DataSource } from 'mpr-ui-framework'
|
||||
import { Panel, LogRenderer } from 'soleprint-ui'
|
||||
import type { LogEntry, DataSource } from 'soleprint-ui'
|
||||
import type { LogEvent } from '../types/sse-contract'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { Panel, GraphRenderer } from 'mpr-ui-framework'
|
||||
import type { GraphNode, GraphMode, DataSource } from 'mpr-ui-framework'
|
||||
import { Panel, GraphRenderer } from 'soleprint-ui'
|
||||
import type { GraphNode, GraphMode, DataSource } from 'soleprint-ui'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
import { useStageRegistry } from '../composables/useStageRegistry'
|
||||
|
||||
@@ -43,20 +43,13 @@ const graphMode = computed<GraphMode>(() => {
|
||||
return 'observe'
|
||||
})
|
||||
|
||||
// Initialize nodes from pipeline config when it loads
|
||||
// Initialize nodes from pipeline config — single source of truth for stage order
|
||||
watch(pipelineConfig, (config) => {
|
||||
if (config && config.stages.length > 0 && nodes.value.length === 0) {
|
||||
nodes.value = config.stages.map((s) => ({ id: s.name, status: 'pending' }))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Fallback: init from registry if no config loaded
|
||||
watch(stageNames, (names) => {
|
||||
if (names.length > 0 && nodes.value.length === 0) {
|
||||
nodes.value = names.map((id) => ({ id, status: 'pending' }))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
props.source.on<{ nodes: GraphNode[] }>('graph_update', (e) => {
|
||||
nodes.value = e.nodes
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import { Panel } from 'soleprint-ui'
|
||||
import { usePipelineStore } from '../stores/pipeline'
|
||||
import type { Timeline, Job } from '@common/types/generated'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Panel } from 'mpr-ui-framework'
|
||||
import type { DataSource } from 'mpr-ui-framework'
|
||||
import { Panel } from 'soleprint-ui'
|
||||
import type { DataSource } from 'soleprint-ui'
|
||||
import type { Detection } from '../types/sse-contract'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -86,9 +86,9 @@ export const usePipelineStore = defineStore('pipeline', () => {
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
// Reload to reinitialize panels with current job context
|
||||
// Go to compare view — the dashboard is for live runs only
|
||||
const url = new URL(window.location.href)
|
||||
url.hash = ''
|
||||
url.hash = '#/compare'
|
||||
if (jobId.value) {
|
||||
url.searchParams.set('job', jobId.value)
|
||||
}
|
||||
|
||||
7
ui/framework/.spr
Normal file
7
ui/framework/.spr
Normal file
@@ -0,0 +1,7 @@
|
||||
name=soleprint-ui
|
||||
version=0.1.0
|
||||
type=npm
|
||||
sha=0822761
|
||||
mode=published
|
||||
updated=2026-04-12T00:22:10Z
|
||||
source=/home/mariano/wdir/spr/soleprint/common/ui
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "mpr-ui-framework",
|
||||
"name": "soleprint-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
1692
ui/framework/pnpm-lock.yaml
generated
1692
ui/framework/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user