第六部分 · 部署:模型怎么驱动真实的机械臂
训练完了,现在把 adapter 装到真机上跑。
6.1 部署是两端架构
bash
┌──────────── 本机 ────────────┐ ┌────── 服务器 ──────┐
│ 相机 ──┐ │ │ predict_server.py │
│ 主臂 ──┼→ 控制脚本 │ HTTP │ ├ 加载 base+LoRA │
│ └→ POST /predict ────┼──隧道──┼→ ├ 归一化+拼prompt│
│ ←── 50×7 动作 ───────┼────────┼─ └ 反归一化→JSON │
│ 主臂 ← send_action │ │ │
└──────────────────────────────┘ └────────────────────┘
分工:
- 本机:读状态、拍图,POST 出去;收到动作后按 30fps 发给机械臂;
- 服务器:加载模型、归一化、推理、反归一化。
6.2 服务器端核心:四个关键配置
服务器脚本 predict_server.py 里有四个变量,共同决定一次推理:
| 变量 | 值 | 含义 |
|---|---|---|
BASE_DIR |
/root/pi05_b601/models/pi05_base |
基座模型:π0.5 原始预训练权重,冻结不变 |
ADAPTER_DIR |
.../checkpoints/005000/pretrained_model |
LoRA 适配器 :微调出来的增量。换权重就是换这一行 |
STATS_PATH |
.../orange_rack_test_tubes_848/meta/stats.json |
归一化统计:q01/q99 |
TASK |
place two test tubes into the orange rack |
语言指令 |
一句话理解:
BASE_DIR= 谁在干活(基座大脑);ADAPTER_DIR= 学了啥(微调的增量);STATS_PATH= 数值怎么换算;TASK= 任务是什么。
换权重 = 改 ADAPTER_DIR 这一行,其他三个不变(前提是同一任务、同一套统计)。
模型加载的三段式
ini
# 第一段:加载 base
_config = PI05Config.from_pretrained(BASE_DIR, local_files_only=True)
_config.device = 'cuda'
_config.dtype = 'bfloat16'
_base = PI05Policy.from_pretrained(BASE_DIR, config=_config, local_files_only=True, strict=True)
# 第二段:套 LoRA
policy = PeftModel.from_pretrained(_base, ADAPTER_DIR)
policy.eval()
# 第三段:加载 tokenizer + stats
tok = AutoTokenizer.from_pretrained(f'{ADAPTER_DIR}/tokenizer')
with open(STATS_PATH) as f:
_stats = json.load(f)
_state_q01 = np.array(_stats['observation.state']['q01'], dtype=np.float32)
_state_q99 = np.array(_stats['observation.state']['q99'], dtype=np.float32)
_action_q01 = np.array(_stats['action']['q01'], dtype=np.float32)
_action_q99 = np.array(_stats['action']['q99'], dtype=np.float32)
技术要点:
- 第一段
strict=True要求权重字典严格匹配,防止加载残缺模型; - 第二段
PeftModel.from_pretrained把适配器「并联」到冻结的基座上; - 第三段的 stats 提供训练数据的分位数------训练和推理必须用同一套。
6.3 推理链六步(这是整个部署最精妙的地方)
π0.5 的推理流程很特别------它把 7 个连续数值变成了 7 个「词」。
这一节我讲得很细,因为这是整个部署里最容易出错、也最需要理解的地方。
为什么要有这六步?先看问题
模型只会处理「数字」,而且它对数字的范围很敏感。
但我们的数据是角度:可能是 -200°、也可能是 +145°。范围很大,而且不同关节的范围还不一样。
如果直接把角度喂给模型会怎样?
举个例子。假设两个关节:
- 关节 A 平时在 -30° ~ +30° 之间变化;
- 关节 B 平时在 -200° ~ +100° 之间变化。
如果直接喂:
ini
关节 A = 15 (它自己范围内的中间值)
关节 B = -200 (它自己范围内的最小值)
模型看到的是两个数字:15 和 -200。它无法知道「15 对 A 来说是中间,-200 对 B 来说是最小」------因为这两个数字的量级差太大了。
这就是为什么要归一化:把每个关节的值都映射到同一个标准范围,模型才能「公平地」看待每一个关节。
第 1 步 · 状态翻转
ini
s = STATE_FLIP * state_deg # STATE_FLIP = -1.0
这一步是纯粹取负(乘以 -1)。
把本机读到的 7 个关节角度整体取负。
为什么要取负? 这是整个项目最微妙的地方,我在 6.5 节会用一整节推导。
现在你只需要知道:因为你的机械臂「读数的方向」和「训练数据的方向」正好相反。所以喂给模型之前,要把它翻回来。
举例:
css
本机读到: [15.0, -40.0, -30.0, 10.0, 0.0, 15.0, 20.0]
取负之后: [-15.0, 40.0, 30.0, -10.0, 0.0, -15.0, -20.0]
(注意 0.0 取负还是 0.0)
⚠️ 注意 0.0 这个细节 :如果关节正好在零位,取负不变。这就是为什么「在零位验证方向」是无效的------见 6.5。
第 2 步 · 分位数归一化
ini
s_norm = 2.0 * (s - q01) / (q99 - q01) - 1.0 # 落到 [-1,1]
这一步把任意角度,线性映射到 -1, 1 这个标准区间。
这个公式在干什么?拆开看:
scss
(s - q01) → 先把起点挪到 0
/(q99 - q01) → 再缩放,让范围变成 [0,1]
*2 - 1 → 最后平移,让范围变成 [-1,1]
一个具体例子。 假设某个关节的 q01 = -30、q99 = 90:
| 输入角度 | 计算过程 | 结果 |
|---|---|---|
-30(最小值) |
2*(-30-(-30))/(90-(-30))-1 = 2*0/120-1 |
-1.0 |
30(中间) |
2*(30-(-30))/120-1 = 2*60/120-1 |
0.0 |
90(最大值) |
2*(90-(-30))/120-1 = 2*120/120-1 |
+1.0 |
-200(超出范围) |
2*(-200+30)/120-1 = 2*(-170)/120-1 |
-3.83(被截到 -1) |
看到那个超范围的例子了吗? 如果输入值超出了 q01~q99 的范围,算出来的绝对值会大于 1。这就是为什么下一步要「截断」。
💡 为什么用
q01/q99(第 1% 和 99% 分位数),而不是最小/最大值?因为分位数对异常值不敏感。
想象你录数据时,有一次机械臂瞬间抖了一下,读到了 -350°(这是个异常值,不该出现)。如果用最小值归一化,那 -350 就成了「下界」,导致所有正常数据被压到很小的范围里。
而
q01是「第 1% 位置的值」------它不受这一个异常点影响。
第 3 步 · 离散化成 256 桶
ini
s_disc = np.digitize(s_norm, np.linspace(-1, 1, 257)[:-1]) - 1
s_disc = np.clip(s_disc, 0, 255)
这是 π0.5 最核心的设计。 我来拆解。
np.linspace(-1, 1, 257)[:-1] 在干什么?
它生成从 -1 到 1 之间等距的 256 个边界点:
css
[-1.0, -0.992, -0.984, ..., 0.984, 0.992] (去掉最后一个 1.0)
np.digitize 在干什么?
它找出「这个值落在第几个桶里」,返回桶的编号(0~256)。
- 1 和 np.clip(0, 255) 在干什么?
把编号调整为 0~255 的整数,超出范围的截断。
一个具体例子。 假设归一化后的值是 0.0:
0.0 落在第 128 个桶(正中间)
→ 桶编号 128
→ 结果就是整数 128
假设归一化后的值是 -1.0(最小):
→ 桶编号 0
假设是 +1.0(最大):
→ 桶编号 255
所以整个流程本质上是:
角度 15.0 → 归一化 -1.0 ~ +1.0 → 整数 0 ~ 255
为什么要把数字变成「词」?
这是 π0.5 最聪明的地方,值得单独讲。
传统的做法 :把状态作为一个单独的数字输入,喂给模型。
π0.5 的做法 :把状态变成一个字符串,和语言指令拼在一起。
vbnet
Task: place two test tubes into the orange rack, State: 128 40 200 100 128 3 127;
Action:
为什么这样更好?
因为 π0.5 的骨架(PaliGemma)本来就是处理文字的。它有一套「词表」------比如「cat」是一个词、「robot」是一个词。
现在 π0.5 把「0~255 的整数」也加进了词表。所以对它来说:
128就是一个「词」;- 和
robot一样,都是词表里的一个条目。
这样做的巨大好处:模型可以用同一套机制,同时处理「语言」和「数值」。
它不再需要两套不同的输入通道(一套给语言、一套给数值)。所有东西都是「词」,统一处理。这也是为什么 π0.5 能理解「State: 128」和「Task: place tubes」之间的关系------它们在同一段文字里。
💡 用人话总结这一步:π0.5 把「机械臂现在在什么姿势」翻译成了一句「机器人能读懂的外语」,然后和任务指令拼成一句话,一起喂给它。
第 4 步 · 拼 prompt
ini
state_str = " ".join(map(str, s_disc.tolist()))
prompt = f"Task: {TASK}, State: {state_str};\nAction: "
enc = tok(prompt, return_tensors='pt', padding='max_length', max_length=200, truncation=True)
拼出来的 prompt 长这样:
vbnet
Task: place two test tubes into the orange rack, State: 0 128 255 64 100 3 127;
Action:
注意几个细节:
| 细节 | 说明 |
|---|---|
Task: 开头 |
固定的格式标记 |
| 逗号分隔任务和状态 | Task: XXX, State: YYY |
| 分号结尾 | State: ...; |
\nAction: |
换行 + Action: + 一个空格,提示模型「接下来该输出动作了」 |
| 7 个数字用空格分隔 | 就是 7 个关节的离散值 |
为什么结尾是 Action: ?
这是一个提示(prompt)技巧。模型是「续写」型的------你给它一段话,它接着往下写。
所以 Action: 就像一个「填空题的开头」,模型会接着往后面写「动作数字」。
tokenize 在干什么?
把这段文字拆成 token(词元),并且补齐到固定长度。
| 参数 | 含义 |
|---|---|
return_tensors='pt' |
返回 PyTorch 张量格式 |
padding='max_length' |
补齐到固定长度 |
max_length=200 |
固定长度是 200 |
truncation=True |
太长了就截断 |
为什么要补齐到 200? 因为模型要求固定长度的输入。不够长的用特殊「填充 token」补上。
这是 π0.5 的硬性规格,不能改。
第 5 步 · 图像预处理 + 模型推理
先看图像怎么处理:
scss
def decode_image(b64):
data = base64.b64decode(b64)
img = Image.open(io.BytesIO(data)).convert('RGB')
arr = np.array(img, dtype=np.float32) / 255.0 # [H,W,3] ∈ [0,1]
arr = arr.transpose(2, 0, 1) # [3,H,W]
return torch.from_numpy(arr).unsqueeze(0).to('cuda') # [1,3,H,W]
逐行拆解:
| 行 | 在做什么 | 为什么 |
|---|---|---|
base64.b64decode(b64) |
把 base64 字符串解码回字节 | 因为网络传输的是文本,图要转成 base64 才能放进 JSON |
Image.open(...).convert('RGB') |
读成图片,强制转 RGB | 统一格式(有些相机是 BGR、有些是灰度) |
np.array(img) / 255.0 |
转成数组,除以 255 | 把像素 0 |
.transpose(2, 0, 1) |
把 [高,宽,通道] 变成 [通道,高,宽] |
PyTorch 的格式要求 |
.unsqueeze(0) |
加一个维度 | 变成 [1,通道,高,宽],1 是 batch 维(因为一次处理 1 个样本) |
.to('cuda') |
搬到 GPU | 模型在 GPU 上 |
为什么要 /255.0? 因为图像像素原始值是 0255 的整数,除以 255 变成 0 1 的浮点数。这是标准的图像预处理做法。
那为什么不是 -1,1? 因为 π0.5 内部会自己做 img*2-1 转换。所以脚本层只需要给 0,1。
为什么要 transpose?
这是两个不同库的「习惯」不同:
- OpenCV / PIL:
[高, 宽, 通道] - PyTorch:
[通道, 高, 宽]
就像有人写地址从「省」开始,有人从「街道」开始------得转换。
再看模型推理:
ini
batch = {
'observation.images.base_0_rgb': front,
'observation.images.left_wrist_0_rgb': wrist,
OBS_LANGUAGE_TOKENS: tokens,
OBS_LANGUAGE_ATTENTION_MASK: mask,
}
with torch.inference_mode():
actions = policy.base_model.predict_action_chunk(batch) # (1, 50, 32)
逐个 key 解释:
| key | 内容 | 说明 |
|---|---|---|
observation.images.base_0_rgb |
俯瞰相机图 | 名字必须精确 ,写错会报 image features missing |
observation.images.left_wrist_0_rgb |
腕部相机图 | 同上 |
OBS_LANGUAGE_TOKENS |
prompt 的 token id | 文字拆成数字后的结果 |
OBS_LANGUAGE_ATTENTION_MASK |
注意力掩码 | 告诉模型「哪些位置是真词、哪些是填充」 |
为什么 key 名这么怪? 因为这是 π0.5 的内部约定,不能改。
base_0_rgb 的意思是「基础视角、第 0 个、RGB 格式」;left_wrist_0_rgb 是「左腕部视角、第 0 个、RGB」。
⚠️ 这是一个高频报错点 :你的数据集里字段叫
front/wrist,但模型的 batch 里必须叫base_0_rgb/left_wrist_0_rgb。这就是为什么训练时要用--rename_map,推理时要在代码里手动指定 key 名。
torch.inference_mode() 是什么?
它告诉 PyTorch:「现在是推理,不是训练,不要记录梯度」。
为什么要这么做? 因为记录梯度需要额外的内存和时间。推理时不需要梯度,所以关掉它------省内存、更快。
输出 (1, 50, 32) 是什么意思?
ini
1 = batch 大小(一次处理 1 个样本)
50 = 动作块长度(一次预测未来 50 帧)
32 = 动作维度上限(模型能输出 32 维)
⚠️ 关键:只有前 7 维是真实关节。 后 25 维是填充------因为 π0.5 是通用模型,要支持最多 32 维的机器人(比如双臂 + 双手 + 更多关节)。我们只有 7 个关节,所以后 25 维没用。
第 6 步 · 动作反归一化
scss
actions = actions[0, :, :7].float().cpu().numpy() # (50, 7)
actions_deg = (actions + 1.0) / 2.0 * (_action_q99 - _action_q01) + _action_q01
第一行:切片。
[0, :, :7] 的意思是:
0:去掉 batch 维(取第 0 个样本);::保留全部 50 帧;:7:只取前 7 维,丢掉后面 25 维的填充。
结果变成 (50, 7) ------ 50 帧 × 7 个关节。
第二行:反归一化。
这是第 2 步的逆运算:
scss
第 2 步:s_norm = 2 * (s - q01) / (q99 - q01) - 1 (度数 → [-1,1])
第 6 步:s = (s_norm + 1) / 2 * (q99 - q01) + q01 ([-1,1] → 度数)
验证一下它们互为逆运算:
把第 2 步的结果代入第 6 步:
scss
((2*(s-q01)/(q99-q01) - 1) + 1) / 2 * (q99-q01) + q01
= (2*(s-q01)/(q99-q01)) / 2 * (q99-q01) + q01
= (s-q01) + q01
= s ✓
所以要记住:训练和推理必须用同一套 q01/q99。 如果训练时用 A 的统计,推理时用 B 的统计,这个逆运算就错了,动作全乱。
注意:动作不翻转。 这里没有乘以 STATE_FLIP。
为什么状态翻转、动作不翻转? 因为:
- 状态是「本机读到的」,方向是本机的,所以要翻成训练约定;
- 动作 是「模型输出的」,方向已经是训练约定,而训练约定和本机的
send_action输入一致,所以直接用。
这个推导在 6.5 节详细讲。
六步总结(一张图记住)
bash
本机读到的 7 个角度
↓ ① 取负(翻回训练约定)
↓ ② 归一化到 [-1,1](按 q01/q99)
↓ ③ 离散化成 0~255 整数
↓ ④ 拼成 prompt 文字
↓ ⑤ 模型推理(图 + 文字 → 动作)
↓ ⑥ 反归一化回角度(前 7 维)
7×50 个动作角度
6.4 HTTP 接口规格
python
class PredictRequest(BaseModel):
state: list[float] # 7 关节角(度,本机原始值)
front: str # 俯瞰相机 JPEG base64
wrist: str # 腕部相机 JPEG base64
@app.post('/predict')
def predict_endpoint(req: PredictRequest):
action_chunk, disc_state, prompt = predict(req.state, req.front, req.wrist)
return {'action_chunk': action_chunk, 'state_discretized': disc_state, 'prompt': prompt}
请求体 :{"state":[7个数], "front":"<base64>", "wrist":"<base64>"} 响应体 :{"action_chunk":[[50行×7列]], "state_discretized":[7个整数], "prompt":"..."}
state_discretized 和 prompt 是调试用回显 (方便验证归一化/离散化对不对),真正用的只有 action_chunk。
6.5 方向约定:STATE_FLIP = -1.0 的完整推导
这是整个部署最微妙、也最容易搞错的一点。
结论先说:状态(state)取负喂给模型,动作(action)不翻转直接发。
如果你在别处看到这段代码而不知道为什么要取负,接口对不上你就找不出原因。这一节我会从头推。
先搞清楚:什么是「原始量」和「逻辑量」
这是理解一切的起点。
你的机械臂有两个「角度」的概念,它们不一样:
| 概念 | 谁产生 | 含义 |
|---|---|---|
| 原始编码器角度(物理量) | get_observation() |
电机编码器实际读到 的角度,没有经过任何方向变换 |
| 逻辑量 | send_action() 的输入 |
你「想要」的角度,send_action 内部会先乘 joint_directions 再发 |
为什么要有这个区分? 因为实际机械臂的电机装的方向不一定统一。
举个具体例子,这很重要。
假设 shoulder_pan 这个关节。你希望「正数 = 往左转」。但实际上:
- 电机 A 装的时候,正转就是往左 → 方向系数是
+1 - 电机 B 装的时候,正转是往右 → 方向系数是
-1
如果不管方向,直接发角度,那同一句「转到 15 度」,装在 A 上的臂往左、装在 B 上的臂往右。整个系统就乱了。
解决办法 :代码里维护一张表 joint_directions,告诉驱动「每个关节要把指令乘上哪个系数」:
yaml
joint_directions = {
shoulder_pan: -1.0, # 电机装反了,所以要乘 -1
shoulder_lift: -1.0,
elbow_flex: 1.0, # 电机装正了,不用乘
wrist_flex: 1.0,
wrist_yaw: 1.0,
wrist_roll: -1.0,
gripper: -6.0 # 注意这个不是 ±1,是 -6
}
send_action 内部做的事:
css
逻辑量(你想要的角度)
↓ × joint_directions(把「想要」翻译成「电机能懂的」)
↓ clip 到 joint_limits(防止超出安全范围)
↓ 转成弧度
↓ 发 CAN 帧给电机
物理运动
而 get_observation() 返回的是「原始编码器角度」------它没有乘这个系数。
💡 一句话 :
get_observation()给你「电机的实际读数」,send_action()收「你想要的逻辑角度」。这两个之间隔着一张joint_directions表。
现在看训练数据:它的方向和我们的不一样
我们要用 orange_rack_test_tubes_848 这个数据集训练。
关键发现:这个数据集的 joint_directions,正好是我们的负值。
也就是说,如果我们的臂 shoulder_pan 的方向系数是 -1,数据集里这个关节的方向系数是 +1。
为什么会这样? 因为这个数据集是从别的机械臂型号(RS 臂)的格式转换过来的。转换的时候,方向约定和我们的 DM 臂相反。
这意味着什么?用一个表格说清楚:
| 我们的臂(DM) | 训练数据集(RS) | |
|---|---|---|
shoulder_pan 方向系数 |
-1.0 |
+1.0(我们的负值) |
shoulder_lift 方向系数 |
-1.0 |
+1.0 |
| 读到的状态值 | 物理读数 | 物理读数的相反数 |
所以:模型学到的是「看到 RS 约定的状态,输出 RS 约定的动作」。
推导第一步:状态为什么要取负
模型是在 RS 的约定下学的。它学会的规律是:
css
「看到 状态 X_rs,就输出 动作 A」
但我们现在喂给它的,是 DM 的原始读数 X_dm。
而我们知道 X_rs = -X_dm(因为方向系数相反)。
所以如果我们直接喂 X_dm,模型会以为现在状态是 X_dm------但它训练时从没见过这种约定下的状态。
怎么办?把它翻回去:
ini
s = STATE_FLIP * state_deg # STATE_FLIP = -1.0
# 相当于 s = -X_dm = X_rs ✓ 翻回训练约定
所以状态取负,是为了「把我们臂的读数,翻译成模型认识的那种约定」。
一个具体数字例子:
ini
我们的臂读到 shoulder_pan = 15.0(DM 约定)
↓ 取负
变成 -15.0(RS 约定)
↓ 喂给模型
模型看到 -15.0,觉得「哦,这和训练时见过的某个状态一样」
如果方向搞反了会怎样?
markdown
我们的臂读到 15.0
↓ 错误地不取负
喂给模型 15.0
↓
模型以为「现在机械臂在 -15.0 的位置」(在它的约定里)
↓
它输出的动作,是「从 -15.0 出发该怎么做」
↓
但实际机械臂在 +15.0,所以动起来完全不对
结果就是机械臂朝着完全错误的方向动。 这就是为什么这个翻转必须做对。
推导第二步:动作为什么不用翻转
模型输出的动作,已经在 RS 的约定里了。
因为我们刚才喂给它的状态是 RS 约定的,它输出的动作自然也是 RS 约定的。
关键问题:RS 约定的动作,能不能直接发给我们的 DM 臂?
答案是:能。 原因有点绕,我讲清楚:
send_action() 的输入是逻辑量 ,它内部会乘 joint_directions(我们的 DM 表)。
而模型输出的动作,虽然是在 RS 约定下学的,但训练数据集的 action 字段本身就是「逻辑量」------也就是说,训练时录的 action,是「RS 臂想要的角度」,也就是 RS 的逻辑量。
而 RS 的逻辑量 = DM 的逻辑量(因为两者的 joint_directions 是负值关系,但**「逻辑量」的定义本身就是相对的**------它表示「相对于这个臂,转到哪个位置」,这个语义两者是通用的)。
用人话讲:
状态是「物理读数」,两个臂的物理读数方向相反,所以必须翻译。
动作是「我想要它去哪」,这个语义两个臂是一样的,所以不用翻译。
一个更实用的验证理解:为什么零位验证无效
这是新手最常犯的验证错误。
你可能想:「我就让机械臂停在零位,跑一下干跑,看看预测的动作对不对。」
这个验证是无效的。 因为:
perl
零位时:state ≈ 0.0
取负: 0.0 × (-1) = 0.0 ← 和取正一样!
零位取负还是零位,你根本区分不出方向对不对。
正确的验证方法:把臂摆到「非零位姿」。
具体操做:
- 手动把臂摆成一个明显的姿势------比如抬大臂 + 弯肘 ,让
shoulder_lift和elbow_flex明显偏离 0; - 跑干跑:
arduino
python run_pi05_robot.py --dry-run
- 看输出的「预测首动作」,和「当前状态」对比:
ini
[step 0] 当前状态: [-15.7, -40.0, -30.0, 10.8, -0.1, 0.1, 0.1]
预测首动作: [17.6, 40.0, -68.8, 17.1, -8.6, 12.4, 1.0]
怎么判断?
关键在于:预测的首动作,应该和「当前逻辑角度」量级接近 (逻辑角度 = 原始读数 × joint_directions,不是屏幕上打印的原始读数)。
⚠️ 一个容易搞错的点 :屏幕上打印的「当前状态」是原始读数 (未乘方向系数),而模型输出的动作在逻辑量空间 。所以两者的符号可能相反------这是正常的,不能只看符号判断对错。
以示例输出为例:当前状态 [-15.7, -40.0, ...],预测首动作 [17.6, 40.0, ...]------符号相反是符合预期的。判断标准是「预测动作是否落在合理的逻辑量范围内」,而不是与原始读数逐位比对。
如果方向反了,你会看到什么?
预测的动作会和当前状态差得很远,而且方向相反 。比如当前是 -40.0,预测首动作是 +40.0 ------那就是明显的反向信号。
发现方向反了怎么办?
改服务器脚本里这一行:
ini
STATE_FLIP = -1.0 → STATE_FLIP = 1.0
然后重启服务器。
推导第三步:为什么夹爪是 -6.0 而不是 ±1
你可能注意到 joint_directions 里,夹爪是 -6.0,而其他都是 ±1。
这个 -6 是「方向 + 传动比」的组合。
什么意思?
夹爪电机和夹爪本身之间有一个减速/传动机构。电机转 1 度,夹爪不一定开合 1 度------可能是 6 倍的关系。
所以:
- 负号:方向(装反了);
6:传动比(电机转 6 度 = 夹爪动 1 度)。
这个 -6 由 send_action 内部消化,脚本层不用管。 你在桥接代码里正常给「逻辑角度」就行。
完整的方向约定总结
scss
┌─────────────────────────────────────────────────────┐
│ 状态(state): │
│ 本机读数(DM 原始物理量) │
│ → × STATE_FLIP (-1.0) → RS 约定 │
│ → 归一化 → 离散化 → 进 prompt │
│ │
│ 动作(action): │
│ 模型输出(RS 约定) │
│ → 反归一化 → 直接发(不翻转) │
│ → send_action 内部 × joint_directions(DM) │
│ → 物理运动 │
└─────────────────────────────────────────────────────┘
一句话记住:状态要「翻译」进来,动作不用「翻译」出去。
⚠️ 这一步必须真机验证的原因
方向约定是唯一一个「光看代码推不出来、必须真机确认」的项。
为什么?因为你无法从代码里 100% 确定:
- 数据集的方向系数到底是不是我们臂的负值?(这是从数据推断的)
- 我们的臂实际接线是不是和 config 里写的一致?
所以每次「重新校零后」,都要复验一次方向。 因为校零可能改变零点,间接影响你的判断。
验证流程:
bash
# 1. 摆到非零位姿(关键!)
# 2. 干跑
python run_pi05_robot.py --dry-run
# 3. 看「当前状态」和「预测首动作」的关系
# 4. 判断:接近 = 对;明显相反 = 错,改 STATE_FLIP
6.6 本机端控制脚本
相机读取(三个技术点)
python
def open_cam(idx, w, h):
cap = cv2.VideoCapture(idx, cv2.CAP_V4L2)
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG')) # ① 强制 MJPG
cap.set(cv2.CAP_PROP_FRAME_WIDTH, w)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h)
for _ in range(6): # ② 预热 6 帧
cap.read(); time.sleep(0.05)
return cap
def read_frame(cap, n=5): # ③ 容错重读
for _ in range(n):
ok, frame = cap.read()
if ok and frame is not None: return frame
time.sleep(0.05)
return None
- ① 强制 MJPG:不设 fourcc 时 OpenCV 自动探测可能选错格式;
- ② 预热 6 帧:V4L2 相机刚打开时前几帧不稳定;
- ③ 容错重读:读 5 次,任何一次成功就返回。
状态读取(7 关节顺序)
ini
JOINTS = ['shoulder_pan', 'shoulder_lift', 'elbow_flex',
'wrist_flex', 'wrist_yaw', 'wrist_roll', 'gripper']
obs = robot.get_observation()
state = np.array([obs[f'{j}.pos'] for j in JOINTS], dtype=np.float32)
动作执行(帧率控制)
ini
chunk = resp.json()['action_chunk'] # (50, 7) 度
for row in chunk:
action = {f'{j}.pos': float(row[i]) for i, j in enumerate(JOINTS)}
robot.send_action(action)
time.sleep(0.033) # ≈ 30fps
一个 chunk 50 帧,每帧发一个动作、sleep 0.033s,总共约 1.65s。
为什么是 30fps? 要和训练数据的动作采样率一致。否则模型预测的「时间轨迹」和实际执行对不上。
send_action 内部做什么?
css
×joint_directions → clip 到 joint_limits → 转弧度 → 按 motor_can_ids 发 CAN 帧
6.7 端到端一次推理的完整时序
以 run_pi05_robot.py 跑一步为例:
ini
t0 本机: robot.get_observation() → 7 关节角(度) [~1ms]
t1 本机: read_frame(front/wrist) → 两张 BGR 图 [~30ms]
t2 本机: encode_jpeg → base64 [~10ms]
t3 本机: POST /predict,JSON 体 ~100KB [隧道 ~5ms]
t4 服务器: 状态→翻转→归一化→离散化→拼 prompt→tokenize [~5ms]
t5 服务器: 两张图 base64→JPEG→[0,1]张量 [~10ms]
t6 服务器: 模型推理 predict_action_chunk [~300-800ms ← 占大头]
t7 服务器: 动作反归一化 → (50,7) → JSON 响应 [~5ms]
t8 本机: 收到 action_chunk,进入 50 帧执行循环
t9 本机: 每帧 send_action + sleep(0.033),共 ~1.65s
t10 回到 t0 读新状态(下一 chunk)
关键观察:推理延迟(t6)远小于执行时间(t9)。 所以「分块闭环」里推理不卡顿,执行是主体。
6.8 关于「实时性」的澄清
我之前以为系统是「边推理边执行」的流水线。实际上不是。
当前是分块闭环控制(chunk-level closed-loop) :
模型一次预测 50 帧 → 执行完这 50 帧(约 1.65s)→ 重新读状态和图像 → 预测下一个 50 帧
每个 chunk 之间,臂会停顿一下等下一次推理。
如果要真正的流水线(执行第 N 块时并行推理第 N+1 块),需要改脚本------理论上可以并行 t9 和下一块的 t4~t6。这个我没做。
6.9 部署验证:从假到真,四层递进
这是我最推荐的方法论------不要一上来就连真机。
第 1 层 · 冒烟测试(不碰真机)
perl
python test_predict.py --state "5,-40,-30,10,0,15,20"
验证:① 离散化状态在 0~255 合理范围;② prompt 格式正确;③ action_chunk 形状 (50,7)、度数落在 q01/q99 内;④ 真图 vs 灰图动作明显不同(说明视觉生效)。
第 ④ 点最重要 ------如果发真图和发灰图,模型输出几乎一样,说明视觉根本没读进去,要查图像格式。
bash
python test_predict.py # 真图
python test_predict.py --front /nonexistent --wrist /nonexistent # 灰图
两次对比。
第 2 层 · 干跑(连真机但不动)
arduino
python run_pi05_robot.py --dry-run
验证:读状态、读相机、调服务器全链路通,但不发动作。
第 3 层 · 真机执行(非零位姿,看方向)
css
python run_pi05_robot.py --steps 1
先摆到非零位姿,看预测首动作方向对不对(验证 STATE_FLIP)。
第 4 层 · 评估
css
python eval_pi05_robot.py --episodes 10 --steps-per-episode 5
第七部分 · 深入原理:动作块、流匹配与控制频率
前面六部分讲了"怎么做"。这一部分讲为什么这么设计------如果你要读论文、改代码,或者理解为什么模型会失败,这一部分是必需的。
7.1 为什么是"动作块(action chunk)"而不是"单步动作"
传统的行为克隆(behavior cloning)做法是:模型每次预测一个动作,执行,再预测下一个。
markdown
传统做法(单步):
观测 → 模型 → 动作1 → 执行 → 观测 → 模型 → 动作2 → 执行 → ...
↑
每一步都要推理
π0.5 用的是动作块(action chunking) :一次预测50 个动作,然后连续执行完。
css
π0.5 做法(动作块):
观测 → 模型 → [动作1..动作50] → 连续执行 50 步 → 重新观测 → ...
↑
这一段不需要推理
为什么要这样做?有三个理由:
理由一:推理延迟与实时性的矛盾
推理一次要 300~800ms(见 6.7 节时序)。但机械臂需要 30fps 的控制频率(每帧 33ms)。
如果每一步都推理:
需要 33ms 出一帧,但推理要 500ms
→ 实际控制频率降到 2Hz
→ 机械臂动起来一顿一顿的,根本无法完成精细操作
如果用动作块:
推理一次 500ms,得到 50 帧
然后以 30fps 连续执行 50 帧(1.65 秒)
→ 这 1.65 秒内不需要推理,控制频率稳定在 30fps
动作块把"推理延迟"摊薄到了"整块执行时间"上。
理由二:时序一致性与多解性
机器人任务存在多解性:从同一个起始状态,可以有多种到达目标的方式。
css
抓试管:
路径 A:从左边绕过去抓
路径 B:从右边绕过去抓
两者都能成功
如果你每次只预测一个动作,模型可能在 A 和 B 之间反复横跳------因为它每一步独立决策,可能在中间某一步"改变主意",导致动作不连贯。
动作块的好处 :一次预测一整条轨迹,在块内保持时序一致性。模型在预测时就"选定"了一条路径,块内不会中途变卦。
理由三:隐含了"未来信息"
π0.5 的动作专家是一个流匹配(flow matching) 模型,它在生成动作块时,可以"看到"整块的全局结构。
arduino
单步预测:模型只知道"现在该往哪动一小步"
动作块: 模型知道"接下来 50 步的完整轨迹"
→ 可以规划出更平滑、更符合任务目标的路径
代价:动作块也有缺点------如果环境在执行过程中发生了模型没预料到的变化,模型要等这一块执行完才能反应。这是"反应速度"和"轨迹质量"之间的权衡。
7.2 流匹配(flow matching)与动作生成
π0.5 的动作专家用流匹配生成动作,而不是简单的回归。这是理解模型行为的关键。
先看朴素的回归方法有什么问题
假设你直接用均方误差(MSE)训练模型预测动作:
损失 = ||预测动作 - 真实动作||²
问题:面对多解性时,MSE 会"取平均"。
ini
如果从状态 S 出发,有两种正确动作 A1 和 A2
(比如 A1 = 从左边走,A2 = 从右边走)
MSE 训练出的模型会输出:(A1 + A2) / 2 ← 两种答案的平均
这个"平均动作"通常既不是 A1 也不是 A2
→ 如果 A1 和 A2 方向相反,平均下来接近 0
→ 机械臂原地不动!
这是行为克隆的一个经典失败模式。
流匹配怎么做
流匹配属于生成模型(和 diffusion 模型同族)。它的核心思路是:
从噪声出发,逐步"流"向真实动作。
css
训练阶段:
取真实动作 A
加入噪声:A_noisy = A + 噪声
训练网络:给定 A_noisy 和"噪声程度",预测"该往哪走才能回到 A"
推理阶段:
从纯噪声出发
迭代多步:每一步都用网络预测的方向,走一小步
最终收敛到真实动作
用直觉理解:
想象你在一个有雾的山上,要下山。 你不知道山脚在哪(多解性),但你知道"哪边是下坡"。
流匹配就是:先随机乱走几步(噪声起点),然后每一步都问"往哪边走更符合真实动作的分布",一步步走到一个合理的目的地。
流匹配为什么能解决多解性
因为它的目标是学习动作的"分布" ,而不是输出"平均值"。
arduino
回归方法:输出一个"平均动作"(可能都不对)
流匹配: 输出"分布中的一个样本"(是某个具体的、合理的动作)
结果:模型会输出 A1 或者 A2(都是对的),而不是它们的中点。
这对推理的实际影响
π0.5 的动作生成是迭代的------它不是一个前向 pass 出结果,而是要跑若干步去噪。
这解释了:
- 为什么推理要 300~800ms(迭代多步,每步都要过一遍网络);
- 为什么
predict_action_chunk这个函数名带 "chunk" (一次生成一整块)。
💡 你不需要能推导流匹配的数学。但理解"它是生成模型、迭代生成、能表达多解性"这三点,就能解释模型为什么有时候输出很奇怪的轨迹(采样到了分布边缘),也能理解为什么调低推理步数会变快但可能变差。
7.3 分块闭环(chunk-level closed loop)与执行中断
回到内外两层闭环的"内层"。当前实现是分块闭环:
bash
while 任务未完成:
观测 = 读状态 + 拍图
动作块 = 模型推理(观测) # 50 帧
for 动作 in 动作块: # 执行整块
send_action(动作)
sleep(0.033) # 30fps
这个结构的"盲区"
执行这 50 帧的 1.65 秒内,模型是"瞎"的------它不观测、不调整。
ini
时间轴:
t=0 观测 + 推理(500ms)
t=0.5s 开始执行
┌──────────────────────────────────┐
│ 执行 50 帧,1.65 秒 │
│ 模型"闭眼",无法应变 │
└──────────────────────────────────┘
t=2.15s 执行完,重新观测
如果这 1.65 秒内环境变了(试管被碰歪、物体滑动),模型不会察觉,会继续执行原计划的剩余动作。
更高级的方案:异步推理(policy_server + robot_client)
Seeed 的两端部署架构支持异步:
GPU 服务器:policy_server 持续推理
机械臂端: robot_client 持续执行 + 上传观测
这个架构可以做到"执行第 N 块的同时,推理第 N+1 块":
markdown
时间轴(异步):
推理块1 ──┐
│ 执行块1 ──┐
│ │ 推理块2 ──┐
│ │ │ 执行块2 ──┐
...
参数(来自 Seeed 文档):
| 参数 | 含义 |
|---|---|
--actions_per_chunk |
每次推理多少帧动作 |
--chunk_size_threshold |
剩余动作低于这个比例时,触发下一次推理 |
--fixed_update_fps |
固定执行频率 |
chunk_size_threshold=0.5 的意思是:当这一块动作执行了一半时,就开始推理下一块。这样在"块切换"处不会停顿。
这是 robotics 里的实时性问题 ,也是研究热点(比如训练侧的
real-time chunking方法)。理解了这个权衡,你就能判断"什么时候该用分块闭环、什么时候需要异步"。
7.4 控制频率为什么是 30fps
训练数据的动作采样率,必须和推理执行频率一致。
bash
训练时:数据是按 30fps 录的
→ 数据集里相邻两帧的时间差 = 33.3ms
→ 模型学到的"动作序列"隐含了这个时间尺度
推理时:执行也是 30fps(sleep 0.033)
→ 相邻动作的时间差 = 33.3ms
→ 和训练一致 ✓
如果两者不一致会怎样?
arduino
假设训练是 30fps,你用 15fps 执行:
模型说"这一块要 1.65 秒完成"
你实际花了 3.3 秒
→ 机械臂动作变慢一倍
→ 平滑的轨迹变成"慢动作"
→ 精细操作(比如对准试管口)失败
arduino
假设训练是 30fps,你用 60fps 执行:
模型说"这一块要 1.65 秒"
你 0.8 秒就跑完了
→ 机械臂动作快一倍
→ 可能因为太快而震荡、超调、撞东西
所以 time.sleep(0.033) 不是随便写的------它是和训练数据的 fps 绑定的。
⚠️ 这也解释了 4.10 节那个"录制必须 640×480"的坑:录制的 fps 必须真的是 30,否则训练数据的时间尺度就错了。
7.5 数据量、步数与真机成功率的关系
这是最实际的一个问题:我需要多少数据、训练多少步?
定性的规律
数据量 ↑ → 泛化能力 ↑(但边际递减)
步数 ↑ → 拟合程度 ↑(但过多会过拟合)
画成曲线:
markdown
成功率
│
│ ┌──────── ← 全量微调 / 大量数据
│ ┌────┘
│ ┌────┘
│ ┌────┘ ← LoRA / 中等数据
│ ┌───┘
│ ┌───┘ ← 少量数据(当前情况)
│ ┌─┘
└─┴──────────────────────────────────→ 数据量
10 50 100 500 1000+
在当前项目里,我们的位置是最左端:
| 项 | 值 | 评价 |
|---|---|---|
| 公开数据集 episode 数 | 16 | 很少 |
| 之前训练的步数 | 1000~4000 | 偏少 |
| 任务难度 | 放试管(精细操作) | 高 |
三者叠加 = 真机成功率极低(我的评估日志:12 次 1 成功)。
为什么"放试管"特别吃数据
任务的精细程度决定了数据需求:
arduino
粗粒度任务(数据需求低):
"把瓶子推倒"
→ 力大砖飞,碰一下就行,容错高
中粒度任务(数据需求中):
"把方块抓起来"
→ 需要对准,但方块大、容错中等
精细任务(数据需求高):
"把试管插进架子的孔里" ← 我们做的是这个
→ 孔位小、试管细,容错极低
→ 需要覆盖大量"对准"的微妙变化
经验法则
| 任务类型 | 建议 episode 数 |
|---|---|
| 简单(碰触、推) | 20~50 |
| 中等(抓取放置,大物体) | 100~200 |
| 精细(插孔、装配) | 200~500+ |
⚠️ 这些是经验值,不是定律。 实际需求取决于:任务的一致性(每次都一样 vs 每次有变化)、物体的多样性、环境的杂乱程度。
判断标准应该是"成功率" ------录一批、训一轮、评估,不达标就加数据。这就是外层学习闭环的实际操作方式。
一个重要的反直觉点
数据"质量"比"数量"更重要。
100 条乱七八糟的数据(抖动、不一致、起点不一)
<
30 条干净的数据(起点一致、轨迹平滑、无异常)
"干净"的判定标准:
| 维度 | 干净 | 不干净 |
|---|---|---|
| 起点 | 每条 episode 从相同位姿 | 起点随机变化 |
| 轨迹 | 平滑、无抖动 | 手抖、突然停顿又加速 |
| 场景 | 试管/架子位置固定 | 每次位置不同 |
| 完成度 | 每条都成功完成 | 有的中途放弃 |
所以录制时宁可少录、录慢一点,也要保证每条都干净。 返工比多录贵得多。
第八部分 · 评估:数字说了算
模型跑起来了,现在要回答一个问题:它到底行不行?
8.1 为什么「成功」必须人工判定
放试管是视觉-语义任务,没有现成的自动判据。
不像「电机转到某角度」能读编码器------「试管是否准确放进橙色架的孔里」需要人看结果。
所以评估脚本设计成:
- 每个 episode 跑完后,提示你输入
y(成功)/n(失败) ; - 脚本只负责记录和算比例,不自己判断成败。
这是评估脚本最核心的设计约束:机器负责执行,人负责判定。
8.2 评估脚本的技术结构
评估脚本 = 单次执行脚本 + 三层外壳:
| 层 | 技术实现 | 作用 |
|---|---|---|
| episode 循环 | for ep in range(1, episodes+1) |
重复跑 N 次 |
| 复位闸门 | input("复位后按回车...") |
每集前人工复位到相同初始状态 |
| 判定记录 | input("成功? y/n/q") + results.append |
人工判定 + 累计 |
核心逻辑:
python
results.append(ans == 'y')
succ = sum(results)
print(f'当前成功率: {succ}/{len(results)} = {succ/len(results)*100:.1f}%')
with open(LOG, 'a') as f:
f.write(f'episode {ep}: {"success" if ans == "y" else "fail"}\n')
成功率实时累计显示 ,可随时 Ctrl+C 中断(已测的不丢)。eval_results.log 逐条追加。
8.3 两个关键参数
| 参数 | 含义 | 默认 |
|---|---|---|
--episodes |
评估次数 | 50 |
--steps-per-episode |
每集跑几个 50 帧 chunk | 1 |
⚠️ 重点:--steps-per-episode 的默认值 1 是错的(对放试管任务而言)。
一个 chunk = 50 帧 = 1.65s。放试管要 5~15s,所以默认 1 个 chunk 太短,任务根本完不成,成功率全是 0。
应该设 --steps-per-episode 5(约 8s)。
这个坑很典型:你以为是模型不行,其实是评估参数没设对。
8.4 安全设计(真机评估必需)
- 每集之间回车闸门:保证你手不在工作区时才动;
- 全程 Ctrl+C 急停 :
except KeyboardInterrupt捕获,finally里robot.disconnect()释放; - 工作区清空。
csharp
try:
for ep in range(1, args.episodes + 1):
...
except KeyboardInterrupt:
print('\n【急停】已停止。')
finally:
robot.disconnect()
front_cap.release()
wrist_cap.release()
这两个设计是真机评估的安全底线,不能省。
8.5 评估方法论:怎么让数字可信
样本量:50 次够不够
成功率是二项分布的估计。50 次采样,成功率 60% 时,95% 置信区间约 ±13%(真实值大概在 47%~73%)。
所以:
- 50 次能给粗粒度判断(「大概行 / 大概不行」);
- 要区分 60% 和 70%,需要更多次(几百次);
- 对当前阶段(判断「要不要重训」),50 次够。
复位标准化(最容易忽略的公平性问题)
每个 episode 的初始状态(试管位置、机械臂位姿)必须尽量一致,否则成功率里混入了「起始条件差异」的噪声:
- 试管放回固定起始位置;
- 机械臂回到 rest position;
- 干扰物摆回原位。
复位不标准 → 测出来的成功率不可比 → 反馈给学习的信号就是错的。
成功率怎么解读
| 现象 | 解读 | 行动 |
|---|---|---|
| 成功率 ≈ 0%,且动作乱 | 方向/接口错了,或完全没学会 | 查 STATE_FLIP、stats、重训 |
| 成功率低但动作「像」 | 学会了大致动作,缺精细 | 多录数据、加步数 |
| 成功率突然变好/变坏 | 环境/复位/硬件变了 | 检查相机、校零、复位标准化 |
8.6 我的真实评估日志
yaml
episode 1: fail
episode 1: fail
episode 1: fail
episode 1: fail
episode 2: fail
episode 1: fail
episode 1: fail
episode 2: fail
episode 3: success
episode 1: fail
episode 2: fail
episode 1: fail
12 次里只有 1 次成功。
但请注意:这些结果混用了两个不同的 checkpoint,而且「从头开始 episode 1」重复了很多次------说明我每次跑 few episodes 就中断了。
这个日志本身就是「外层学习闭环还没转起来」的证据:成功率太低 → 需要更多更好的数据 → 回到示范节点。
第九部分 · 首次跑通全流程(照做即可)
前面十一个部分是"理解",这一部分是"执行"。
目标:从一台刚接好的机械臂,到跑出第一个成功率数字。全程按顺序执行,不要跳步。
预计时间:
- 硬件连接 + 校零:1~2 小时(第一次可能更久,各种坑)
- 下载模型 + 数据:视网络,1~4 小时(可并行)
- 录制 10 条数据:30 分钟
- 训练:1~3 小时
- 部署 + 评估:1 小时
9.1 阶段零:环境准备(只做一次)
12.1.1 确认环境
scss
conda activate lerobot
python -c "import lerobot, torch; print('lerobot', lerobot.__version__); print('torch', torch.__version__); print('cuda', torch.cuda.is_available()); print('device', torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'CPU')"
期望输出:
python
lerobot 0.6.2
torch 2.11.0+cu130
cuda True
device NVIDIA GB10
如果 cuda 是 False ,说明 PyTorch 没装好 CUDA 版本,或者驱动有问题。先解决这个,不要往下走。
12.1.2 创建目录结构
bash
mkdir -p /root/pi05_b601/{models,datasets,outputs,logs,cache/huggingface}
目录用途:
| 路径 | 用途 |
|---|---|
models/ |
基座模型 |
datasets/ |
数据集 |
outputs/ |
训练产物(checkpoint) |
logs/ |
训练日志 |
cache/huggingface/ |
HuggingFace 缓存 |
12.1.3 设置 HuggingFace 环境变量
bash
export HF_HOME=/root/pi05_b601/cache/huggingface
export HF_DATASETS_CACHE=/root/pi05_b601/cache/huggingface/datasets
export HUGGINGFACE_HUB_CACHE=/root/pi05_b601/cache/huggingface/hub
💡 建议把这四条写进
~/.bashrc,省得每次都要 export。但要理解它们的作用(见 4.8 节):把缓存从系统盘挪到数据盘,避免塞满系统盘。
9.2 阶段一:并行下载(可以同时做)
这三件事互不依赖,可以开三个 tmux 窗口同时做。
12.2.1 窗口 A:下载公开数据集
bash
conda activate lerobot
export HF_HOME=/root/pi05_b601/cache/huggingface
export HF_DATASETS_CACHE=/root/pi05_b601/cache/huggingface/datasets
export HUGGINGFACE_HUB_CACHE=/root/pi05_b601/cache/huggingface/hub
mkdir -p /root/pi05_b601/datasets/orange_rack_test_tubes_848
hf download \
nyancos/orange_rack_test_tubes_848 \
--repo-type dataset \
--local-dir /root/pi05_b601/datasets/orange_rack_test_tubes_848
12.2.2 窗口 B:下载基座模型
bash
conda activate lerobot
# 先接受 PaliGemma 许可(浏览器操作,见 5.1 节)
read -rsp "请输入 Hugging Face Read Token: " HF_TOKEN
echo
export HF_TOKEN
export HF_ENDPOINT=https://hf-mirror.com
export HF_HUB_DISABLE_XET=1
mkdir -p /root/pi05_b601/models/pi05_base
hf download \
lerobot/pi05_base \
--repo-type model \
--local-dir /root/pi05_b601/models/pi05_base
unset HF_TOKEN
12.2.3 窗口 C:检查硬件
bash
# 主臂 CAN
ip -details -statistics link show can0
# 期望:state UP, ERROR-ACTIVE
# 从臂串口
python ~/桌面/rebot_bridge/ping_leader.py
# 期望:7 个全 True
# 相机
v4l2-ctl --list-devices
# 期望:找到两个相机,记下编号
# 串口权限
groups | grep dialout
# 期望:看到 dialout 组(说明有权限)
四个检查全绿才能继续。任何一个红,先去 2.x 节解决。
9.3 阶段二:校零(硬件准备)
⚠️ 这是所有后续步骤的前提。校零不对,录的数据就是废的。
12.3.1 主臂校零
css
conda activate lerobot
lerobot-calibrate --robot.type seeed_b601_dm_follower --robot.port can0
操作流程:
markdown
1. 终端提示:"请把整臂摆到零位、夹爪闭合"
→ 你手动把机械臂摆成标准的零位姿势
→ 夹爪手动闭合
2. 按回车
→ 它逐个电机写入零位
3. 看到完成提示
12.3.2 从臂校零
css
lerobot-calibrate --robot.type rebot_arm_102_leader --robot.port /dev/ttyUSB0
同样的操作:摆零位、回车。
12.3.3 验证夹爪
跑一次连接,看有没有 gripper not homed 报错:
ini
python -c "
from lerobot_robot_seeed_b601.seeed_b601_dm_follower import SeeedB601DMFollower
from lerobot_robot_seeed_b601.config_seeed_b601_dm_follower import SeeedB601DMFollowerConfig
r = SeeedB601DMFollower(SeeedB601DMFollowerConfig(port='can0', cameras={}))
r.connect(calibrate=True)
print('连接成功,无夹爪报错')
r.disconnect()
"
如果报 offset 353.96 deg → 回到 12.3.1 重新校零(见 3.3 节)。
9.4 阶段三:录数据
12.4.1 录制前最后的 checklist
按顺序确认(顺序很重要):
bash
# 1. 从臂 ping
python ~/桌面/rebot_bridge/ping_leader.py # 7 全 True
# 2. 主臂夹爪
# (12.3.3 已验证)
# 3. 相机编号
v4l2-ctl --list-devices # 确认 front/wrist 编号
# 4. 相机参数就写在录制命令里(640x480 + MJPG)
12.4.2 启动录制
ini
conda activate lerobot
lerobot-record \
--robot.type=seeed_b601_dm_follower \
--robot.port=can0 \
--robot.cameras="{front: {type: opencv, index_or_path: 6, width: 640, height: 480, fps: 30, fourcc: MJPG}, wrist: {type: opencv, index_or_path: 4, width: 640, height: 480, fps: 30, fourcc: MJPG}}" \
--teleop.type=rebot_arm_102_leader \
--teleop.port=/dev/ttyUSB0 \
--dataset.repo_id=my_b601/place_tubes \
--dataset.num_episodes=10 \
--dataset.single_task="place two test tubes into the orange rack" \
--dataset.push_to_hub=false
⚠️
--robot.cameras里的index_or_path要改成你实际的编号 (这里是 6 和 4)。 ⚠️ 不要加--robot.id(见 4.4 节)。
12.4.3 录制节奏(每条 episode 按两次 n)
arduino
┌─────────────────────────────────────────────────────┐
│ 每条 episode 的完整节奏: │
│ │
│ ① 你手动把试管、架子、机械臂复位到起始状态 │
│ ↓ │
│ ② 掰从臂,做一遍"放试管"的动作 │
│ (主臂会跟着动,同时录制) │
│ ↓ │
│ ③ 按 n → 结束动作部分 │
│ ↓ │
│ ④ 把机械臂掰回起始位姿(复位) │
│ ↓ │
│ ⑤ 再按 n → 结束复位,进入下一条 │
│ │
│ ⚠️ 不要按 r!r 会丢弃当前这条 │
│ │
│ 录满 10 条后自动结束 │
└─────────────────────────────────────────────────────┘
12.4.4 检查录到的数据
bash
ls -lh ~/.cache/huggingface/lerobot/my_b601/
# 看数据集的元信息
cat ~/.cache/huggingface/lerobot/my_b601/place_tubes_*/meta/info.json | head -60
# 看任务文字(关键:后面训练和推理要用同一句)
python -c "
import pyarrow.parquet as pq, glob
p = glob.glob('/root/.cache/huggingface/lerobot/my_b601/place_tubes_*/meta/tasks.parquet')[0]
print(pq.read_table(p).to_pylist())
"
重点确认:
- episode 数是 10;
- 任务文字和你录的时候写的一致;
- fps 是 30。
9.5 阶段四:训练
12.5.1 训练前检查 GPU
c
nvidia-smi
free -h
pgrep -af 'vllm|omni'
如果 pgrep 有输出 (别人在跑服务),不要贸然训练------DGX Spark 是统一内存,会抢资源(见 1.1 节概念四)。
12.5.2 启动训练
ini
conda activate lerobot
cd /root/pi05_b601/src/rebot_lerobot
export HF_HOME=/root/pi05_b601/cache/huggingface
export HF_DATASETS_CACHE=/root/pi05_b601/cache/huggingface/datasets
export HUGGINGFACE_HUB_CACHE=/root/pi05_b601/cache/huggingface/hub
# 安全检查:有 vLLM 就不训练
if pgrep -afi 'vllm|vllm-omni' >/tmp/pi05_gpu_blocked.txt; then
echo "检测到现有 vLLM 服务,为避免影响其他用户,本次不启动训练。"
cat /tmp/pi05_gpu_blocked.txt
exit 2
fi
mkdir -p /root/pi05_b601/outputs
lerobot-train \
--dataset.repo_id=my_b601/place_tubes \
--policy.type=pi05 \
--policy.pretrained_path=/root/pi05_b601/models/pi05_base \
--policy.dtype=bfloat16 \
--policy.device=cuda \
--policy.gradient_checkpointing=true \
--peft.method_type=LORA \
--peft.r=32 \
--peft.lora_alpha=32 \
--batch_size=8 \
--num_workers=4 \
--steps=1500 \
--save_freq=250 \
--log_freq=10 \
--wandb.enable=false \
--policy.push_to_hub=false \
--output_dir=/root/pi05_b601/outputs/pi05_tubes_lora_my10 \
--job_name=pi05_tubes_lora_my10 \
> /root/pi05_b601/logs/pi05_tubes_lora_my10.log 2>&1 &
💡 注意最后的
&------放到后台跑。然后可以开另一个 tmux 窗口看日志(见 12.5.3)。在 tmux 里跑的话,即使 SSH 断了,训练也会继续。
12.5.3 监控训练
另开一个窗口:
bash
tail -f /root/pi05_b601/logs/pi05_tubes_lora_my10.log
日志里要关注什么:
| 项 | 关注点 |
|---|---|
| loss(损失) | 应该整体下降。不降反升 → 有问题 |
| step | 在涨,说明在训练 |
| 显存 | 不要爆(看有没有 OOM 报错) |
| 保存 checkpoint | 每 250 步会保存一次 |
判断训练是否正常:
matlab
正常:loss 从比如 0.5 慢慢降到 0.05 左右
异常:loss 一直是 nan → 数值问题
异常:loss 不降 → 数据或配置问题
异常:loss 降得太快(几步就 0) → 数据太少,秒过拟合
12.5.4 等待训练完成
1500 步大概 1~3 小时(取决于 GPU 和数据量)。训练完成后:
bash
ls -lh /root/pi05_b601/outputs/pi05_tubes_lora_my10/checkpoints/
会看到 000250、000500、......、001500 这些目录。
9.6 阶段五:部署
12.6.1 开 SSH 隧道(本机执行)
ini
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-L 8080:localhost:8080 -p 6022 root@43.155.204.215
这个窗口要保持开着。
12.6.2 更改 ADAPTER_DIR 并启动服务器
在服务器的另一个窗口:
bash
cd ~/pi05_b601/src/rebot_lerobot/rerobot_bridge
# 指向刚训好的 checkpoint(先用 001500 试)
sed -i "s|^ADAPTER_DIR = .*|ADAPTER_DIR = '/root/pi05_b601/outputs/pi05_tubes_lora_my10/checkpoints/001500/pretrained_model'|" predict_server.py
# 确认改对了
grep '^ADAPTER_DIR' predict_server.py
# 重启服务器
pkill -f predict_server.py; sleep 2
nohup python predict_server.py > server.log 2>&1 &
# 看启动日志
tail -f server.log
期望看到:
csharp
[1/3] 加载基础 pi0.5 模型 ...
[2/3] 套 LoRA 适配器 ...
[3/3] 加载 tokenizer + stats ...
模型就绪。
Uvicorn running on http://127.0.0.1:8080
12.6.3 验证隧道
json
curl http://localhost:8080/health
# 期望:{"ok":true}
9.7 阶段六:从假到真,四层验证
严格按顺序。不要跳到第三层。
第一层:冒烟测试(不碰真机)
perl
python test_predict.py --state "5,-40,-30,10,0,15,20"
验证:
- 离散化状态在 0~255 之间;
- prompt 格式正确;
- action_chunk 形状是 (50, 7);
- 动作度数落在 q01/q99 附近(不是乱飞)。
再做一次对比测试(关键):
bash
python test_predict.py # 真图
python test_predict.py --front /nonexistent --wrist /nonexistent # 灰图
两次的动作应该明显不同。如果几乎一样 → 视觉没生效,要查图像格式。
第二层:干跑(连真机,不动)
arduino
python run_pi05_robot.py --dry-run
验证: 读状态、读相机、调服务器全链路通。不发动作。
第三层:真机执行(关键:先摆非零位姿)
bash
# ⚠️ 先把机械臂摆到一个明显的非零位姿(抬大臂 + 弯肘)
python run_pi05_robot.py --steps 1
看输出的对比:
ini
[step 0] 当前状态: [-15.7, -40.0, -30.0, 10.8, -0.1, 0.1, 0.1]
预测首动作: [17.6, 40.0, -68.8, 17.1, -8.6, 12.4, 1.0]
判断方向:预测首动作应与当前逻辑角度量级接近(注意屏幕打印的"当前状态"是原始读数,两者符号可能相反,属正常)。
如果明显相反 → 改 STATE_FLIP(见 6.5 节),重启服务器,重来。
第四层:评估
css
python eval_pi05_robot.py --episodes 10 --steps-per-episode 5
流程:
ini
[episode 1/10] 复位场景+机械臂后按回车开始(q 退出)...
(你复位 → 回车 → 它自动跑几秒)
成功? [y/n/q]: y
当前成功率: 1/1 = 100.0%
[episode 2/10] ...
...
=== 评估完成: X/10 成功 = XX.X% ===
9.8 阶段七:根据成功率决定下一步
这是外层学习闭环的关键决策点。
arduino
┌──────────────────┐
│ 看成功率 │
└────────┬─────────┘
↓
┌────────────────────┼────────────────────┐
↓ ↓ ↓
≈ 0% 且动作乱 低但动作"像" 还行(>50%)
│ │ │
↓ ↓ ↓
查方向/接口 多录数据、加步数 试其他 checkpoint
STATE_FLIP、stats 数据质量优先 或者收工
│ │
└────────────────────┘
↓
回到阶段三(录数据)
具体动作:
| 现象 | 诊断 | 下一步 |
|---|---|---|
| ≈0% 且动作完全乱 | 接口问题(方向/统计/字段) | 查 6.5、4.9、4.12 节 |
| ≈0% 但动作"看起来像那么回事" | 模型没学会(数据/步数不够) | 多录数据 + 多训 |
| 低但在涨(多试几次有成功的) | 数据不足 | 加到 20~50 条 |
| 部分成功(不同位置成功率不同) | 数据没覆盖到那些情况 | 有针对地多录那些场景 |
| 换 checkpoint 效果不同 | 过拟合 | 试更早的 checkpoint |
9.9 全流程时间线(一张图)
┌────────────────────────────────────────────────────────────┐
│ 阶段零 环境准备(一次性) │
│ ↓ │
│ 阶段一 并行下载:数据集 / 模型 / 硬件检查 │
│ ↓ │
│ 阶段二 校零(主臂 + 从臂) │
│ ↓ │
│ 阶段三 录 10 条数据 │
│ ↓ │
│ 阶段四 训练(1500 步) │
│ ↓ │
│ 阶段五 部署(改 ADAPTER_DIR + 启服务器 + 开隧道) │
│ ↓ │
│ 阶段六 四层验证:冒烟 → 干跑 → 真机 → 评估 │
│ ↓ │
│ 阶段七 看成功率 → 决定:收工 / 回阶段三 / 回阶段四 │
│ └───────────────────────┘ │
│ ↑ 这就是外层学习闭环 │
└────────────────────────────────────────────────────────────┘
第十部分 · 排障:把接口思维用起来
10.1 排障第一原则
报错时,先判断「是哪个接口断了 / 哪个约定不一致」,而不是「哪个零件坏了」。
这是整个项目最有价值的一句话。因为你踩的每一个坑,本质上都是接口不匹配。
10.2 十二个接口清单(系统的「合同」)
| # | 接口 | 精确约定 | 不匹配的后果 |
|---|---|---|---|
| I1 | 感知→决策(状态) | 7 关节顺序 + 符号取负 + q01/q99 归一化 | 方向反了 / 数值乱飞 |
| I2 | 感知→决策(图像) | key=base_0_rgb+left_wrist_0_rgb;值∈0,1;内部 resize 224 |
报 image features missing |
| I3 | 感知→决策(语言) | Task: {task}, State: {7整数};\nAction: ;max_length=200 |
prompt 格式错 |
| I4 | 决策→执行(动作) | 动作不翻转,直接发;前 7 维有效 | 臂反向 / 维度错 |
| I5 | 执行→环境→感知 | 物理动作→世界改变→新状态/图(反馈) | ------ |
| I6 | 示范→数据(录制) | 关节顺序、相机、single_task 文字固定 | 数据不可用 |
| I7 | 数据→学习(统计) | stats.json 的 q01/q99 一致 |
训练崩 / 损失异常 |
| I8 | 学习→决策(加载) | PeftModel.from_pretrained(base, adapter) |
加载失败 |
| I9 | 决策↔本机(网络) | SSH 隧道 localhost:8080 |
连不上 |
| I10 | 物理层(CAN) | can0 @ 1 Mbps |
电机无响应 |
| I11 | 物理层(串口) | /dev/ttyUSB0 @ 1 Mbps,id 0~6 |
舵机 ping 不到 |
| I12 | 物理层(相机) | 640×480 + MJPG | fps 掉 / 读不到帧 |
我的每一个坑,都能对到这张表上:
| 我遇到的报错 | 断的接口 |
|---|---|
Servo not found (id=0) |
I11(串口物理层) |
Permission denied |
I11(串口权限) |
failed to set fps=30 (actual_fps=15) |
I12(相机分辨率) |
Timed out waiting for frame |
I12(相机 fourcc) |
gripper not homed, offset 353.96 |
I10(CAN 层,零点) |
Bad local forwarding specification |
I9(隧道格式) |
image features missing |
I2(图像 key 名) |
| 动作方向反 | I1(状态符号) |
10.3 症状 → 链路 速查表
| 症状 | 哪条链路 | 原因 | 解决 |
|---|---|---|---|
can0 不是 UP / 收发 0 |
① CAN | 波特率错 / 没接电机 | 重设 bitrate 1000000、重插转接头 |
serial error: Permission denied |
② 串口 | 没权限 | usermod -aG dialout $USER |
Servo not found (id=0),7 个全 False |
② 串口 | 数据线没通(拓展坞/松动) | 直插 USB、重插总线 |
gripper not homed, offset 353.96° |
① 主臂 | 夹爪零点漂了 | lerobot-calibrate |
failed to set fps=30 (actual_fps=15) |
③ 相机 | 分辨率不支持 30fps | 改成 640×480 |
Timed out waiting for frame |
③ 相机 | fourcc 探测错 | 加 fourcc: MJPG |
curl localhost:8080 不通 |
④ 隧道 | 隧道没开 / 服务器没起 | 重开隧道、tail -f server.log |
| 相机图是黑的 / 编号错 | ③ 相机 | USB 重新枚举编号变了 | v4l2-ctl --list-devices |
服务器 500 + image features missing |
接口 I2 | 图像 key 名写错 | 核对 batch 里 key 名 |
| 动作全在边界上 / 离散状态全 0 或 255 | 接口 I1 | 状态在训练分布外 | 属正常;移到工作位姿再试 |
| 动作方向反 | 接口 I1 | STATE_FLIP 符号错 | 非零位姿干跑验证,改 ±1 |
10.4 我踩过的所有坑(完整清单)
坑 1:SSH 免密失败
现象:Permission denied publickey, password。 解决:服务器命令让我自己跑、贴输出(因为服务器没有配置免密)。
坑 2:ffmpeg 打不开相机
现象:ffmpeg 打开 /dev/video4/6 报 Operation not permitted。 排查:检查了 ACL,有 rw 权限------不是权限问题 。 解决:用 v4l2-ctl --stream-mmap --stream-to 代替。
坑 3:相机 select() timeout
现象:读帧超时。 解决:加「预热读 + 重试 + 强制 MJPG fourcc」。
坑 4:SSH Broken pipe
现象:服务器前台跑进程随 SSH 断开被杀。 解决:改成 nohup ... > server.log 2>&1 & 后台跑,隧道加心跳参数。
这个坑很典型 ------长任务一定要放后台,而且要用 tmux 或 nohup。
坑 5:服务器缺 fastapi
现象:ModuleNotFoundError: No module named 'fastapi'。 解决:pip install fastapi uvicorn。
坑 6:SSH 隧道写法
现象:-L 8080:8080 报 Bad local forwarding specification。 解决:要四段式 -L 8080:localhost:8080。
坑 7:动作没切片
现象:模型输出 (1,50,32),直接反归一化会广播报错 / 算错。 解决:要 [0,:,:7] 先取前 7 维再反归一化。
坑 8:rerun-sdk 未安装
现象:--display_data=true 报 ImportError: 'rerun-sdk' is required but not installed。 解决:去掉 --display_data=true,或 pip install 'lerobot[viz]'。
我当时问「是不是忘了连接了」------不是连接问题 ,是缺可视化包。报错关键词要认准。
坑 9:Python 代码直接贴进 bash
现象:报语法错「找不到命令 from」。 原因:Python 代码没有用 python -c 或写成脚本。 解决:把诊断逻辑写成 .py 文件,直接 python xxx.py 跑。
坑 10:从臂舵机 ping 不到(卡最久)
见 2.4 节。根因是从臂走了拓展坞。
10.5 后台运行长任务
不要在裸网页终端跑长任务! 用 tmux,就算断网、broken pipe,日志和程序都会保留。
bash
# 创建会话
tmux new -s pi05
# 在 tmux 里跑,输出落地
python your_script.py > run.log 2>&1
# 分离(保持运行)
# 按 Ctrl+B,松开,再按 D
# 重新接入
tmux attach -t pi05
# 查看所有会话
tmux ls
日志落地的三种写法:
bash
python x.py > run.log 2>&1 # 覆盖写
python x.py >> run.log 2>&1 # 追加写(多次实验推荐)
python x.py 2>&1 | tee run.log # 屏幕 + 文件同时
解释:
>/>>:标准输出重定向;2>&1:错误信息也进同一个文件;tee:一边屏幕显示一边存文件。
分屏同时看日志:
tmux 里按 Ctrl+B 松开再按 " → 上下分屏。上半屏跑程序,下半屏 tail -f run.log。
关键提醒 :tmux 只是保进程,真正永久保存日志靠
> / >> / tee。不要依赖 tmux 滚轮看大量历史。
常用 tmux 命令:
| 操作 | 命令 |
|---|---|
| 新建会话 | tmux new -s 名称 |
| 查看所有会话 | tmux ls |
| 接入已有会话 | tmux attach -t 名称 |
| 分离会话(会话内) | Ctrl+b 然后 d |
| 关闭会话(会话内) | 输入 exit |
| 杀死会话 | tmux kill-session -t 名称 |
| 杀所有 | tmux kill-server |
重连服务器后的标准流程:
bash
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=6 -p 6022 root@43.155.204.215
tmux ls # 看有哪些会话
tmux attach -t pi05_down0 # 进入原来的会话
source /root/miniforge3/etc/profile.d/conda.sh
conda activate lerobot
nvidia-smi # 检查 GPU
free -h # 检查内存
pgrep -af 'vllm|omni' # 检查是否已有服务
第十一部分 · 系统当前状态与下一步
11.1 进度盘点
| 模块 | 状态 |
|---|---|
| π0.5 推理链(分词+图像+模型+反归一化) | ✅ 冒烟测试通过(4/4) |
| 真机推理跑通 | ✅ 已跑通(001000 checkpoint) |
| 切换权重到 005000 | ✅ 已完成(服务器侧) |
评估脚本 eval_pi05_robot.py |
✅ 已写好 |
| 夹爪校零 | ⚠️ 需重新校零(353.96°) |
| 遥操作采集 10 条数据 | ❌ 阻塞(从臂舵机 ping 不到) |
11.2 系统诊断
| 层 | 状态 |
|---|---|
| 内层运行时闭环 | ✅ 已通(冒烟测试 + 真机跑通) |
| 外层学习闭环 | ❌ 断在起点------示范节点(从臂)物理链路断路 |
断点详情 :从臂 7 舵机全 ping 不到。/dev/ttyUSB0 能打开、电源灯亮、4 个波特率全 False。
这是接口 I11 断路(数据线没通,非软件/波特率问题)。最可能是拓展坞 USB-串口问题。
连带阻塞:示范断了 → 录不了数据 → 学习/评估整条弧线无法启动。
11.3 修复路径:让闭环转起来第一圈
bash
① 修 I11:从臂 USB 直插主板(不过拓展坞)→ 重插总线 → 核对 ttyUSB0/ttyUSB1 → ping 通
② 修 I10 附带:夹爪校零(353.96°)------ lerobot-calibrate
③ 示范:录 10 条 place_tubes
④ 学习:lerobot-train(LoRA,~1500 步)
⑤ 部署:ADAPTER_DIR 指向新 checkpoint,重启服务器
⑥ 执行+评估:eval_pi05_robot.py 跑成功率
⑦ 反馈:不达标 → 回到 ③ 转第二圈
11.4 长期路线图
第一阶段:并行准备
css
终端 A:下载公开数据集 orange_rack_test_tubes_848
终端 B:下载 lerobot/pi05_base 基础模型
终端 C:检查机械臂和相机
终端 D:准备目录、查看 info.json
第二阶段:等待 GPU 空闲 → 第一轮训练
用公开数据(RS 机械臂)跑 3000 步,先验证数据格式、动作维度和模型管线。
ini
lerobot-train \
--dataset.repo_id=nyancos/orange_rack_test_tubes_848 \
--policy.type=pi05 \
--policy.pretrained_path=/root/pi05_b601/models/pi05_base \
--policy.normalization_mapping='{"ACTION":"MEAN_STD","STATE":"MEAN_STD","VISUAL":"IDENTITY"}' \
--policy.freeze_vision_encoder=true \
--policy.train_expert_only=true \
--policy.gradient_checkpointing=true \
--policy.dtype=bfloat16 \
--rename_map='{"observation.images.front":"observation.images.image","observation.images.wrist":"observation.images.image2"}' \
--batch_size=1 \
--steps=3000 \
--save_freq=500
第三阶段:采集 B601-DM 数据(关键)
为什么必须采自己的数据?
公开数据集的机器人类型是 seeed_b601_rs_follower,你的目标机器人是 seeed_b601_dm_follower。
这意味着公开数据不能直接作为最终控制数据。 即使 action 都是 7 维,也可能存在:
- 关节顺序不同;
- 正负方向不同;
- DM 电机零点不同;
- 夹爪范围不同;
- 绝对动作和相对动作定义不同;
- RS 与 DM 的运动学响应不同。
建议流程:
- 公开数据训练 3000 步(验证管线);
- B601-DM 采集 5 条,确认格式;
- B601-DM 采集 20~50 条,做第一轮微调;
- B601-DM 采集 100 条以上,做稳定训练;
- 最后才进行试管抓取。
第四阶段:目标域微调
用自录的 B601-DM 数据继续训练 10000 步。
第五阶段:部署推理
GPU 服务器:policy_server
机械臂电脑:robot_client
或者(当前 Seeed 版本更推荐)用 lerobot-record 加载策略评估:
ini
lerobot-record \
--robot.type=seeed_b601_dm_follower \
--robot.port=can0 \
--display_data=false \
--policy.path=/root/pi05_b601/outputs/pi05_b601_dm/checkpoints/last/pretrained_model
注意区分两个参数:
--policy.pretrained_path:加载基础权重 ,必须同时写--policy.type=pi05;--policy.path:加载训练完成的 checkpoint ,会同时读权重和config.json,推理时通常不再写--policy.type。
11.5 一句话的当前结论
你已经完成了软件环境和 CUDA 环境部署,但还没完成「可直接抓取试管」的模型部署。
公开 RS 数据可以作为第一阶段训练数据,最终控制 B601-DM 必须用 DM 机械臂采集的数据继续微调。
第十二部分 · 速查手册
12.1 环境与登录
bash
# 登录服务器(带心跳)
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=6 -p 6022 root@43.155.204.215
# 激活环境
source /root/miniforge3/etc/profile.d/conda.sh
conda activate lerobot
# 进入项目
cd /root/pi05_b601/src/rebot_lerobot
12.2 硬件检查
bash
# CAN(主臂)
ip -details -statistics link show can0
sudo ip link set can0 down
sudo ip link set can0 type can bitrate 1000000
sudo ip link set can0 up
# 串口(从臂)
python ping_leader.py
ls -l /dev/ttyUSB*
dmesg | grep -i tty | tail -20
# 相机
v4l2-ctl --list-devices
12.3 校零
css
lerobot-calibrate --robot.type seeed_b601_dm_follower --robot.port can0
12.4 录制数据
ini
conda activate lerobot
lerobot-record \
--robot.type=seeed_b601_dm_follower \
--robot.port=can0 \
--robot.cameras="{front: {type: opencv, index_or_path: 6, width: 640, height: 480, fps: 30, fourcc: MJPG}, wrist: {type: opencv, index_or_path: 4, width: 640, height: 480, fps: 30, fourcc: MJPG}}" \
--teleop.type=rebot_arm_102_leader \
--teleop.port=/dev/ttyUSB0 \
--dataset.repo_id=my_b601/place_tubes \
--dataset.num_episodes=10 \
--dataset.single_task="place two test tubes into the orange rack" \
--dataset.push_to_hub=false
12.5 训练
ini
lerobot-train \
--dataset.repo_id=my_b601/place_tubes \
--policy.type=pi05 \
--policy.pretrained_path=/root/pi05_b601/models/pi05_base \
--peft.method_type=LORA \
--peft.r=32 \
--peft.lora_alpha=32 \
--policy.dtype=bfloat16 \
--policy.device=cuda \
--policy.gradient_checkpointing=true \
--batch_size=8 \
--steps=1500 \
--save_freq=250 \
--output_dir=outputs/pi05_tubes_lora_my10 \
--job_name=pi05_tubes_lora_my10 \
--seed=1000
12.6 部署与隧道
ruby
# ------ 服务器:启动推理服务 ------
cd ~/pi05_b601/src/rebot_lerobot/rerobot_bridge
nohup python predict_server.py > server.log 2>&1 &
tail -f server.log # 看到 "模型就绪" + "Uvicorn running"
# ------ 服务器:换权重 + 重启 ------
sed -i "s|^ADAPTER_DIR = .*|ADAPTER_DIR = '/root/pi05_b601/outputs/pi05_tubes_lora_my10/checkpoints/001500/pretrained_model'|" predict_server.py
grep '^ADAPTER_DIR' predict_server.py
pkill -f predict_server.py; sleep 2
nohup python predict_server.py > server.log 2>&1 &
# ------ 本机:开隧道 ------
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L 8080:localhost:8080 -p 6022 root@43.155.204.215
# ------ 本机:传文件到服务器 ------
scp -P 6022 predict_server.py root@43.155.204.215:~/pi05_b601/src/rebot_lerobot/rerobot_bridge/predict_server.py
# ------ 验证 ------
curl http://localhost:8080/health
12.7 推理与评估
css
# 冒烟测试(不连机械臂)
python test_predict.py
python test_predict.py --state "5,-40,-30,10,0,15,20"
# 干跑(连机械臂,不动)
python run_pi05_robot.py --dry-run
# 真机执行
python run_pi05_robot.py --steps 1
# 评估
python eval_pi05_robot.py --episodes 10 --steps-per-episode 5
python eval_pi05_robot.py --episodes 50 --steps-per-episode 5
12.8 训练日志与监控
bash
tail -f /root/pi05_b601/logs/pi05_tubes_lora_first100_1000.log
cat run.log
tail -n 100 run.log
pgrep -af 'lerobot-train|lerobot.scripts'
nvidia-smi
free -h
pgrep -af 'vllm|omni'
12.9 tmux
perl
tmux new -s pi05
tmux ls
tmux attach -t pi05
tmux kill-session -t pi05
tmux kill-server
# 分离:Ctrl+B 然后 D
# 分屏:Ctrl+B 然后 "
12.10 查看数据集任务语义
less
python -c "import pyarrow.parquet as pq; p='/root/pi05_b601/datasets/orange_rack_test_tubes_848/meta/tasks.parquet'; print(pq.read_table(p).to_pylist())"
第十三部分 · 延伸阅读:相关论文
本文所有技术点都来自真实的工作,下面列出对应的原始文献,供深入研读。
⚠️ 以下为按领域常识整理的代表性工作,具体卷期与最新版本请自行核实原文。
动作块(action chunking)
为什么模型一次预测 50 帧动作? 这个设计来自:
- ACT / ALOHA --- Learning Fine-Grained Bimanual Manipulation with Low-Cost Hardware (Zhao et al., RSS 2023) 提出 ACT(Action Chunking Transformer),系统性地把「一次预测多步动作」(action chunking)用于精细双臂操作,是该方法的代表作。注意:「动作分块」这一概念并非 ACT 首创------ACT 论文本身将其溯源至心理学研究,且同期 Behavior Transformers(BeT, 2022)已做过多步动作序列克隆。ACT 的贡献在于把分块与 Transformer、时序集成(temporal ensembling)结合用于精细双臂操作。本文 7.1 节「动作块摊薄推理延迟」的论证直接源自它。
核心贡献是缓解复合误差累积------单步预测在长时序任务里误差会累积,动作块通过一次预测多步降低有效步数。
生成式动作(为什么动作不是「算」出来而是「生成」出来)
为什么模型输出会有随机性?为什么要「去噪」多步?
- Diffusion Policy --- Diffusion Policy: Visuomotor Policy Learning via Action Diffusion(Chi et al., RSS 2023) 把扩散模型引入机器人动作生成,解决多模态动作分布建模。本文 7.2 节「MSE 导致均值化」的论证正是它的核心动机。
- Flow Matching --- Flow Matching for Generative Modeling(Lipman et al., ICLR 2023) 提出流匹配,相比扩散生成路径更直、推理步数更少。π0.5 的动作专家基于此。
- π0 / π0.5 --- Physical Intelligence 的 π 系列技术报告 π0 将流匹配用于 VLA 动作生成(是该路线的重要代表);π0.5 采用「状态离散化并入语言序列」的状态处理方式(该模式在 π0 与 RT-2 中已有先例)。
VLA 模型
- RT-2 --- RT-2: Vision-Language-Action Models Transfer Web Knowledge to Robotic Control(Brohan et al., CoRL 2023) 是早期将大规模视觉-语言模型直接用于机器人控制的代表性工作,展示「网络知识迁移到机器人」。
- Open X-Embodiment --- Open X-Embodiment: Robotic Learning Datasets and RT-X Models(O'Neill et al., ICRA 2024) 聚合多机构机器人数据集,训练跨本体(cross-embodiment)模型。这是「异质数据训练」路线的代表。
- PaliGemma --- PaliGemma: A versatile 3B VLM for transfer(Beyer et al., 2024) π0.5 骨干的基础。
- SigLIP --- Sigmoid Loss for Language Image Pre-Training(Zhai et al., ICCV 2023) 相比 CLIP 的对比损失改用 sigmoid 损失。π0.5 用它作为视觉编码器。
参数高效微调(LoRA)
- LoRA --- LoRA: Low-Rank Adaptation of Large Language Models(Hu et al., ICLR 2022) 提出低秩适配,冻结预训练权重、仅训练低秩增量矩阵。本文第五部分的核心依据。
- QLoRA --- QLoRA: Efficient Finetuning of Quantized LLMs(Dettmers et al., NeurIPS 2023) 在 LoRA 基础上量化基座权重,进一步降显存。
模仿学习的理论基础
- DAgger --- A Reduction of Imitation Learning and Structured Prediction to No-Regret Online Learning (Ross et al., AISTATS 2011) 形式化了模仿学习中的分布偏移(distribution shift) 问题------这正是「为什么需要大量干净数据」「为什么需要闭环纠正」的理论基础。
LeRobot 生态
- LeRobot --- HuggingFace 的开源机器人学习库,提供数据格式(LeRobotDataset)、训练框架(
lerobot-train)、模型实现(ACT、Diffusion Policy、π0.5 等)。
阅读顺序建议
markdown
1. ACT 理解动作分块
2. Diffusion Policy 理解生成式动作
3. RT-2 / Open X-Embodiment 理解 VLA 与跨本体
4. π0 / π0.5 理解最新的流匹配 VLA
5. LoRA 理解微调机制
前两篇是理解「为什么这样设计」的基础,后三篇是当前前沿。
结语:三个层次的收获
写到这里,回头看这个项目,我觉得收获分三层。
第一层:具体的技能
你现在知道了:
- 四条独立链路的建立与验证;
- 遥操作录制的原理与命令;
- LoRA 微调的机制与参数;
- π0.5 推理链的六步数学;
- 评估的方法论。
这些是可以直接复用的。
第二层:系统思维
但更重要的是第二层。这套「边界 + 子系统 + 接口 + 反馈回路」的分析方法,可以用在任何复杂系统上。
当你遇到一个陌生的机器人系统、一个陌生的分布式服务、甚至一个陌生的业务系统,你可以问:
- 它的边界在哪?什么在内部、什么在外部?
- 它有哪些功能子系统?信息怎么在里面流转?
- 子系统之间的接口约定是什么?哪一处最容易不匹配?
- 有几个反馈回路?信号从哪来、到哪去?
这比记住任何一条命令都有用。
第三层:闭环意识
第三层是最难但最重要的。
新手最容易犯的错,是把「跑通一次」当成终点。
但真相是:机器学习系统是一个需要持续转动的闭环。
示范 → 学习 → 部署 → 评估 →(不达标)→ 回到示范
我当前的系统,内层转起来了,外层还没转起来。
断点很具体:一根 USB 数据线走了拓展坞。修好它,闭环就能转第一圈。
然后转第二圈、第三圈------每转一圈,模型都会好一点。
这才是整个项目真正的样子。
这是一个由「边界 + 六个功能子系统(感知/决策/执行/示范/学习/评估)」组成、靠「12 个精确接口」耦合、由「内外两层反馈回路」驱动的闭环系统。它当前内层通、外层断在示范节点的物理接口上------修好这个断点,闭环就转起来了。
而你要记住的排障第一原则是:报错时,先判断「是哪个接口断了」,而不是「哪个零件坏了」。
本文整理自真实项目的完整技术记录,包含硬件接线、遥操作采集、LoRA 微调、推理部署与评估五个阶段的实践细节。所有命令、参数、报错信息均来自实际运行。