智绘鸿蒙 · 如 7 而至端侧 AI 能力实战手记

手机架着就能数深蹲

智绘鸿蒙·如 7 而至

前言

产品需求原本只有一句话:"用户把手机靠在墙上,自己做深蹲,应用负责数次数。"

听起来是个 CV 活儿,拆开之后其实只有三件事:拿到人体的关节点、把关节点变成一个角度、用角度判断"这算不算一次"。前两件鸿蒙 7 的 Core Vision Kit 直接给了,第三件——阈值——是产品自己的决定,而且这个决定最后会成为整个功能最容易挨骂的地方。

我们在 MatePad Pro(API 26)上把这条链跑通了,样本是自己造的三张健身图。这篇文章按落地顺序记录:先确认能力边界,再定指标,最后写代码。中间踩到的四个坑,每一个都会直接影响线上口径。

先确认能力边界

上线前必须回答的四个问题,以及我们在真机拿到的答案:

问题 结论
支持哪些点? 固定 17 个:鼻、左右眼、左右耳、左右肩、左右肘、左右腕、左右髋、左右膝、左右踝
一次能出几个人? 可以。双人同框返回 skeletons.length = 2,两套点都完整
多人会明显变慢吗? 不会。单人 process 176ms,双人 175ms,基本免费
单帧耗时多少? process 稳定在 166~285ms;create 另需 290~360ms,能不能摊掉见第 5 步

第四条里藏着一个坑,而且是我们自己写的代码造成的,后面细说。

另外官方 FAQ 明确提到:部分设备没有 NPU,会导致骨骼点检测变慢。所以"我这台 300ms"不能当成所有人的 300ms,上线前得留一个降级口径(比如降到"每 500ms 采一帧"),而不是假设人人都有 NPU。

指标定义:阈值就是产品

深蹲计数的通行做法是看髋-膝-踝三个点夹出来的膝角:站起来接近 180°,蹲下去变小。我们最后定的状态机只有两个状态、两个阈值:

膝角 < 110°  → 判定为「下蹲」
膝角 > 160°  → 判定为「起立」,并在 下蹲→起立 这条边上计数 +1

两个阈值而不是一条,是为了做迟滞。只用一个 135° 的线,用户在底部抖动一下就变成两次,客服工单会告诉你什么叫真实世界。

真正需要产品拍板的是"蹲到多少度才算蹲"。我们在双人样本里量到一个特别有用的数字:画面里那位女士明明在做高脚杯深蹲,肉眼看着"蹲下去了",膝角实测 154.7°。也就是说,如果阈值定在 110°,她这组动作一次都不会被记上

这就是这类功能最难的地方:算法没错,用户的"蹲"和产品的"蹲"不是一个东西。我们的处理是上线时给一个"灵敏度"开关(110° / 140° 两档),而不是替用户决定他蹲得够不够深。

第 1 步:把图变成 PixelMap

样本放在 rawfile 里,读出来直接构造 ImageSource

static async decode(bytes: Uint8Array): Promise<image.PixelMap> {
  const buffer: ArrayBuffer =
    bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
  const source: image.ImageSource = image.createImageSource(buffer);
  const pm: image.PixelMap = await source.createPixelMap();
  await source.release();
  return pm;
}

注意 Uint8Array.buffer 要按 byteOffset / byteLength 切片,直接拿 .buffer 在别的样本上会带出多余字节。

第 2 步:一次检测拿回 17 个点

const detector = await skeletonDetection.SkeletonDetector.create();
const request: visionBase.Request = { inputData: { pixelMap: pm } };
const data: skeletonDetection.SkeletonDetectionResponse = await detector.process(request);
await detector.destroy();

接口本身没有参数可调:没有阈值、没有"最多几个人"、没有精度档位。SkeletonDetector.process 的注释里还写了一句"仅支持单任务处理,不支持批量任务",所以别指望一次塞多张图。

第 3 步:算膝角,先处理那三个假点

拿回的角度函数很朴素,余弦定理:

static angle(a: SkelPoint, b: SkelPoint, c: SkelPoint): number {
  const v1x = a.x - b.x, v1y = a.y - b.y;
  const v2x = c.x - b.x, v2y = c.y - b.y;
  const d = v1x * v2x + v1y * v2y;
  const n = Math.hypot(v1x, v1y) * Math.hypot(v2x, v2y);
  if (n <= 0) { return -1; }
  return Math.round(Math.acos(Math.max(-1, Math.min(1, d / n))) * 1800 / Math.PI) / 10;
}

真正重要的是调用它之前那一步。侧身下蹲这张图,17 个点的置信度依次是:

95,95,51,94,0,100,99,98,91,97,91,98,97,97,95,92,91

第 5 个(右耳)是 0,而它的坐标是 (0, 0)。这就是第一个坑:points 永远是 17 个元素,检不出来的点不会被删掉,而是给你一个坐标 (0,0) + 置信度 0。如果这个点正好在你要算的角上(比如脚踝出画),余弦定理会照常算出一个"看起来很合理"的角度,你根本发现不了。

所以判据不能是"点存在吗",只能是"score 够不够"。我们在 pick() 里对 score 为 0 的点直接返回哨兵值,角度函数返回 -1,上层遇到 -1 就不更新状态——宁可少计一次,也不要计错。

第 4 步:左右是解剖学左右,不是画面左右

双人样本里,左边那位女士的肘部横坐标是:

LEFT_ELBOW  x = 405
RIGHT_ELBOW x = 248

