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)}`
}