feat: ASRX hybrid pipeline, identity history, worker fixes, checkpoint system

This commit is contained in:
Accusys
2026-06-02 07:13:23 +08:00
parent e3066c3f49
commit e1572907ae
198 changed files with 43705 additions and 8910 deletions

View File

@@ -0,0 +1,174 @@
<template>
<div ref="container" class="w-full h-full min-h-[300px] bg-gray-900 rounded-lg overflow-hidden"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
const props = defineProps<{
landmarks: number[][] // 468 x [x, y, z]
}>()
const container = ref<HTMLElement>()
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let controls: OrbitControls | null = null
let animId: number
let objects: THREE.Object3D[] = []
function disposeScene() {
cancelAnimationFrame(animId)
for (const obj of objects) {
scene?.remove(obj)
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose()
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose())
else obj.material?.dispose()
}
if (obj instanceof THREE.Points) {
obj.geometry?.dispose()
if (obj.material) obj.material.dispose()
}
}
objects = []
controls?.dispose()
controls = null
if (renderer) {
renderer.dispose()
renderer = null
}
scene = null
camera = null
}
const FACES_TRI = [
// Eyes
[33, 133, 7], [33, 7, 163], [160, 159, 158], [159, 158, 157],
// Nose
[168, 6, 197], [6, 197, 195], [197, 195, 5],
// Mouth outer
[61, 146, 91], [91, 181, 84], [84, 17, 314], [314, 405, 321],
// Mouth inner
[78, 95, 88], [95, 88, 178], [87, 14, 317], [14, 317, 402],
// Jaw
[10, 338, 297], [297, 332, 284], [284, 251, 389],
// Left eye brow
[46, 53, 52], [53, 52, 65],
// Right eye brow
[276, 283, 282], [283, 282, 295],
// Face oval
[10, 338, 297], [297, 332, 284], [284, 251, 389], [389, 356, 454],
[454, 323, 361], [361, 288, 397], [397, 365, 379], [379, 378, 400],
[400, 377, 152], [152, 148, 176], [176, 149, 150], [150, 136, 172],
[172, 58, 132], [132, 93, 234], [234, 127, 162], [162, 21, 54],
[54, 103, 67], [67, 109, 10]
]
function buildMesh(pts: number[][]): THREE.BufferGeometry {
const verts = new Float32Array(pts.length * 3)
for (let i = 0; i < pts.length; i++) {
verts[i * 3] = (pts[i][0] - 0.5) * 2
verts[i * 3 + 1] = -(pts[i][1] - 0.5) * 2
verts[i * 3 + 2] = pts[i][2] * 2
}
const indices: number[] = []
for (const tri of FACES_TRI) {
if (tri.every(i => i < pts.length)) indices.push(...tri)
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3))
geo.setIndex(indices)
geo.computeVertexNormals()
return geo
}
function init() {
if (!container.value) return
// Dispose previous scene if re-initializing
disposeScene()
const rect = container.value.getBoundingClientRect()
const w = rect.width || 400, h = rect.height || 300
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 10)
camera.position.set(0, 0, 2.5)
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(w, h)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
container.value.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.autoRotate = true
controls.autoRotateSpeed = 2
// Mesh
if (props.landmarks?.length) {
const geo = buildMesh(props.landmarks)
const mat = new THREE.MeshPhongMaterial({
color: 0x4488ff,
flatShading: false,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide
})
const mesh = new THREE.Mesh(geo, mat)
scene.add(mesh)
objects.push(mesh)
// Points
const ptGeo = new THREE.BufferGeometry()
ptGeo.setAttribute('position', geo.getAttribute('position')!)
const ptMat = new THREE.PointsMaterial({ color: 0x88bbff, size: 0.008 })
const points = new THREE.Points(ptGeo, ptMat)
scene.add(points)
objects.push(points)
}
// Lights
const ambient = new THREE.AmbientLight(0x404060)
scene.add(ambient)
const dir = new THREE.DirectionalLight(0xffffff, 1)
dir.position.set(1, 1, 1)
scene.add(dir)
const dir2 = new THREE.DirectionalLight(0x8888ff, 0.5)
dir2.position.set(-1, -1, 0.5)
scene.add(dir2)
// Resize observer
const resizeObserver = new ResizeObserver(() => {
if (!container.value || !renderer || !camera) return
const r = container.value.getBoundingClientRect()
const w = r.width || 400, h = r.height || 300
renderer.setSize(w, h)
camera.aspect = w / h
camera.updateProjectionMatrix()
})
resizeObserver.observe(container.value)
;(container.value as any).__resizeObserver = resizeObserver
animate()
}
function animate() {
animId = requestAnimationFrame(animate)
controls?.update()
if (renderer && scene && camera) renderer.render(scene, camera)
}
onMounted(() => init())
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
if ((container.value as any)?.__resizeObserver) {
(container.value as any).__resizeObserver.disconnect()
}
disposeScene()
})
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div class="space-y-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-300">身分泳道圖 V2</h3>
<span class="text-xs text-gray-500">{{ identities.length }} identities</span>
</div>
<div class="relative overflow-x-auto" ref="scrollRef">
<svg :width="svgW" :height="rowH * identities.length + 30" class="block">
<!-- time axis -->
<line x1="80" :y1="rowH * identities.length + 5" :x2="svgW" :y2="rowH * identities.length + 5" stroke="#4b5563" stroke-width="1" />
<g v-for="t in ticks" :key="t">
<line :x1="xPos(t)" :y1="rowH * identities.length + 1" :x2="xPos(t)" :y2="rowH * identities.length + 5" stroke="#6b7280" stroke-width="1" />
<text :x="xPos(t)" :y="rowH * identities.length + 16" fill="#9ca3af" font-size="9" text-anchor="middle">{{ t }}s</text>
</g>
<!-- swimlanes -->
<g v-for="(ident, i) in identities" :key="ident.name">
<text x="4" :y="rowH * i + rowH / 2 + 5" fill="#d1d5db" font-size="11" class="select-none">{{ ident.name }}</text>
<rect x="78" :y="rowH * i + 4" width="2" :height="rowH - 8" fill="#374151" rx="2" />
<rect
v-for="seg in ident.segments" :key="seg.start"
:x="xPos(seg.start)" :y="rowH * i + 6"
:width="Math.max(2, xPos(seg.end) - xPos(seg.start))"
:height="rowH - 12"
:fill="ident.color"
:opacity="0.7"
rx="3"
class="cursor-pointer hover:opacity-100"
@click="$emit('selectTrace', seg.trace_id)"
/>
</g>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
identities: SwimlaneIdentity[]
totalDuration: number
}>()
defineEmits<{ selectTrace: [traceId: number] }>()
export interface SwimlaneSegment {
trace_id: number
start: number
end: number
face_count: number
}
export interface SwimlaneIdentity {
name: string
color: string
segments: SwimlaneSegment[]
}
const rowH = 28
const labelW = 80
const padR = 20
const svgW = computed(() => {
const dur = props.totalDuration || 6000
return Math.max(500, labelW + dur / 8)
})
function xPos(sec: number): number {
const dur = props.totalDuration || 6000
return labelW + (sec / dur) * (svgW.value - labelW - padR)
}
const ticks = computed(() => {
const dur = props.totalDuration || 6000
const step = Math.max(30, Math.round(dur / 6 / 30) * 30)
const tks: number[] = []
for (let t = 0; t <= dur; t += step) tks.push(t)
return tks
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div :class="['rounded-lg p-3 border', bgColor]">
<div class="flex items-center justify-between">
<span class="font-semibold">{{ name }}</span>
<span :class="statusColor">{{ status === 'ok' ? '●' : '○' }}</span>
</div>
<div v-if="latency" class="text-xs text-gray-400 mt-1">{{ latency }}ms</div>
<div v-if="error" class="text-xs text-red-400 mt-1 truncate">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
name?: string
status?: string
latency?: number | null
error?: string | null
}>()
const statusColor = computed(() => {
if (props.status === 'ok') return 'text-green-400'
if (props.status === 'degraded') return 'text-yellow-400'
return 'text-red-400'
})
const bgColor = computed(() => {
if (props.status === 'ok') return 'bg-green-900/20 border-green-700'
if (props.status === 'degraded') return 'bg-yellow-900/20 border-yellow-700'
return 'bg-red-900/20 border-red-700'
})
</script>

View File

@@ -0,0 +1,354 @@
<template>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-lg font-semibold mb-4 text-blue-400">V5: 3D Space-Time Cube</h3>
<div class="text-xs text-gray-500 mb-3 flex gap-2 items-center">
<span>X/Y = 畫面位置</span>
<span>Z = 深度bbox 大小</span>
<span>T = 時間</span>
</div>
<!-- Trace selector -->
<div class="flex gap-2 mb-3">
<select v-model="selectedTraceId"
class="bg-gray-700 text-white px-3 py-1.5 rounded text-sm flex-1">
<option :value="null" disabled>選擇 Trace</option>
<option v-for="t in traceOptions" :key="t.id"
:value="t.id">{{ t.label }}</option>
</select>
<button @click="loadData"
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 rounded text-sm"
:disabled="!selectedTraceId || loading">
{{ loading ? '載入中...' : '載入' }}
</button>
</div>
<div ref="container" class="w-full h-[400px] bg-gray-900 rounded-lg overflow-hidden"></div>
<div class="text-xs text-gray-500 mt-2 flex gap-4">
<span>🖱 拖曳旋轉</span>
<span>🔍 滾輪縮放</span>
<span v-if="faceCount">{{ faceCount }} 個檢測點</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { httpFetch, getCurrentConfig } from '@/api/client'
const props = defineProps<{
fileUuid: string
traces?: any[]
frameWidth?: number
frameHeight?: number
}>()
const container = ref<HTMLElement>()
const selectedTraceId = ref<number | null>(null)
const loading = ref(false)
const faceCount = ref(0)
const traceOptions = computed(() => {
return (props.traces || []).map((t: any) => ({
id: t.trace_id,
label: `#${t.trace_id} (${t.face_count} faces, ${(t.duration_sec || 0).toFixed(1)}s)`
}))
})
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let controls: OrbitControls | null = null
let animId: number
let objects: THREE.Object3D[] = []
function disposeScene() {
cancelAnimationFrame(animId)
for (const obj of objects) {
scene?.remove(obj)
if (obj instanceof THREE.Mesh || obj instanceof THREE.Points || obj instanceof THREE.Line) {
obj.geometry?.dispose()
const mat = (obj as any).material
if (mat) {
if (Array.isArray(mat)) mat.forEach((m: any) => m.dispose())
else mat.dispose()
}
}
}
objects = []
controls?.dispose()
controls = null
if (renderer) {
renderer.dispose()
renderer = null
}
scene = null
camera = null
}
type FacePoint = {
frame: number
t: number
x: number
y: number
w: number
h: number
z: number
}
function loadData() {
if (!selectedTraceId.value) return
loading.value = true
const config = getCurrentConfig()
httpFetch(`${config.api_base_url}/api/v1/file/${props.fileUuid}/trace/${selectedTraceId.value}/faces?interpolate=true&limit=2000&dimension=3d`)
.then((res: any) => {
const faces = res?.faces || []
const fw = props.frameWidth || 1920
const fh = props.frameHeight || 1080
const points: FacePoint[] = faces.map((f: any) => {
const w = f.width || 1
const h = f.height || 1
const areaPct = (w * h) / (fw * fh)
const z = f.z_rel !== undefined && f.z_rel !== null
? f.z_rel
: 1.0 - Math.min(areaPct * 50, 1.0)
return {
frame: f.start_frame || 0,
t: f.start_time || 0,
x: f.x || 0,
y: f.y || 0,
w,
h,
z
}
})
faceCount.value = points.length
buildScene(points)
})
.catch((err: any) => {
console.error('Failed to load trace faces:', err)
})
.finally(() => {
loading.value = false
})
}
function buildScene(points: FacePoint[]) {
if (!container.value) return
disposeScene()
// Normalize coordinates to [-1, 1] range
const fw = props.frameWidth || 1920
const fh = props.frameHeight || 1080
const maxT = points.length > 0 ? points[points.length - 1].t : 100
const vertexData = points.map(p => ({
x: (p.x / fw) * 2 - 1,
y: -((p.y / fh) * 2 - 1),
z: p.z * 2 - 1,
t: (p.t / maxT) * 2 - 1
}))
const rect = container.value.getBoundingClientRect()
const w = rect.width || 600, h = rect.height || 400
scene = new THREE.Scene()
scene.background = new THREE.Color(0x111827)
camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 10)
camera.position.set(2.5, 1.8, 3)
camera.lookAt(0, 0, 0)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(w, h)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
container.value.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.08
controls.target.set(0, 0, 0)
controls.update()
// ---- Axes helper with labels ----
const axesLen = 1.2
const axesMat = (color: number) => new THREE.LineBasicMaterial({ color })
// X axis (red) — screen x
const xLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(axesLen, -axesLen, -axesLen)
]),
axesMat(0xff4444)
)
scene.add(xLine)
objects.push(xLine)
// Y axis (green) — screen y
const yLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(-axesLen, axesLen, -axesLen)
]),
axesMat(0x44ff44)
)
scene.add(yLine)
objects.push(yLine)
// Z axis (blue) — depth
const zLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(-axesLen, -axesLen, axesLen)
]),
axesMat(0x4488ff)
)
scene.add(zLine)
objects.push(zLine)
// T axis (yellow) — time (at an angle for 3D effect)
const tLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(axesLen, axesLen, axesLen)
]),
axesMat(0xffdd44)
)
scene.add(tLine)
objects.push(tLine)
// ---- Cube wireframe ----
const cubeSize = axesLen * 2
const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize)
const cubeWire = new THREE.LineSegments(
new THREE.EdgesGeometry(cubeGeo),
new THREE.LineBasicMaterial({ color: 0x444466, transparent: true, opacity: 0.3 })
)
cubeWire.position.set(0, 0, 0)
scene.add(cubeWire)
objects.push(cubeWire)
// ---- Points: color by time (t) ----
if (vertexData.length > 0) {
const positions = new Float32Array(vertexData.length * 3)
const colors = new Float32Array(vertexData.length * 3)
const color = new THREE.Color()
for (let i = 0; i < vertexData.length; i++) {
const p = vertexData[i]
// Position: (x, y, z) with time as movement along diagonal
positions[i * 3] = p.x
positions[i * 3 + 1] = p.y
positions[i * 3 + 2] = p.t * 0.5 // compress time a bit
// Color gradient: blue (early) → cyan → yellow → red (late)
const tNorm = (p.t + 1) / 2 // 0..1
color.setHSL(0.6 - tNorm * 0.6, 0.9, 0.5)
colors[i * 3] = color.r
colors[i * 3 + 1] = color.g
colors[i * 3 + 2] = color.b
}
const ptGeo = new THREE.BufferGeometry()
ptGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
ptGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3))
const ptMat = new THREE.PointsMaterial({
size: 0.03,
vertexColors: true,
transparent: true,
opacity: 0.8,
sizeAttenuation: true
})
const pointsObj = new THREE.Points(ptGeo, ptMat)
scene.add(pointsObj)
objects.push(pointsObj)
// ---- Trajectory line ----
const linePositions = new Float32Array(vertexData.length * 3)
for (let i = 0; i < vertexData.length; i++) {
const p = vertexData[i]
linePositions[i * 3] = p.x
linePositions[i * 3 + 1] = p.y
linePositions[i * 3 + 2] = p.t * 0.5
}
const lineGeo = new THREE.BufferGeometry()
lineGeo.setAttribute('position', new THREE.BufferAttribute(linePositions, 3))
const lineMat = new THREE.LineBasicMaterial({
color: 0x88ccff,
transparent: true,
opacity: 0.35
})
const line = new THREE.Line(lineGeo, lineMat)
scene.add(line)
objects.push(line)
}
// ---- Lights ----
const ambient = new THREE.AmbientLight(0x404060)
scene.add(ambient)
const dir = new THREE.DirectionalLight(0xffffff, 0.8)
dir.position.set(1, 2, 1)
scene.add(dir)
// ---- Grid helper (subtle) ----
const gridHelper = new THREE.GridHelper(2.5, 10, 0x444466, 0x333355)
gridHelper.position.y = -axesLen - 0.05
scene.add(gridHelper)
objects.push(gridHelper)
// Resize
const resizeObserver = new ResizeObserver(() => {
if (!container.value || !renderer || !camera) return
const r = container.value.getBoundingClientRect()
const rw = r.width || 600, rh = r.height || 400
renderer.setSize(rw, rh)
camera.aspect = rw / rh
camera.updateProjectionMatrix()
})
resizeObserver.observe(container.value)
;(container.value as any).__resizeObserver = resizeObserver
animate()
// Notify demo runner via callback URL if present
const cb = new URLSearchParams(window.location.search).get("_callback")
if (cb) {
fetch(cb, { mode: "no-cors" }).catch(() => {})
}
}
function animate() {
animId = requestAnimationFrame(animate)
controls?.update()
if (renderer && scene && camera) renderer.render(scene, camera)
}
onMounted(() => {
tryAutoLoad()
})
watch(() => props.traces, () => {
tryAutoLoad()
}, { deep: false })
function tryAutoLoad() {
if (props.traces?.length && !selectedTraceId.value && !loading.value) {
selectedTraceId.value = props.traces[0].trace_id
loadData()
}
}
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
if ((container.value as any)?.__resizeObserver) {
(container.value as any).__resizeObserver.disconnect()
}
disposeScene()
})
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="space-y-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-300">持續時間分布圖 V3</h3>
<span class="text-xs text-gray-500">{{ traces.length }} traces</span>
</div>
<div class="bg-gray-900 rounded p-4" v-if="bars.length">
<svg :width="barW * bars.length + 4" :height="maxH + 30" class="block">
<g v-for="(b, i) in bars" :key="i">
<rect :x="i * barW + 2" :y="maxH - b.h" :width="barW - 4" :height="b.h" fill="#4488ff" :opacity="0.6 + 0.4 * b.r" rx="2" />
<text :x="i * barW + barW / 2" :y="maxH - b.h - 4" fill="#9ca3af" font-size="9" text-anchor="middle" v-if="b.c > 0">{{ b.c }}</text>
<text :x="i * barW + barW / 2" :y="maxH + 16" fill="#6b7280" font-size="8" text-anchor="middle">{{ b.l }}</text>
</g>
</svg>
</div>
<div v-else class="text-center text-gray-500 text-sm py-8">no data</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ traces: any[] }>()
const barW = 44
const maxBars = 20
const maxH = 200
const bars = computed(() => {
const d = props.traces.map((t: any) => t.duration_sec).filter((x: number) => x > 0)
if (!d.length) return []
const mx = Math.ceil(Math.max(...d))
const st = Math.max(1, Math.ceil(mx / maxBars))
const bins: { l: string; c: number; r: number; h: number }[] = []
for (let s = 0; s <= mx; s += st) {
bins.push({ l: `${s}-${s + st}s`, c: d.filter((x: number) => x >= s && x < s + st).length, r: 0, h: 0 })
}
const mc = Math.max(...bins.map(b => b.c), 1)
bins.forEach(b => { b.r = b.c / mc; b.h = Math.max(4, b.r * maxH) })
return bins
})
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="space-y-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-300">相似度矩陣 V4</h3>
<span class="text-xs text-gray-500">{{ traces.length }} traces</span>
</div>
<div class="bg-gray-900 rounded p-4 overflow-x-auto" v-if="matrix.length">
<svg :width="cellS * matrix.length + 60" :height="cellS * matrix.length + 40" class="block">
<!-- labels -->
<text v-for="(_, i) in matrix" :key="'l'+i"
:x="cellS * i + cellS / 2 + 50" :y="14" fill="#9ca3af" font-size="7" text-anchor="end"
transform="rotate(-60, 10, 10)">{{ traces[i]?.trace_id }}</text>
<text v-for="(_, i) in matrix" :key="'r'+i"
:x="44" :y="cellS * i + cellS / 2 + 24" fill="#9ca3af" font-size="7">{{ traces[i]?.trace_id }}</text>
<!-- cells -->
<g v-for="(row, i) in matrix" :key="i">
<rect v-for="(v, j) in row" :key="j"
:x="cellS * j + 50" :y="cellS * i + 20"
:width="cellS" :height="cellS"
:fill="color(v)" stroke="#374151" stroke-width="0.5" />
</g>
</svg>
</div>
<div v-else class="text-center text-gray-500 text-sm py-8">no data</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ traces: any[] }>()
const cellS = 14
const maxN = 40
const matrix = computed(() => {
const t = props.traces.slice(0, maxN)
if (t.length < 2) return []
const durs = t.map(x => x.duration_sec || 0)
const cnt = t.map(x => x.face_count || 0)
const maxDur = Math.max(...durs, 1)
const maxCnt = Math.max(...cnt, 1)
const m: number[][] = []
for (let i = 0; i < t.length; i++) {
const row: number[] = []
for (let j = 0; j < t.length; j++) {
if (i === j) { row.push(1); continue }
// Simple similarity: duration + face_count proximity
const durSim = 1 - Math.abs(durs[i] - durs[j]) / maxDur
const cntSim = 1 - Math.abs(cnt[i] - cnt[j]) / maxCnt
row.push((durSim + cntSim) / 2)
}
m.push(row)
}
return m
})
function color(v: number): string {
if (v > 0.85) return 'rgba(68, 255, 68, 0.8)' // bright green = similar
if (v > 0.7) return 'rgba(68, 200, 68, 0.6)'
if (v > 0.5) return 'rgba(100, 100, 100, 0.4)'
return 'rgba(40, 40, 50, 0.3)' // dark = dissimilar
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<div class="space-y-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-300">臉孔縮圖時間軸 V1</h3>
<span class="text-xs text-gray-500">{{ traces.length }} traces</span>
</div>
<div class="relative overflow-x-auto py-4" ref="scrollRef">
<svg :width="svgW" :height="80" class="block">
<!-- time axis -->
<line x1="0" y1="70" :x2="svgW" y2="70" stroke="#4b5563" stroke-width="1" />
<!-- time ticks -->
<g v-for="t in ticks" :key="t">
<line :x1="xPos(t)" y1="66" :x2="xPos(t)" y2="70" stroke="#6b7280" stroke-width="1" />
<text :x="xPos(t)" y="78" fill="#9ca3af" font-size="9" text-anchor="middle">{{ t }}s</text>
</g>
<!-- trace thumbnails -->
<g v-for="(tr, idx) in topTraces" :key="tr.trace_id">
<image
:x="thumbX(tr, idx)" :y="thumbY(idx)"
:width="thumbSize" :height="thumbSize"
:href="thumbUrl(tr)"
preserveAspectRatio="xMidYMid slice"
class="cursor-pointer hover:opacity-80"
@click="$emit('select', tr.trace_id)"
/>
<text
:x="thumbX(tr, idx) + thumbSize / 2" :y="thumbY(idx) + thumbSize + 10"
fill="#9ca3af" font-size="8" text-anchor="middle"
>#{{ tr.trace_id }}</text>
</g>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getCurrentConfig } from '@/api/client'
const props = defineProps<{
fileUuid: string
traces: any[]
totalDuration: number
}>()
defineEmits<{ select: [traceId: number] }>()
const config = getCurrentConfig()
const thumbSize = 40
const maxTraces = 15
const timelinePad = 60
const topTraces = computed(() =>
[...props.traces].sort((a, b) => b.face_count - a.face_count).slice(0, maxTraces)
)
const svgW = computed(() => {
const dur = props.totalDuration || 6000
return Math.max(600, timelinePad * 2 + dur / 10)
})
function xPos(sec: number): number {
const dur = props.totalDuration || 6000
return timelinePad + (sec / dur) * (svgW.value - timelinePad * 2)
}
function thumbY(_index: number): number {
return 15 + (_index % 3) * (thumbSize + 8)
}
function thumbX(tr: any, _index: number): number {
return xPos((tr.first_sec + tr.last_sec) / 2) - thumbSize / 2
}
function thumbUrl(tr: any): string {
return `${config.api_base_url}/api/v1/file/${props.fileUuid}/thumbnail?frame=${tr.first_frame}`
}
const ticks = computed(() => {
const dur = props.totalDuration || 6000
const step = Math.max(30, Math.round(dur / 8 / 30) * 30)
const tks: number[] = []
for (let t = 0; t <= dur; t += step) tks.push(t)
return tks
})
</script>