从零到「机械臂会放试管」:π0.5 VLA 真机部署完全指南(第二节)

第六部分 · 部署:模型怎么驱动真实的机械臂

训练完了,现在把 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)

技术要点:

  1. 第一段 strict=True 要求权重字典严格匹配,防止加载残缺模型;
  2. 第二段 PeftModel.from_pretrained 把适配器「并联」到冻结的基座上;
  3. 第三段的 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 把像素 0255 变成 01
.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    ← 和取正一样!

零位取负还是零位,你根本区分不出方向对不对。

正确的验证方法:把臂摆到「非零位姿」。

具体操做:

  1. 手动把臂摆成一个明显的姿势------比如抬大臂 + 弯肘 ,让 shoulder_lift 和 elbow_flex 明显偏离 0;
  2. 跑干跑:
arduino 复制代码
python run_pi05_robot.py --dry-run
  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]

怎么判断?

关键在于:预测的首动作,应该和「当前逻辑角度」量级接近 (逻辑角度 = 原始读数 × 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"

验证:

  1. 离散化状态在 0~255 之间;
  2. prompt 格式正确;
  3. action_chunk 形状是 (50, 7);
  4. 动作度数落在 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 的运动学响应不同。

建议流程:

  1. 公开数据训练 3000 步(验证管线);
  2. B601-DM 采集 5 条,确认格式;
  3. B601-DM 采集 20~50 条,做第一轮微调;
  4. B601-DM 采集 100 条以上,做稳定训练;
  5. 最后才进行试管抓取。

第四阶段:目标域微调

用自录的 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 微调、推理部署与评估五个阶段的实践细节。所有命令、参数、报错信息均来自实际运行。

相关推荐
Oiooohuohuo1 小时前
LangGraph Multi-Agent案例讲解
人工智能
滑翔的企鹅1 小时前
从真实系统看 CMS 与知识内容平台的架构演进
架构
winfredzhang1 小时前
9.7GB AI 模型总下崩?我用 Python 写了个支持分段续传的多线程下载器(源码解析)
人工智能·大语言模型·多线程·下载·多任务
深蓝AI1 小时前
不生成文本的AI日吞一万亿Token:决策模型Jev撕开大模型的替代路线
人工智能·ai编程
小宋10211 小时前
一套服务托管多个 LoRA:适配器加载、租户隔离与热切换实战
大数据·人工智能·算法
思考着亮1 小时前
14.Agentic RAG -3
人工智能
黑妹天下第一乖1 小时前
第 04 讲:阿加犀 AIMO 模型优化平台与 Model Farm 模型广场实战
人工智能·嵌入式硬件·矩阵·架构·iot
郝学胜_神的一滴1 小时前
AI 编程智能体 06:用Anaconda搞定Python多环境,彻底告别版本兼容灾难
人工智能·python
橘和柠1 小时前
显存计算与模型选择:你的显卡能跑多大的模型
人工智能