【征文计划】用Rokid眼镜做个AR跳绳助手

5 阅读7分钟

一、 为啥要做这个

跳绳是个挺简单的运动,但有个地方一直觉得别扭:想知道跳了多少个、心率多少,或者间歇训练还剩多久休息,就得停下来看手机,节奏一下就断了。我就想,要是这些数字能一直飘在眼前就好了。手头正好有Rokid Glasses和它的CXR-M SDK开发套件,就决定自己动手试一下。

主要想实现几个功能:

  1. 基础计数:得能准确实时数我跳了多少个。
  2. 心率显示:连上心率手环,数据要能同步显示。
  3. 训练模式:除了随便跳的自由模式,还得支持间歇训练(比如跳45秒休15秒)。
  4. 好玩一点:能跟朋友联网实时PK。

生成特定照片.png

二、 大概怎么实现的

整体思路不复杂:手机当成一个智能计算中心,眼镜当成一个显示屏。

  • 手机干活:运行一个App,负责用手机传感器数跳绳,通过蓝牙接收心率数据,管理训练模式(自由、间歇、PK)的逻辑计时。
  • 眼镜显示:眼镜上运行一个简单的应用,只负责接收数据并把它们画在屏幕上。
  • 连接桥梁:两者通过CXR-M SDK连接,手机把处理好的数据打包发给眼镜。

整个系统的数据流和工作流程大致如下:

哈哈.png

三、 具体怎么做的

1. 数清楚跳了多少个 一开始用简单的波峰检测,发现手稍微晃一下就可能误数。后来改进了算法,结合了加速度计和陀螺仪的数据。简单说,就是只有检测到“往上跳”的加速度变化,同时伴随“摇绳”的旋转动作时,才算一次有效跳跃。这样能过滤掉很多无关的抖动。

2. 心率数据处理 从蓝牙手环读心率数据,关键是要稳。会遇到信号干扰,突然跳出一个离谱的数字(比如从70瞬间跳到200)。我的处理方法是加了个判断:如果新心率值和上次比差别太大,或者超出了合理范围(比如低于40或高于200),就把它扔掉,还是显示上一次的稳定值。虽然可能会延迟一秒更新,但总显示乱跳的数字强。

3. 管理三种训练模式 我用一个“状态机”来管理所有模式,这样代码清楚,不容易乱。

// 状态机的核心定义
public class TrainingStateMachine {
    // 三种模式
    public enum Mode { FREE, INTERVAL, PK }
    // 几种状态
    public enum State { IDLE, WORKING, RESTING, PK_BATTLE }

    private Mode currentMode = Mode.FREE;
    private State currentState = State.IDLE;
    private int setsCompleted = 0; // 当前组数
    private long timeLeftInPhase = 0; // 当前阶段剩余时间

    // 这个方法把要显示的数据打包成一个JSON对象
    public JSONObject packData(int jumpCount, int heartRate) {
        JSONObject data = new JSONObject();
        try {
            // 不管什么模式,核心数据都一样
            data.put("jumps", jumpCount);
            data.put("heartRate", heartRate);
            data.put("mode", currentMode.name());
            data.put("state", currentState.name());

            // 不同模式,再塞点特有的数据
            if (currentMode == Mode.INTERVAL) {
                data.put("sets", setsCompleted + "/" + totalSets);
                data.put("timeLeft", timeLeftInPhase);
            } else if (currentMode == Mode.PK) {
                data.put("opponentJumps", pkOpponentJumps);
                data.put("pkTimeLeft", pkTimeLeft);
            }
        } catch (Exception e) { e.printStackTrace(); }
        return data;
    }
    // ... 这里还有控制状态切换的逻辑 ...
}

用状态机的好处是,逻辑清晰。比如在RESTING状态,计时器归零后自动切回WORKING状态,并开始新一组计数,不容易出错。

4. 联网实时PK功能的核心实现 PK模式是最复杂的,需要和服务器通信。我在服务器上搭了个简单的WebSocket服务,处理匹配和实时数据转发。

手机端PK管理器核心代码:

public class PKBattleManager {
    private WebSocketClient wsClient;
    private String matchId;
    private int myJumps = 0;
    private int opponentJumps = 0;
    private boolean isConnected = false;

    // 开始一场PK
    public void startPKBattle(String opponentId) {
        // 连接WebSocket服务器
        wsClient = new WebSocketClient("ws://yourserver.com/pk") {
            @Override
            public void onMessage(String message) {
                // 处理服务器发来的消息
                processServerMessage(message);
            }
            
            @Override
            public void onConnected() {
                isConnected = true;
                // 发送开始PK的请求
                JSONObject startMsg = new JSONObject();
                startMsg.put("type", "start_battle");
                startMsg.put("opponent_id", opponentId);
                startMsg.put("duration", 60); // PK时长为60秒
                wsClient.send(startMsg.toString());
            }
        };
        wsClient.connect();
    }

    // 处理服务器消息
    private void processServerMessage(String message) {
        try {
            JSONObject msg = new JSONObject(message);
            String type = msg.getString("type");
            
            if ("opponent_update".equals(type)) {
                // 收到对手数据更新
                opponentJumps = msg.getInt("jumps");
                int opponentHeartRate = msg.getInt("heart_rate");
                updatePKDisplay(); // 更新眼镜显示
                
            } else if ("match_result".equals(type)) {
                // 收到比赛结果
                String winner = msg.getString("winner");
                showPKResult(winner);
            }
        } catch (JSONException e) { e.printStackTrace(); }
    }

