fix: trace debug — show Stranger_NNN for unnamed traces instead of unknown

This commit is contained in:
Accusys
2026-05-14 15:12:21 +08:00
parent 8f013cbdbc
commit c90394897d
42 changed files with 1806 additions and 1722 deletions
+202 -141
View File
@@ -1,9 +1,9 @@
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold">Face Candidates</h2>
<h2 class="text-2xl font-bold">Face Traces</h2>
<button
@click="loadCandidates"
@click="loadTraces"
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition"
>
Refresh
@@ -11,187 +11,248 @@
</div>
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-4 gap-4">
<div>
<label class="text-gray-400 text-sm mb-1">Min Confidence</label>
<input
v-model.number="minConfidence"
@change="loadCandidates"
type="number"
step="0.1"
min="0"
max="1"
<label class="text-gray-400 text-sm mb-1">Filter by File (必選)</label>
<select
v-model="selectedFileUuid"
@change="loadTraces"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
/>
>
<option value="">-- 選擇檔案 --</option>
<option v-for="f in files" :key="f.file_uuid" :value="f.file_uuid">
{{ f.file_name?.substring(0, 50) || f.file_uuid }}
</option>
</select>
</div>
<div>
<label class="text-gray-400 text-sm mb-1">Page Size</label>
<label class="text-gray-400 text-sm mb-1">Sort By</label>
<select
v-model="sortBy"
@change="loadTraces"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
>
<option value="face_count">Face Count</option>
<option value="duration">Duration</option>
<option value="first_appearance">First Appearance</option>
</select>
</div>
<div>
<label class="text-gray-400 text-sm mb-1">Min Faces</label>
<input
v-model.number="pageSize"
@change="loadCandidates"
v-model.number="minFaces"
@change="loadTraces"
type="number"
min="1"
max="100"
max="1000"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
/>
</div>
</div>
</div>
<div v-if="loading" class="text-center py-12 text-gray-500">
Loading...
</div>
<div v-else-if="candidates.length > 0">
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-4">
<div class="text-gray-400">
Showing {{ candidates.length }} of {{ total }} candidates
<span v-if="selectedFaces.length > 0" class="ml-4 text-green-400">
{{ selectedFaces.length }} selected
</span>
<div>
<label class="text-gray-400 text-sm mb-1">Binding Status</label>
<select
v-model="bindingFilter"
@change="loadTraces"
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
>
<option value="all">全部</option>
<option value="registered">已綁定</option>
<option value="unregistered">未綁定</option>
</select>
</div>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div
v-for="face in candidates"
:key="face.id"
@click="toggleSelection(face)"
:class="[
'bg-gray-800 rounded-lg border overflow-hidden cursor-pointer transition',
selectedFaces.includes(face.id) ? 'border-green-500 bg-green-900/20' : 'border-gray-700 hover:border-gray-500'
]"
>
<div class="aspect-square bg-gray-700 flex items-center justify-center overflow-hidden">
<img
:src="getThumbnailUrl(face)"
alt="Face thumbnail"
class="w-full h-full object-cover"
loading="lazy"
@error="onThumbnailError"
/>
<div v-if="!selectedFileUuid" class="text-center py-12 text-gray-500">
請選擇一個檔案來檢視 Face Traces
</div>
<template v-else>
<div v-if="loading && traces.length === 0" class="text-center py-12 text-gray-500">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500 mx-auto mb-4"></div>
<span>Loading...</span>
</div>
<div v-if="traces.length > 0">
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-4 flex justify-between items-center">
<div class="text-gray-400">
Showing {{ paginatedTraces.length }} of {{ traces.length }} traces
</div>
<div class="p-3">
<div class="flex justify-between items-center mb-1">
<span class="text-xs text-gray-400">Conf:</span>
<span class="text-sm font-mono" :class="getConfidenceColor(face.confidence)">
{{ face.confidence.toFixed(2) }}
</span>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500">每頁</span>
<select v-model.number="pageSize" @change="page=1"
class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white text-xs">
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div>
<!-- Pagination top -->
<div v-if="totalPages > 1" class="flex justify-center mb-4 space-x-2">
<button @click="page = 1" :disabled="page === 1"
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">«</button>
<button @click="page--" :disabled="page === 1"
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm"></button>
<span class="text-gray-400 text-sm py-1">{{ page }} / {{ totalPages }}</span>
<button @click="page++" :disabled="page >= totalPages"
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm"></button>
<button @click="page = totalPages" :disabled="page >= totalPages"
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">»</button>
</div>
<!-- Loading overlay during pagination -->
<div v-if="loading" class="text-center py-4 text-gray-500 mb-2">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div
v-for="trace in paginatedTraces"
:key="trace.trace_id"
@click="viewTrace(trace)"
class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden cursor-pointer hover:border-blue-500 transition"
>
<div class="aspect-square bg-gray-700 flex items-center justify-center overflow-hidden">
<img
:src="getThumbnailUrl(trace)"
alt="Trace thumbnail"
class="w-full h-full object-cover"
loading="lazy"
@error="onThumbnailError(trace.trace_id)"
/>
</div>
<div v-if="face.attributes" class="text-xs text-gray-500">
<div v-if="face.attributes.gender">{{ face.attributes.gender }}</div>
<div v-if="face.attributes.age">Age: {{ face.attributes.age }}</div>
<div class="p-3 space-y-1">
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-blue-300">Trace #{{ trace.trace_id }}</div>
<span class="text-xs px-1.5 py-0.5 rounded"
:class="trace.face_count > 5 ? 'bg-green-900 text-green-300' : 'bg-gray-700 text-gray-400'">
{{ trace.face_count > 5 ? '多' : '少' }}
</span>
</div>
<div class="text-xs text-gray-400">{{ trace.face_count }} faces, {{ trace.duration_sec?.toFixed(1) || '?' }}s</div>
<div class="text-xs font-mono" :class="getConfidenceColor(trace.avg_confidence)">
{{ (trace.avg_confidence * 100).toFixed(0) }}%
</div>
</div>
</div>
</div>
<!-- Pagination bottom -->
<div v-if="totalPages > 1" class="flex justify-center mt-6 space-x-2">
<button @click="page = 1" :disabled="page === 1"
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">«</button>
<button @click="page--" :disabled="page === 1"
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm"></button>
<span class="text-gray-400 text-sm py-1">{{ page }} / {{ totalPages }}</span>
<button @click="page++" :disabled="page >= totalPages"
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm"></button>
<button @click="page = totalPages" :disabled="page >= totalPages"
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">»</button>
</div>
</div>
<div v-if="total > pageSize" class="flex justify-center mt-6 space-x-2">
<button
v-if="page > 1"
@click="prevPage"
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded"
>
Previous
</button>
<span class="text-gray-400 py-2">
Page {{ page }} of {{ Math.ceil(total / pageSize) }}
</span>
<button
v-if="page * pageSize < total"
@click="nextPage"
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded"
>
Next
</button>
<div v-if="!loading && traces.length === 0" class="text-center py-12 text-gray-500">
No traces found for this file
</div>
</div>
<div v-else class="text-center py-12 text-gray-500">
No candidates found
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listFaceCandidates, getCurrentConfig } from '@/api/client'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getVideos, getCurrentConfig } from '@/api/client'
interface FaceCandidate {
id: number
face_id: string | null
file_uuid: string
frame_number: number
confidence: number
bbox: any
attributes: any
const router = useRouter()
interface TraceInfo {
trace_id: number
face_count: number
first_frame: number
last_frame: number
first_sec: number
last_sec: number
duration_sec: number
avg_confidence: number
sample_face_id?: string | null
}
const candidates = ref<FaceCandidate[]>([])
const traces = ref<TraceInfo[]>([])
const loading = ref(false)
const total = ref(0)
const totalTraces = ref(0)
const selectedFileUuid = ref('')
const files = ref<any[]>([])
const sortBy = ref('face_count')
const minFaces = ref(1)
const bindingFilter = ref('all')
const page = ref(1)
const pageSize = ref(20)
const minConfidence = ref(0.8)
const selectedFaces = ref<number[]>([])
const pageSize = ref(50)
const getThumbnailUrl = (face: FaceCandidate): string => {
const totalPages = computed(() => Math.max(1, Math.ceil(traces.value.length / pageSize.value)))
const paginatedTraces = computed(() => {
const start = (page.value - 1) * pageSize.value
return traces.value.slice(start, start + pageSize.value)
})
const failedThumbnails = ref<Set<number>>(new Set())
const getThumbnailUrl = (trace: TraceInfo): string => {
const config = getCurrentConfig()
if (!face.bbox) return ''
const b = face.bbox
return `${config.api_base_url}/api/v1/file/${face.file_uuid}/thumbnail?frame=${face.frame_number}&x=${b.x}&y=${b.y}&w=${b.width}&h=${b.height}`
return `${config.api_base_url}/api/v1/file/${selectedFileUuid.value}/thumbnail?frame=${trace.first_frame}`
}
const onThumbnailError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'none'
const parent = img.parentElement
if (parent) {
parent.innerHTML = '<div class="text-center p-4"><div class="text-2xl">👤</div></div>'
}
const onThumbnailError = (traceId: number) => {
failedThumbnails.value = new Set([...failedThumbnails.value, traceId])
}
const loadCandidates = async () => {
const getConfidenceColor = (conf: number): string => {
if (conf >= 0.8) return 'text-green-400'
if (conf >= 0.5) return 'text-yellow-400'
return 'text-red-400'
}
const viewTrace = (trace: TraceInfo) => {
router.push(`/traces/${selectedFileUuid.value}/${trace.trace_id}`)
}
const loadTraces = async () => {
if (!selectedFileUuid.value) return
loading.value = true
page.value = 1
try {
const result = await listFaceCandidates(undefined, minConfidence.value, page.value, pageSize.value)
candidates.value = result.candidates || []
total.value = result.total || 0
} catch (error) {
console.error('Failed to load candidates:', error)
alert('Load failed: ' + error)
const config = getCurrentConfig()
const result = await fetch(
`${config.api_base_url}/api/v1/file/${selectedFileUuid.value}/face_trace/sortby`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(config.api_key ? { 'X-API-Key': config.api_key } : {})
},
body: JSON.stringify({
sort_by: sortBy.value,
limit: 200,
min_faces: minFaces.value
})
}
)
const data = await result.json()
traces.value = data.traces || []
totalTraces.value = data.total_traces || 0
} catch (e) {
console.error('Failed to load traces:', e)
} finally {
loading.value = false
}
}
const toggleSelection = (face: FaceCandidate) => {
const idx = selectedFaces.value.indexOf(face.id)
if (idx >= 0) {
selectedFaces.value.splice(idx, 1)
} else {
selectedFaces.value.push(face.id)
}
}
const nextPage = () => {
page.value++
loadCandidates()
}
const prevPage = () => {
page.value--
loadCandidates()
}
const getConfidenceColor = (conf: number): string => {
if (conf >= 0.9) return 'text-green-400'
if (conf >= 0.8) return 'text-blue-400'
if (conf >= 0.7) return 'text-yellow-400'
return 'text-gray-400'
}
onMounted(() => {
loadCandidates()
onMounted(async () => {
try {
const result = await getVideos()
files.value = result.data || result.files || []
} catch { /* ignore */ }
})
</script>