feat: Library 批量注册 + PeopleView 修复 + 国际化

Library:
- 添加 checkbox 和鼠标框选功能
- Unregistered 卡片添加快速注册按钮
- 状态标签移至左下方,人脸图标移至左侧中间
- Deep Scan 改为 unregister -> register -> process
- 完整国际化(日语、繁体中文、英语)

PeopleView:
- 修复卡片不显示问题(使用 get_file_traces API)
- 移除对 identity_id 字段的依赖

其他:
- 添加日语语言包
- 添加 useDragSelect composable
- 新增文档:Face Bbox Null Bug Report
This commit is contained in:
2026-10-01 00:42:13 +08:00
parent 7b60ca3d2d
commit f3d75ebce8
35 changed files with 8054 additions and 961 deletions

View File

@@ -29,10 +29,10 @@ export function getAsrStatus(asr: AsrData | null | undefined): AsrStatus {
export function getAsrStatusLabel(status: AsrStatus): string {
const labels: Record<AsrStatus, string> = {
'no_audio_track': '無音軌',
'silent_audio': '無語音',
'has_transcript': '有語音',
'processing': '處理中'
'no_audio_track': 'No Audio Track',
'silent_audio': 'No Speech',
'has_transcript': 'Has Speech',
'processing': 'Processing'
}
return labels[status]
}

View File

@@ -0,0 +1,122 @@
import type { FaceLandmarksResponse } from '@/api/types'
export type LandmarkVerdict = 'PASS' | 'REVIEW' | 'FAIL'
export interface LandmarkScoreResult {
score: number
verdict: LandmarkVerdict
reasons: string[]
}
function pts(arr?: number[][] | null): [number, number][] {
return (arr || []).map((p) => [p[0], p[1]] as [number, number])
}
function centroid(arr?: number[][] | null): [number, number] | null {
const a = pts(arr)
if (a.length === 0) return null
const cx = a.reduce((s, p) => s + p[0], 0) / a.length
const cy = a.reduce((s, p) => s + p[1], 0) / a.length
return [cx, cy]
}
export function scoreFaceLandmarks(fa: FaceLandmarksResponse): LandmarkScoreResult {
const lm = fa.landmarks || {}
const lips = fa.lips || {}
const reasons: string[] = []
// Dimension A: completeness (20)
let a = 20
for (const k of ['left_eye', 'right_eye', 'nose'] as const) {
const v = lm[k] || []
if (v.length === 0) {
a -= 20 / 3
reasons.push(`missing ${k}`)
} else if (![6, 8].includes(v.length)) {
reasons.push(`${k} pts=${v.length}`)
}
}
if (lips.outer_lips && ![0, 14].includes(lips.outer_lips.length)) {
reasons.push(`outer_lips pts=${lips.outer_lips.length}`)
}
if (lips.inner_lips && ![0, 6].includes(lips.inner_lips.length)) {
reasons.push(`inner_lips pts=${lips.inner_lips.length}`)
}
// Dimension B: geometry (50)
let b = 50
const faceSize = Math.max(fa.width, fa.height, 1)
const le = centroid(lm.left_eye)
const re = centroid(lm.right_eye)
const no = centroid(lm.nose)
if (le && re && faceSize > 0) {
const eyeDist = Math.hypot(le[0] - re[0], le[1] - re[1])
const eyeRatio = eyeDist / faceSize
if (eyeRatio < 0.25) {
const pen = Math.min(20, (0.25 - eyeRatio) * 200)
b -= pen
reasons.push(`eye_ratio=${eyeRatio.toFixed(2)} too small`)
} else if (eyeRatio > 0.55) {
const pen = Math.min(20, (eyeRatio - 0.55) * 100)
b -= pen
reasons.push(`eye_ratio=${eyeRatio.toFixed(2)} too large`)
}
const eyeDy = Math.abs(le[1] - re[1]) / faceSize
if (eyeDy > 0.15) {
const pen = Math.min(15, (eyeDy - 0.15) * 100)
b -= pen
reasons.push(`eye_dy=${eyeDy.toFixed(2)} asymmetric`)
}
if (no) {
const noseBelow = (no[1] - Math.max(le[1], re[1])) / faceSize
if (noseBelow < 0.03) {
const pen = Math.min(15, (0.03 - noseBelow) * 50)
b -= pen
reasons.push(`nose_below=${noseBelow.toFixed(2)}`)
} else if (noseBelow > 0.35) {
const pen = Math.min(15, (noseBelow - 0.35) * 50)
b -= pen
reasons.push(`nose_below=${noseBelow.toFixed(2)} too low`)
}
const off = eyeDist > 0 ? (no[0] - (le[0] + re[0]) / 2) / eyeDist : 0
if (Math.abs(off) > 0.4) {
const pen = Math.min(15, (Math.abs(off) - 0.4) * 20)
b -= pen
reasons.push(`nose_off_center=${off.toFixed(2)}`)
}
}
} else {
b -= 30
reasons.push('no eye centroids')
}
for (const k of ['left_eye', 'right_eye', 'nose'] as const) {
for (const p of pts(lm[k])) {
if (!(fa.x - 5 <= p[0] && p[0] <= fa.x + fa.width + 5 && fa.y - 5 <= p[1] && p[1] <= fa.y + fa.height + 5)) {
b -= 10
reasons.push(`${k} outside bbox`)
break
}
}
}
if (Math.min(fa.width, fa.height) < 64) {
b -= 10
reasons.push('face < 64px')
}
// Dimension C: confidence & pose (30)
let c = fa.confidence * 30
const pa = fa.pose_angle
const maxPose = Math.max(Math.abs(pa?.yaw || 0), Math.abs(pa?.roll || 0), Math.abs(pa?.pitch || 0))
if (maxPose > 1.5) {
reasons.push(`pose=${maxPose.toFixed(1)}`)
}
const score = Math.max(0, Math.min(100, a + b + c))
const verdict: LandmarkVerdict = score >= 70 ? 'PASS' : score >= 40 ? 'REVIEW' : 'FAIL'
return { score: Math.round(score * 10) / 10, verdict, reasons }
}