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:
@@ -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]
|
||||
}
|
||||
|
||||
122
src/utils/faceLandmarkScoring.ts
Normal file
122
src/utils/faceLandmarkScoring.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user