AI跳绳系统从零实现:姿态识别、计数算法与多端架构实战

AI跳绳是一类通过摄像头或惯性传感器采集人体运动数据,借助姿态估计模型识别跳绳动作并自动计数的应用系统。它的技术链路通常分为四层:端侧采集层(摄像头抽帧或蓝牙 IMU 数据)、算法推理层(人体关键点检测 + 计数状态机)、业务服务层(Spring Boot 提供接口与统计逻辑)、数据存储层(MySQL 落库与聚合查询)。如果只做单人娱乐场景,纯端侧推理就能闭环;如果要支持班级体测、多人同屏、历史曲线,则必须补上服务端与多端适配。本文按工程实现的顺序,把这条链路完整拆一遍。

一、先选路线:视觉方案与传感器方案的取舍

在动手写代码之前,需要先确定计数信号的来源,这决定了后续 70% 的工作量。

视觉方案:用手机摄像头拍摄,通过 BlazePose、MoveNet 等轻量姿态模型输出 17 或 33 个人体关键点,再基于腕部或踝部的周期性位移做计数。优点是零额外硬件,小程序、H5、App 都能跑;缺点是受光照、遮挡、背景干扰影响大,且推理功耗不可忽视。

传感器方案:跳绳手柄内置六轴 IMU(加速度计 + 陀螺仪),通过加速度模值的周期峰值计数,再经蓝牙 BLE 上报。优点是计数稳定、延迟低、几乎不耗手机算力;缺点是需要专用硬件,且只能计数,拿不到姿态是否标准的判断。

混合方案在实践中更常见:传感器负责高频计数,视觉负责姿态质量评估(例如判断是否出现"甩绳不跳"或"双摇误判")。两者数据在服务端按时间戳对齐。

二、视觉计数链路:关键点检测 + 峰值状态机

2.1 端侧抽帧与推理调度

不要在 requestAnimationFrame 里逐帧推理,手机端会迅速发热降频。合理做法是按固定间隔抽帧,例如 12~15 FPS,用 setInterval 或 cameraContext.onCameraFrame 控制节奏。

// uniapp:按固定间隔从摄像头取帧,送入本地推理
const INFER_INTERVAL = 66; // 约 15 FPS
let timer = null;

function startCapture(cameraContext, onKeypoints) {
  const ctx = uni.createCameraContext();
  timer = setInterval(() => {
    ctx.takePhoto({
      quality: 'low',
      success: (res) => {
        // res.tempImagePath 交给 WASM 版姿态模型推理
        inferPose(res.tempImagePath).then(onKeypoints);
      }
    });
  }, INFER_INTERVAL);
}

function stopCapture() {
  if (timer) clearInterval(timer);
  timer = null;
}

推理侧建议把模型量化为 int8,并用 WebAssembly + SIMD 后端;在小程序里可以走 .createInferenceSession,H5 端则用 TensorFlow.js 的 WASM 后端。模型输入分辨率压到 256×256 通常就够用,再大对计数精度几乎没有提升。

2.2 用腕部纵坐标做峰值检测

关键点里适合做计数的是左右手腕(索引 9、10)。腕部随绳的摆动呈明显周期性,且比踝部更不容易被腿部自遮挡。直接对 y 坐标做"过零检测"会抖动误判,正确做法是带滞回的峰值状态机:

class JumpCounter {
  constructor({ high = 0.42, low = 0.30, minInterval = 180 }) {
    this.high = high;        // 归一化 y 的上升阈值
    this.low = low;          // 回落阈值
    this.minInterval = minInterval;
    this.state = 'down';     // down -> up -> down 记一次
    this.lastTs = 0;
    this.count = 0;
  }

  // y 为归一化到 [0,1] 的腕部纵坐标,越小越靠上
  push(y, ts) {
    if (ts - this.lastTs < this.minInterval) return this.count;

    if (this.state === 'down' && y < this.high) {
      this.state = 'up';
    } else if (this.state === 'up' && y > this.low) {
      this.state = 'down';
      this.count += 1;
      this.lastTs = ts;
    }
    return this.count;
  }
}

三个参数的作用:high/low 构成滞回带,避免关键点抖动在单帧内反复触发;minInterval 限制计数频率,防止把绳子的高频旋转误算成起跳。实际调试时把这两个阈值做成可视化曲线叠加,比盲调快得多。

2.3 多人场景的 ID 绑定

多人同屏时,先按关键点包围盒做 IoU 匹配,把上一帧的轨迹 ID 分配给当前帧的人体,再对每个人体独立维护一个 JumpCounter 实例。轨迹丢失超过 1 秒就销毁计数器,避免 ID 漂移导致计数串号。

三、服务端与多端架构设计

端侧负责实时反馈,服务端负责沉淀与统计。技术栈上,后端用 Spring Boot + MyBatis-Plus + MySQL,用户端用 uni-app(Vue 语法)一次编写、编译到小程序 / H5 / 公众号 / 安卓 / iOS,管理后台用 Vue + Element UI 做数据看板与规则配置,是比较成熟的组合。

