fix(training): 修复 AI 跟练结束卡死,看不清时提示后退,加入开发版识别诊断
- 达到目标次数发生在识别回调里,同步关摄像头、销毁识别引擎会让 iOS 卡死:结算推迟到回调返回后,引擎延后销毁,停止后迟到的结果丢弃 - 补监听 addAnchors(与官方示例一致),首次识别到人、丢失后重现的结果不再丢掉 - 训练中连续 1.5 秒看不清时,屏幕和语音提示怎么站,而不是一声不吭不计数 - 开发版识别诊断:页面诊断面板;编译时设置 VITE_POSE_LOG_URL 可把识别数据实时发到电脑上的日志服务。发行版不生效
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 开发版真机排查:识别数据面板(只在摄像头识别模式显示,发行版不渲染) -->
|
||||
<view v-if="IS_DEV && mode === 'vision'" class="diag">
|
||||
<text class="diag__text">{{ diagText }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 计数环 + 连击 -->
|
||||
<view v-if="hud.phase !== 'framing'" class="ring-slot" :style="{ top: pip.top + 'px' }">
|
||||
<RepRing :count="hud.count" :target="target" :color="ex.color" :theme="theme" />
|
||||
@@ -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<typeof setInterval> | null = null
|
||||
let tipTimer: ReturnType<typeof setTimeout> | 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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>[] = []
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let session = ''
|
||||
let failWarned = false
|
||||
|
||||
/** 开始一次新的跟练:之后的日志带上同一个会话编号 */
|
||||
export function rlogSession() {
|
||||
session = String(Date.now())
|
||||
}
|
||||
|
||||
export function rlog(type: string, data: Record<string, unknown> = {}) {
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<AnchorSource | 'error', number>
|
||||
/** detectBody 发出到收到结果的耗时 */
|
||||
latencyMs: number
|
||||
/** 最近一次识别到人的原始数据摘要 */
|
||||
raw: string
|
||||
}
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === 'development'
|
||||
|
||||
export function usePoseCamera(onPose?: (pose: Pose | null) => void) {
|
||||
const status = ref<CameraStatus>('idle')
|
||||
@@ -28,6 +45,13 @@ export function usePoseCamera(onPose?: (pose: Pose | null) => void) {
|
||||
const frame = ref<FrameInfo>({ width: 480, height: 640 })
|
||||
/** 实际检测帧率,调试用 */
|
||||
const fps = ref(0)
|
||||
const diag = shallowRef<PoseDiag>(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)}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user