fix: trace debug — show Stranger_NNN for unnamed traces instead of unknown
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user