3.1 表结构

CREATE TABLE jump_session (
  id           BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id      BIGINT       NOT NULL,
  source       TINYINT      NOT NULL COMMENT '1=视觉 2=蓝牙 3=混合',
  started_at   DATETIME     NOT NULL,
  ended_at     DATETIME     NULL,
  total_count  INT          DEFAULT 0,
  valid_count  INT          DEFAULT 0 COMMENT '姿态合格次数',
  create_time  DATETIME     DEFAULT CURRENT_TIMESTAMP,
  KEY idx_user_time (user_id, started_at)
);

CREATE TABLE jump_record (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  session_id  BIGINT   NOT NULL,
  seq         INT      NOT NULL COMMENT '端侧序号,用于幂等去重',
  ts_offset   INT      NOT NULL COMMENT '相对会话开始的毫秒偏移',
  confidence  FLOAT    DEFAULT 0,
  KEY uk_session_seq (session_id, seq)
);

uk_session_seq 这个索引很关键:端侧网络抖动时会重发批次,靠它做幂等插入,避免同一次跳跃被计两遍。

3.2 上报接口

@RestController
@RequestMapping("/api/jump")
public class JumpController {

    private final JumpService jumpService;

    public JumpController(JumpService jumpService) {
        this.jumpService = jumpService;
    }

    @PostMapping("/session/{id}/records")
    public R<SyncResult> sync(@PathVariable Long id,
                              @RequestBody @Valid BatchSyncDTO dto) {
        // 单批限制条数,超限直接拒绝,防止端侧异常刷量
        if (dto.getRecords().size() > 500) {
            return R.fail("batch too large");
        }
        return R.ok(jumpService.saveBatchIdempotent(id, dto.getRecords()));
    }
}

saveBatchIdempotent 内部用 MyBatis-Plus 的 saveBatch 配合 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE,一次事务落库。

3.3 离线优先的端侧策略

跳绳场景常在户外、无网环境。端侧必须本地先存(uni.setStorage 或 SQLite),联网后按会话批量补传。补传时带上客户端生成的会话 UUID,服务端用它做跨端去重。

四、蓝牙智能跳绳的数据接入

如果采用硬件方案,通常走 BLE 的 GATT 通道:连接设备后订阅 Notify 特征值,手柄按固定频率推送打包好的数据帧。

uni.notifyBLECharacteristicValueChange({
  deviceId,
  serviceId: SERVICE_UUID,
  characteristicId: NOTIFY_UUID,
  state: true
});

uni.onBLECharacteristicValueChange((res) => {
  const buf = new Uint8Array(res.value);
  const view = new DataView(buf.buffer);
  const count = view.getUint16(2, true);   // 小端读取
  const battery = view.getUint8(4);
  updateRealtime(count, battery);
});

这里容易踩的坑是字节序。不同厂商固件的多字节字段大小端不一致,拿到协议文档后先用固定测试值验证一遍再解析,不要凭经验写 getUint16(2)。另外 Notify 频率往往高于 UI 刷新需求,建议在端侧做一次节流,只在计数变化时才触发状态更新。

五、落地过程中的优化建议

  1. 控制推理频率而不是分辨率。把推理降到 12 FPS,准确率几乎不变,但功耗下降明显。
  2. 阈值自适应。固定 high/low 在不同身高、不同拍摄距离下表现差异很大,可以根据前 3 秒的腕部坐标极差动态初始化阈值。
  3. 姿态质量而非只计数。用膝角、躯干倾角判断动作规范性,比单纯计总数更有训练价值。
  4. 数据对齐。 混合方案中视觉与传感器时间戳来自不同时钟源,统一用服务端接收时间做一次校准。
  5. 灰度发布模型。 端侧模型更新要走版本号 + 回滚开关,避免新模型上线后计数异常。

FAQ

Q1:AI跳绳一定要用摄像头吗?
不一定。纯 IMU 的智能跳绳只能靠加速度峰值计数,稳定性好但拿不到姿态数据。摄像头方案能同时做计数和动作规范判断,代价是功耗和对光照敏感。

Q2:为什么计数会出现多计或少计?
多计通常来自绳子空转被误识别为起跳,可通过提高 minInterval 和加入踝部关键点交叉校验解决;少计多因关键点被遮挡导致轨迹断裂,可加入卡尔曼或简单线性预测补帧。

Q3:端侧推理和云端推理怎么选?
计数必须端侧做,因为需要实时反馈且涉及隐私视频流。云端更适合做长周期统计、排名聚合和模型迭代训练,不要把逐帧推理放到服务端。

Q4:多端适配成本高吗?
用 uni-app 编写用户端后,小程序、H5、安卓、iOS 可复用大部分逻辑;差异主要集中在摄像头调用、文件系统与蓝牙 API 上,建议把这些能力抽成统一的适配层。

Q5:数据上报丢了怎么办?
采用会话级 UUID + 记录序号的双重幂等设计,端侧离线缓存、联网补传,服务端索引兜底,即可保证终一致。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