diff --git a/TUICallKit-Vue3/training/pages/components/pose-coach.scss b/TUICallKit-Vue3/training/pages/components/pose-coach.scss index 3d7779162..71c533761 100644 --- a/TUICallKit-Vue3/training/pages/components/pose-coach.scss +++ b/TUICallKit-Vue3/training/pages/components/pose-coach.scss @@ -746,3 +746,24 @@ $ink-soft: #6b7a72; align-items: center; justify-content: center; } + +/* 开发版识别诊断面板:底部操作栏上方,不挡计数环和小窗,不拦截点击 */ +.diag { + position: absolute; + left: 16rpx; + right: 16rpx; + bottom: calc(270rpx + env(safe-area-inset-bottom)); + z-index: 30; + padding: 10rpx 14rpx; + border-radius: 12rpx; + background: rgba(0, 0, 0, 0.62); + pointer-events: none; +} + +.diag__text { + color: #a7f3d0; + font-size: 19rpx; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-all; +} diff --git a/TUICallKit-Vue3/training/pages/pose-coach.vue b/TUICallKit-Vue3/training/pages/pose-coach.vue index 5ebdbb13c..790cb81c3 100644 --- a/TUICallKit-Vue3/training/pages/pose-coach.vue +++ b/TUICallKit-Vue3/training/pages/pose-coach.vue @@ -81,6 +81,11 @@ + + + {{ diagText }} + + @@ -171,9 +176,9 @@ import AmountMeter from './components/amount-meter.vue' import PoseIntro from './components/pose-intro.vue' import PoseSummary from './components/pose-summary.vue' import { EXERCISES, bgmUrlOf, videoUrlOf } from '../pose/exercises' -import { FRAMING_TIPS, type FramingIssue, type Pose } from '../pose/keypoints' +import { FRAMING_TIPS, KP, isVisible, type FramingIssue, type Pose } from '../pose/keypoints' import { MOTIONS, MISTAKE_POSES, type MoveId, type Side } from '../pose/motions' -import { tipTextsOf } from '../pose/counters' +import { TRACK_MIN_SCORE, tipTextsOf } from '../pose/counters' import { TrainingSession, summarize, type SessionEvent, type SessionPhase, type SessionSummary } from '../pose/session' import { usePoseCamera } from '../pose/usePoseCamera' import { requestCameraPermission, type PermissionApi } from '../pose/cameraPermission' @@ -181,6 +186,7 @@ import { useCoachVoice } from '../pose/useCoachVoice' import { useBgm } from '../pose/useBgm' import type { BgmState } from '../pose/bgm' import { appendHistory, uniStorage } from '../pose/history' +import { remoteLogEnabled, rlog, rlogSession } from '../pose/remoteLog' const IS_DEV = process.env.NODE_ENV === 'development' const MIRROR_KEY = 'pose_coach_overlay_mirror' @@ -236,6 +242,11 @@ let simTimer: ReturnType | null = null let tipTimer: ReturnType | null = null let lastFramingVoice = 0 let lastFramingIssue: FramingIssue = 'no_body' +/** 训练中关键点看不清(算不出动作指标)从何时开始;持续一段时间就提示用户调整站位 */ +let blindSince = 0 +let lastBlindTip = 0 +const BLIND_TIP_AFTER_MS = 1500 +const BLIND_TIP_COOLDOWN_MS = 5000 const videoUrl = computed(() => videoUrlOf(ex.value, IS_DEV)) @@ -293,6 +304,32 @@ const holdRingBg = computed(() => { const deg = Math.round(hud.holdProgress * 360) return `conic-gradient(#34d399 ${deg}deg, rgba(255,255,255,0.25) ${deg}deg)` }) +/** 诊断面板:识别引擎状态 → 原始数据 → 站位 / 阶段 / 幅度 / 次数,逐层看是哪一步断了 */ +const diagText = computed(() => { + const d = camera.diag.value + const e = d.events + return [ + `识别 ${camera.status.value} ${camera.fps.value}次/秒 帧${frame.value.width}×${frame.value.height} 延迟${d.latencyMs}ms`, + `事件 add${e.add} upd${e.update} rm${e.remove} 超时${e.timeout} 错${e.error}`, + d.raw || '尚未识别到人', + `看清(≥${TRACK_MIN_SCORE}) 髋${seen(KP.L_HIP, KP.R_HIP)} 膝${seen(KP.L_KNEE, KP.R_KNEE)} 踝${seen(KP.L_ANKLE, KP.R_ANKLE)} 腕${seen(KP.L_WRIST, KP.R_WRIST)} 指标 ${metricText()}`, + `站位 ${hud.framingIssue} 阶段 ${hud.phase} 幅度 ${hud.liveAmount.toFixed(2)}/${goal.value.toFixed(2)} 次数 ${hud.count}`, + ].join('\n') +}) + +/** 一对左右关键点里看清了几个(按计数用的阈值) */ +function seen(...idx: number[]) { + const p = displayPose.value + return p ? idx.filter((i) => isVisible(p, i, TRACK_MIN_SCORE)).length : 0 +} + +/** 计数器最近一帧的原始指标;null 表示关键点看不清,这一帧被跳过 */ +function metricText() { + const m = session?.counter.metric + if (m === undefined) return '-' + return m === null ? '看不清(整帧跳过)' : m.toFixed(2) +} + const timeText = computed(() => { const s = Math.floor(hud.activeMs / 1000) return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` @@ -462,6 +499,8 @@ function startStage(m: 'vision' | 'sim' | 'guided') { mode.value = m selfMain.value = false session = new TrainingSession(ex.value.id, target.value, { detectLost: m !== 'guided' }) + rlogSession() + rlog('stage', { mode: m, move: ex.value.id, target: target.value }) goal.value = session.counter.goal Object.assign(hud, { phase: 'framing', framingIssue: 'no_body', holdProgress: 0, count: 0, combo: 0, activeMs: 0, liveAmount: 0 }) pop.key = 0 @@ -477,6 +516,7 @@ function startStage(m: 'vision' | 'sim' | 'guided') { } function onCameraReady() { + rlog('camera_ready', { mode: mode.value }) if (mode.value !== 'vision') return camera.start() } @@ -493,12 +533,14 @@ watch( ) function onCameraError() { + rlog('camera_error') camera.onCameraError(null) fallbackToGuided('摄像头不可用,已切换为领练模式') } function fallbackToGuided(msg: string) { if (mode.value === 'guided') return + rlog('fallback_guided', { msg, status: camera.status.value, err: camera.errorText.value }) camera.stop() stopSim() uni.showToast({ title: msg, icon: 'none', duration: 2500 }) @@ -510,6 +552,41 @@ function onPose(p: Pose | null) { displayPose.value = p if (!session) return handle(session.feed(p, Date.now())) + checkBlind() + if (remoteLogEnabled) logPose(p) +} + +/** 训练中持续看不清(离太近、腿出画面等)时不会计数:提示用户怎么站,而不是让人干练不计数 */ +function checkBlind() { + if (!session || hud.phase !== 'training' || session.counter.metric !== null) { + blindSince = 0 + return + } + const now = Date.now() + if (!blindSince) blindSince = now + if (now - blindSince < BLIND_TIP_AFTER_MS || now - lastBlindTip < BLIND_TIP_COOLDOWN_MS) return + lastBlindTip = now + const text = FRAMING_TIPS[hud.framingIssue === 'none' ? 'partial' : hud.framingIssue] + showTip(text, []) + voice.speak(text, 'high') + rlog('blind_tip', { issue: hud.framingIssue }) +} + +/** 远程日志:这一帧算到哪一步了(站位 → 阶段 → 指标 → 幅度 → 次数),以及关键点看清了几个 */ +function logPose(p: Pose | null) { + const m = session?.counter.metric + const key = [KP.L_HIP, KP.R_HIP, KP.L_KNEE, KP.R_KNEE, KP.L_ANKLE, KP.R_ANKLE] + const y = (a: number, b: number) => (p ? +(((p.points[a]?.y ?? 0) + (p.points[b]?.y ?? 0)) / 2).toFixed(3) : null) + rlog('pose', { + ph: hud.phase, + iss: hud.framingIssue, + m: m === null || m === undefined ? null : +m.toFixed(3), + amt: +hud.liveAmount.toFixed(2), + cnt: hud.count, + vis: `h${seen(KP.L_HIP, KP.R_HIP)}k${seen(KP.L_KNEE, KP.R_KNEE)}a${seen(KP.L_ANKLE, KP.R_ANKLE)}w${seen(KP.L_WRIST, KP.R_WRIST)}`, + sc: p ? +Math.min(...key.map((i) => p.scores[i] ?? 0)).toFixed(2) : null, + y: p ? { nose: +(p.points[KP.NOSE]?.y ?? 0).toFixed(3), hip: y(KP.L_HIP, KP.R_HIP), knee: y(KP.L_KNEE, KP.R_KNEE), ankle: y(KP.L_ANKLE, KP.R_ANKLE) } : null, + }) } function handle(events: SessionEvent[]) { @@ -537,6 +614,7 @@ function handle(events: SessionEvent[]) { } function onEvent(e: SessionEvent) { + rlog('ev', { ev: e }) switch (e.type) { case 'phase': if (e.phase === 'countdown') voice.speak('准备', 'high') @@ -544,7 +622,8 @@ function onEvent(e: SessionEvent) { voice.speak('开始', 'high') } if (e.phase === 'lost') voice.speak('请回到画面中', 'high') - if (e.phase === 'done') finish() + // 达到目标次数通常发生在识别结果回调里:推迟到回调返回后再结算、关摄像头、切页面,避免在原生回调里拆掉自己 + if (e.phase === 'done') setTimeout(finish, 0) break case 'countdown': voice.speak(String(e.n), 'high') @@ -584,7 +663,7 @@ function shouldSpeakCount(n: number) { function voicePhrases() { const counts: string[] = [] for (let i = 1; i <= target.value; i++) if (shouldSpeakCount(i)) counts.push(String(i)) - const cues = ['准备', '开始', '3', '2', '1', '太棒了,完成训练', '请回到画面中', '请站到画面中间'] + const cues = ['准备', '开始', '3', '2', '1', '太棒了,完成训练', '请回到画面中', '请站到画面中间', ...Object.values(FRAMING_TIPS)] return [...cues, ...counts, ...tipTextsOf(move.value)] } @@ -646,6 +725,7 @@ function stopTimers() { // ───────── 暂停 / 结束 ───────── function onPause() { if (!session) return + rlog('pause', { phase: hud.phase }) handle(session.pause()) } @@ -658,6 +738,7 @@ function onResume() { function finish() { if (!session || stage.value !== 'camera') return + rlog('finish', { count: hud.count, phase: hud.phase }) // 只改状态、不派发 done 事件:done 的处理就是调用 finish,派发会重入,同一次训练记两条历史 session.finish() const s = summarize(session, ex.value.netMet) @@ -694,6 +775,7 @@ function onClose() { function toggleCoachMode() { coachMode.value = coachMode.value === 'demo' ? 'mirror' : 'demo' + rlog('coach_mode', { mode: coachMode.value }) uni.showToast({ title: coachMode.value === 'demo' ? '跟练:跟着小甄的节奏' : '镜像:小甄模仿你的动作', icon: 'none' }) } diff --git a/TUICallKit-Vue3/training/pose/remoteLog.ts b/TUICallKit-Vue3/training/pose/remoteLog.ts new file mode 100644 index 000000000..7e77be405 --- /dev/null +++ b/TUICallKit-Vue3/training/pose/remoteLog.ts @@ -0,0 +1,47 @@ +/** + * 开发版真机排查:把跟练的识别数据批量发到电脑上的日志服务,边练边看。 + * + * 地址在编译时用环境变量 VITE_POSE_LOG_URL 指定(如 http://192.168.8.11:8787/log),不写进代码; + * 没设置或发行版时整个模块是空操作。需要项目关闭合法域名校验(manifest 里 urlCheck: false)。 + */ +const LOG_URL: string = ((import.meta as any).env?.VITE_POSE_LOG_URL as string | undefined) || '' +const FLUSH_MS = 1000 +/** 断网时最多攒这么多条,超出丢最旧的 */ +const MAX_QUEUE = 600 + +export const remoteLogEnabled = Boolean(LOG_URL) && process.env.NODE_ENV === 'development' + +let queue: Record[] = [] +let timer: ReturnType | null = null +let session = '' +let failWarned = false + +/** 开始一次新的跟练:之后的日志带上同一个会话编号 */ +export function rlogSession() { + session = String(Date.now()) +} + +export function rlog(type: string, data: Record = {}) { + if (!remoteLogEnabled) return + queue.push({ t: Date.now(), type, ...data }) + if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE) + if (!timer) timer = setTimeout(flush, FLUSH_MS) +} + +function flush() { + timer = null + if (!queue.length) return + const entries = queue + queue = [] + uni.request({ + url: LOG_URL, + method: 'POST', + data: { session, entries }, + timeout: 3000, + fail: (e) => { + if (failWarned) return + failWarned = true + console.warn('[跟练日志] 发送失败,检查手机和电脑是否在同一网络、日志服务是否启动', LOG_URL, e) + }, + }) +} diff --git a/TUICallKit-Vue3/training/pose/usePoseCamera.ts b/TUICallKit-Vue3/training/pose/usePoseCamera.ts index cff8939d8..d24e0a3cb 100644 --- a/TUICallKit-Vue3/training/pose/usePoseCamera.ts +++ b/TUICallKit-Vue3/training/pose/usePoseCamera.ts @@ -9,6 +9,7 @@ */ import { ref, shallowRef, onUnmounted } from 'vue' import { anchorToPose, smoothPose, type Pose } from './keypoints.ts' +import { rlog } from './remoteLog.ts' export type CameraStatus = 'idle' | 'starting' | 'running' | 'unsupported' | 'denied' | 'error' @@ -19,7 +20,23 @@ const DETECT_TIMEOUT_MS = 600 /** 连续多久没检测到人,清空姿态 */ const LOST_AFTER_MS = 800 +/** 诊断面板刷新间隔;console 摘要间隔 */ +const DIAG_REFRESH_MS = 500 +const DIAG_LOG_MS = 2000 + type FrameInfo = { width: number; height: number } +type AnchorSource = 'add' | 'update' | 'remove' | 'timeout' + +/** 真机排查用:识别引擎的事件计数和最近一次识别到人的原始数据摘要(开发版跟练页显示) */ +export type PoseDiag = { + events: Record + /** detectBody 发出到收到结果的耗时 */ + latencyMs: number + /** 最近一次识别到人的原始数据摘要 */ + raw: string +} + +const IS_DEV = process.env.NODE_ENV === 'development' export function usePoseCamera(onPose?: (pose: Pose | null) => void) { const status = ref('idle') @@ -28,6 +45,13 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { const frame = ref({ width: 480, height: 640 }) /** 实际检测帧率,调试用 */ const fps = ref(0) + const diag = shallowRef(emptyDiag()) + const events = { ...emptyDiag().events } + let latencyMs = 0 + let lastRaw = '' + let firstAnchorLogged = false + let lastDiagAt = 0 + let lastLogAt = 0 let session: any = null let listener: any = null @@ -52,12 +76,24 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { } } - function handleAnchors(anchors: any[]) { + function handleAnchors(anchors: any[], source: AnchorSource) { + // 已经停止(训练结束、切到领练)后迟到的识别结果直接丢弃 + if (!session) return + const now = Date.now() + if (pending && (source === 'add' || source === 'update')) latencyMs = now - lastSent + events[source] += 1 clearPending() fpsCount += 1 - const now = Date.now() // 画面里多人时取检测框最大的那个 const best = (anchors || []).slice().sort((a, b) => area(b) - area(a))[0] + rlog('det', { + src: source, + lat: source === 'add' || source === 'update' ? latencyMs : undefined, + n: anchors?.length ?? 0, + // 原始坐标不是有效数字的点(画面外的点真机返回 NaN) + nan: best ? nanPoints(best) : undefined, + }) + if (best) recordRaw(best) const next = best ? anchorToPose(best, sentFrame.width, sentFrame.height, now) : null if (next) { lastSeen = now @@ -65,6 +101,32 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { } else if (now - lastSeen > LOST_AFTER_MS) { emit(null) } + refreshDiag(now) + } + + /** 记下原始 anchor 的摘要;第一次识别到人时把完整原始数据打到 console,便于核对字段格式 */ + function recordRaw(anchor: any) { + if (IS_DEV && !firstAnchorLogged) { + firstAnchorLogged = true + // anchor 是原生对象,字段不可枚举,直接 JSON.stringify 得到 {};逐个取出再序列化,NaN 显式写出来 + const json = JSON.stringify(plainAnchor(anchor)) + console.log('[跟练诊断] 首个原始 anchor', `帧 ${sentFrame.width}x${sentFrame.height}`, json.slice(0, 1500)) + rlog('first_anchor', { frame: `${sentFrame.width}x${sentFrame.height}`, anchor: json.slice(0, 4000) }) + } + lastRaw = summarizeAnchor(anchor) + } + + function refreshDiag(now: number) { + if (now - lastDiagAt < DIAG_REFRESH_MS) return + lastDiagAt = now + diag.value = { events: { ...events }, latencyMs, raw: lastRaw } + if (IS_DEV && now - lastLogAt >= DIAG_LOG_MS) { + lastLogAt = now + const e = events + console.log( + `[跟练诊断] 帧 ${frame.value.width}x${frame.value.height} 延迟 ${latencyMs}ms 事件 add${e.add} upd${e.update} rm${e.remove} 超时${e.timeout} 错${e.error} | ${lastRaw || '尚未识别到人'}`, + ) + } } function area(a: any) { @@ -77,6 +139,12 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { function start() { if (status.value === 'running' || status.value === 'starting') return status.value = 'starting' + Object.assign(events, emptyDiag().events) + latencyMs = 0 + lastRaw = '' + firstAnchorLogged = false + diag.value = emptyDiag() + rlog('camera_start', deviceInfo()) // #ifdef MP-WEIXIN startVisionKit() // #endif @@ -90,22 +158,28 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { const w: any = wx if (typeof w.createVKSession !== 'function' || (w.isVKSupport && !w.isVKSupport('v1'))) { status.value = 'unsupported' + rlog('vk_unsupported', { hasCreate: typeof w.createVKSession === 'function' }) return } session = w.createVKSession({ track: { body: { mode: 2 } }, version: 'v1' }) - session.on('updateAnchors', (anchors: any[]) => handleAnchors(anchors)) - session.on('removeAnchors', () => handleAnchors([])) + // 连续帧(sourceType 0)会跟踪人体:首次识别到人、丢失后重新出现走 addAnchors,之后每帧走 updateAnchors(同官方示例) + session.on('addAnchors', (anchors: any[]) => handleAnchors(anchors, 'add')) + session.on('updateAnchors', (anchors: any[]) => handleAnchors(anchors, 'update')) + session.on('removeAnchors', () => handleAnchors([], 'remove')) session.start((err: any) => { if (err) { status.value = 'unsupported' errorText.value = String(err?.errMsg || err) + rlog('vk_start_error', { err: errorText.value }) return } + rlog('vk_started') startFrames() }) } catch (e: any) { status.value = 'unsupported' errorText.value = String(e?.message || e) + rlog('vk_exception', { err: errorText.value }) } } @@ -113,6 +187,7 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { const cam: any = uni.createCameraContext() if (!cam?.onCameraFrame) { status.value = 'unsupported' + rlog('no_camera_frame_api') return } listener = cam.onCameraFrame((f: { data: ArrayBuffer; width: number; height: number }) => { @@ -120,11 +195,12 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { if (pending || now - lastSent < DETECT_INTERVAL_MS || !session) return if (frame.value.width !== f.width || frame.value.height !== f.height) { frame.value = { width: f.width, height: f.height } + rlog('frame_size', { w: f.width, h: f.height, bytes: f.data?.byteLength }) } pending = true lastSent = now sentFrame = { width: f.width, height: f.height } - pendingTimer = setTimeout(() => handleAnchors([]), DETECT_TIMEOUT_MS) + pendingTimer = setTimeout(() => handleAnchors([], 'timeout'), DETECT_TIMEOUT_MS) try { session.detectBody({ frameBuffer: f.data, @@ -135,12 +211,16 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { sourceType: 0, }) } catch (e) { + events.error += 1 + if (IS_DEV) console.warn('[跟练诊断] detectBody 异常', e) + rlog('detect_err', { err: String((e as any)?.message || e) }) clearPending() } }) listener.start({ success: () => { status.value = 'running' + rlog('frames_started') fpsTimer = setInterval(() => { fps.value = fpsCount fpsCount = 0 @@ -149,6 +229,7 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { fail: (e: any) => { status.value = 'error' errorText.value = String(e?.errMsg || e) + rlog('frames_fail', { err: errorText.value }) }, }) } @@ -159,11 +240,17 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { listener?.stop?.() } catch (e) {} listener = null - try { - session?.stop?.() - session?.destroy?.() - } catch (e) {} + const oldSession = session session = null + rlog('camera_stop') + // 停止可能发生在识别引擎自己的回调里(第 N 次计数 → 结束 → 关摄像头):在回调里同步销毁引擎,iOS 真机会卡死。 + // 先断开引用(之后迟到的回调全部丢弃),等当前回调返回后再销毁 + setTimeout(() => { + try { + oldSession?.stop?.() + oldSession?.destroy?.() + } catch (e) {} + }, 0) if (fpsTimer) clearInterval(fpsTimer) fpsTimer = null if (status.value === 'running' || status.value === 'starting') status.value = 'idle' @@ -178,5 +265,59 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) { onUnmounted(stop) - return { status, errorText, pose, frame, fps, start, stop, onCameraError } + return { status, errorText, pose, frame, fps, diag, start, stop, onCameraError } +} + +const num = (v: unknown) => (typeof v === 'number' ? (Number.isFinite(v) ? +v.toFixed(4) : String(v)) : v) +const rawXY = (p: any) => (Array.isArray(p) ? [p[0], p[1]] : [p?.x, p?.y]) + +/** 原始坐标不是有效数字的点的序号,如 "13,14,15,16" */ +function nanPoints(anchor: any): string { + const pts: any[] = Array.isArray(anchor?.points) ? anchor.points : [] + return pts + .map((p, i) => (rawXY(p).every((v) => typeof v === 'number' && Number.isFinite(v)) ? -1 : i)) + .filter((i) => i >= 0) + .join(',') +} + +function plainAnchor(a: any) { + return { + keys: Object.keys(a ?? {}), + score: num(a?.score), + origin: a?.origin ? [num(a.origin.x), num(a.origin.y)] : a?.origin, + size: a?.size ? [num(a.size.width), num(a.size.height)] : a?.size, + points: Array.isArray(a?.points) ? a.points.map((p: any) => rawXY(p).map(num)) : typeof a?.points, + confidence: Array.isArray(a?.confidence) ? a.confidence.map(num) : typeof a?.confidence, + } +} + +/** 机型与微信版本,排查机型差异用 */ +function deviceInfo() { + try { + const s: any = uni.getSystemInfoSync() + return { brand: s.brand, model: s.model, system: s.system, platform: s.platform, wx: s.version, sdk: s.SDKVersion } + } catch (e) { + return {} + } +} + +function emptyDiag(): PoseDiag { + return { events: { add: 0, update: 0, remove: 0, timeout: 0, error: 0 }, latencyMs: 0, raw: '' } +} + +const f2 = (v: unknown) => (typeof v === 'number' ? v.toFixed(2) : String(v)) + +/** 原始 anchor 摘要:点数、坐标是比例还是像素、置信度范围,以及鼻子 / 髋 / 踝的原始坐标 */ +function summarizeAnchor(a: any): string { + const pts: any[] = Array.isArray(a?.points) ? a.points : [] + const xy = (i: number) => { + const p = pts[i] + if (!p) return '-' + return Array.isArray(p) ? `${f2(p[0])},${f2(p[1])}` : `${f2(p.x)},${f2(p.y)}` + } + const flat = pts.flatMap((p) => (Array.isArray(p) ? p : [p?.x, p?.y])).filter((v) => typeof v === 'number') + const unit = flat.some((v) => v > 1.5) ? '像素' : '比例' + const conf: number[] = Array.isArray(a?.confidence) ? a.confidence : [] + const confText = conf.length ? `${f2(Math.min(...conf))}~${f2(Math.max(...conf))}` : '无' + return `${pts.length}点 ${unit} 框分${f2(a?.score)} 点置信${confText} 鼻${xy(0)} 左髋${xy(11)} 右髋${xy(12)} 左踝${xy(15)} 右踝${xy(16)}` }