左手在画面右边。也就是说 LEFT_* 指的是人自己的左手,正面朝向镜头时和画面左右正好相反。侧身图里这个差异会小得多(髋部 446 vs 416),基本靠置信度区分。

对产品的影响很具体:只要你的功能涉及"左右各做几次"(比如单侧哑铃),必须明确告诉用户是解剖学左右,否则用户一定认为数反了。我们最后在设置页里放了"镜像显示"开关,而不是让用户去理解这个词。

第 5 步:把 detector 挪出循环

前面说的"我们自己造成的坑"就是这个。最初的写法每帧都 create() → process() → destroy(),五帧序列实测:

#1 stand  knee=173.2 down=false reps=0
#2 bottom knee=45.2  down=true  reps=0
#3 stand  knee=173.2 down=false reps=1
#4 bottom knee=45.2  down=true  reps=1
#5 stand  knee=173.2 down=false reps=2
5 帧总耗时 3397ms

计数 2 次,正确。但 3397 / 5 = 680ms 一帧,只有 1.5fps,做实时相机流完全不能用。拆开看:create 290~360ms,process 175~220ms。也就是说将近一半的时间花在反复创建同一个模型上

于是改成"进页面 create 一次、退出 destroy",同样的五帧再跑一遍:

warm0 elapsed=453ms   warm1 elapsed=622ms   warm2 elapsed=808ms
warm3 elapsed=995ms   warm4 elapsed=1187ms
warm total=1294ms createOnly=287ms frames=5

1294ms / 5 = 259ms 一帧,比原来快 2.6 倍。注意每帧之间的间隔(169ms、186ms、187ms、192ms)就是 process 的真实成本,非常稳;第一次的 453ms 里包含了 create 的 287ms。

这个改动不难,但如果你像我们一开始那样照着示例代码抄,很容易忽略——官方示例都是单张图,看不出循环成本。

第 6 步:score 为 0 是遮挡信号,而且是可复现的

计数功能一旦上线,最常被问的是"为什么这次没数"。所以我们把两张侧身图各重复检测了 5 次,把 17 个点的置信度逐位对比:

站姿图(5 次完全一致):92,0,91,0,94,98,99,91,97,90,96,99,99,98,99,94,94
底部图(5 次完全一致):95,95,51,94,0,100,99,98,91,97,91,98,97,97,95,92,91

两点结论。

同一张图的输出逐位可复现,不是随机噪声。这意味着 score 可以放心当判据用,不需要在时间维度上做投票平滑。

**为 0 的点恰好是被挡住的那一侧。**站姿图里人是右侧面朝向镜头,为 0 的是左眼、左耳(索引 1、3),坐标同时归零;底部图为 0 的是右耳。也就是说 score == 0 是一个确定性的"这个点在当前帧不可见"信号,而不是"检测失败"。

这条对产品的价值很直接:侧身机位做深蹲计数,本来就会有一半的肢体被挡住。既然模型明确告诉你哪一侧不可见,正确的做法是只用可见侧算角度,而不是要求用户必须正对镜头——后者会直接把"手机靠墙放"这个使用场景作废。

真机数据

MatePad Pro(HarmonyOS 7 / API 26,1840×2800,序列号略)。每张图重复检测 5 次,结果逐位一致,下表取任一次:

样本 尺寸 检出 点数 整体置信度 膝角(左/右) create process
站姿·侧身 683×1024 1 17/17 0.899 174.1° / 172.3° 289~357ms 177~285ms
底部·侧身 683×1024 1 17/17 0.944 45.2°(均值) 288~367ms 166~207ms
双人·正面 1024×819 2 17/17 ×2 0.955 / 0.952 154.7° / 149.8° 287ms 175~266ms

五帧序列的两种跑法:

跑法 5 帧总耗时 平均单帧 折算帧率
每帧 create/destroy 3154 / 3397ms 631~680ms 约 1.5fps
复用同一个 detector 1294ms 259ms 约 3.9fps

坐标单位是图像像素,不是归一化值——站姿帧最大坐标 865,底部帧 784,都落在各自图高 1024 之内。绘制时按 canvasW / imgW 缩放即可,不要按 0~1 处理。

站姿:膝角 174°,判为「起立」

底部:膝角 45°,判为「下蹲」,右耳置信度 0 的点画成红点

双人同框:两套骨骼,橙色是第二个人

五帧序列:每帧新建 detector,计数 2 次、总耗时 3397ms

复用 detector:同一序列 1294ms、平均 259ms/帧

总结

这条能力适合"事后计数"和"低帧率姿态判断":跳绳、深蹲、平板支撑的角度提醒、体测动作是否达标。它的输入就是一张静图,所以任何能拿到 PixelMap 的地方都能用,包括相册里的历史视频抽帧。

不适合实时姿态纠正。复用 detector 之后单帧 259ms、约 3.9fps,做"膝盖别内扣"这种实时语音提醒,画面会明显跟不上动作。真要做实时,方向是降分辨率 + 只算关键角,而不是指望接口本身变快。

💡 一句话:骨骼点检测给你的 17 个点里,检不出来的那个不会消失,而是变成坐标 (0,0)——先按 score 过滤,再谈角度。

参考文献

  1. 骨骼点检测(开发指南):https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-skeleton-detection
  2. skeletonDetection(骨骼点检测)API 参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-skeleton-detection-api
  3. visionBase(通用数据结构)API 参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-vision-base-api
  4. Core Vision Kit 目录:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-kit-guide