    // 向服务器发送自己的最新数据
    public void sendMyUpdate(int jumps, int heartRate) {
        if (!isConnected) return;
        
        JSONObject updateMsg = new JSONObject();
        try {
            updateMsg.put("type", "player_update");
            updateMsg.put("match_id", matchId);
            updateMsg.put("jumps", jumps);
            updateMsg.put("heart_rate", heartRate);
            updateMsg.put("timestamp", System.currentTimeMillis());
            
            wsClient.send(updateMsg.toString());
        } catch (JSONException e) { e.printStackTrace(); }
    }

    // 更新眼镜上的PK界面
    private void updatePKDisplay() {
        JSONObject pkData = new JSONObject();
        try {
            pkData.put("mode", "PK");
            pkData.put("state", "PK_BATTLE");
            pkData.put("my_jumps", myJumps);
            pkData.put("opponent_jumps", opponentJumps);
            pkData.put("difference", myJumps - opponentJumps);
            
            // 通过SDK发送给眼镜
            sendToGlasses(pkData);
        } catch (JSONException e) { e.printStackTrace(); }
    }
}

服务器端简单的消息转发逻辑(Node.js示例):

// WebSocket服务器消息处理
wsServer.on('connection', (client) => {
  client.on('message', (message) => {
    const data = JSON.parse(message);
    
    if (data.type === 'start_battle') {
      // 记录匹配信息
      client.matchId = data.match_id;
      client.opponentId = data.opponent_id;
      
    } else if (data.type === 'player_update') {
      // 将一名玩家的数据转发给其对手
      const opponent = findClientById(data.opponent_id);
      if (opponent) {
        opponent.send(JSON.stringify({
          type: 'opponent_update',
          jumps: data.jumps,
          heart_rate: data.heart_rate,
          timestamp: data.timestamp
        }));
      }
    }
  });
});

5. 把数据发给眼镜 用SDK里的“飞传”功能,把上面打包好的JSON数据发过去。

public void sendToGlasses(JSONObject data) {
    FileTransferManager transferManager = FileTransferManager.getInstance();
    String jsonString = data.toString();
    byte[] bytes = jsonString.getBytes();

    // 把数据当成一个虚拟的文件流发过去
    ByteArrayInputStream virtualFile = new ByteArrayInputStream(bytes);
    transferManager.sendFile(virtualFile, "sport_data.json", bytes.length, new FileTransferCallback() {
        @Override
        public void onSuccess(String fileId) {
            // 发送成功
        }
        @Override
        public void onError(String fileId, int errorCode) {
            // 发送失败,可能需要重试
        }
    });
}

这里没生成真实文件,直接在内存里操作,速度快。

6. 眼镜上怎么显示 眼镜端的应用根据收到的数据,决定显示什么。

// 眼镜端应用(JavaScript代码)
FileTransfer.on('file', (file) => {
  if (file.name === 'sport_data.json') {
    const data = JSON.parse(fs.readFileSync(file.path, 'utf8'));
    updateDisplay(data);
  }
});

function updateDisplay(data) {
  let line1, line2;

  // 关键是看当前是什么状态
  if (data.state === 'WORKING') {
    line1 = `计数: ${data.jumps}`;
    line2 = `心率: ${data.heartRate}`;
  } else if (data.state === 'RESTING') {
    // 休息时,倒计时最重要,用大点字号
    line1 = `休息: ${data.timeLeft}s`;
    line2 = `下一组准备`;
  } else if (data.state === 'PK_BATTLE') {
    // PK模式,显示对比
    line1 = `我: ${data.my_jumps} 对手: ${data.opponent_jumps}`;
    line2 = `差: ${data.difference}`;
  }
  // 调用SDK的方法,把文字画到屏幕上
  displayOverlay(line1, line2);
}

这样设计,显示的内容就和训练状态紧密相关,比较智能。

四、 遇到的问题和解决办法

  1. 数据太多眼镜卡:一开始传感器数据来得飞快,眼镜渲染不过来。后来在手机端做了限制,每秒只发5次更新,流畅多了。
  2. 间歇训练计时不准:刚开始发现手机和眼镜的时间有微小偏差。后来让眼镜在每阶段开始时都跟手机对一下时,解决了。
  3. PK模式有延迟:网络传输总有延迟,导致双方数据不同步。我做了个优化:在等网络数据的时候,眼镜先根据自己的节奏显示一个预测值,等真实数据到了再悄悄修正过来,用户就不太容易感觉到延迟了。
  4. PK网络断线重连:WiFi不稳定时PK会断掉。后来加了自动重连机制,断线后尝试重连,并同步最新的比赛状态。

五、 最后的结果和感想

最后做出来的东西,基本能用:

  • 跳绳计数挺准的,正常跳的话,误差很小。
  • 心率显示稳定,不会乱跳。
  • 三种模式都能正常工作,PK模式也挺有趣,实时性还不错。

做这个项目,感觉CXR-M SDK在连接和基础通信上做得不错,省了我很多事。最大的体会是,这种项目难点往往不在多高深的算法,而在于怎么把各个模块(传感器、蓝牙、网络、显示)顺畅地拼在一起,并处理好各种边界情况。

如果以后还有时间,可能想再加个语音提示功能,或者在PK模式里加更多玩法。但现在这个样子,已经能解决我最初“不想低头看手机”的问题了。技术能这样解决点实际的小麻烦,我觉得就挺有价值。