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:
2026-04-29 05:31:08 -03:00
parent 55e83e4203
commit 020f3540d3
35 changed files with 414 additions and 1747 deletions

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { FrameOverlay } from 'mpr-ui-framework'
import type { FrameOverlay } from 'soleprint-ui'
defineProps<{
overlays: FrameOverlay[]

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { computed } from 'vue'
import { useRegistry } from 'mpr-ui-framework'
import { useRegistry } from 'soleprint-ui'
export interface StageConfigField {
name: string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
{
"name": "mpr-ui-framework",
"name": "soleprint-ui",
"version": "0.1.0",
"private": true,
"type": "module",

File diff suppressed because it is too large Load Diff