diff --git a/actpred/README.md b/actpred/README.md index b70df92..9726df6 100644 --- a/actpred/README.md +++ b/actpred/README.md @@ -179,7 +179,7 @@ recorder.flush() | `--num-workers` | 2 | DataLoader 工作进程数 | | `--checkpoint-dir` | ./checkpoints | 检查点保存目录 | | `--init-from` | 无 | 预训练 checkpoint 路径,作为微调起点(strict=False) | -| `--resume` | 无 | 从 last.pt 断点续训练(恢复 model/optimizer/scheduler/epoch) | +| `--resume` | 无 | 从 last.pt 断点续训练(恢复 model/optimizer/scheduler/epoch;scheduler 的 T_max 一并恢复,resume 时改 `--epochs` 不影响 LR 余弦曲线,只影响循环边界) | | `--save-config` | 无 | 保存配置到 JSON 文件 | | `--predict-delta` | False | R1-C 残差预测(target 改 Δ,推理端自动加回 a_{t-1}) | | `--rollout-len` | 1 | R1-B 多步展开步数 K(>1 时 scheduled sampling) | diff --git a/actpred/_npu_test.py b/actpred/_npu_test.py index 85d9ece..bd5297f 100644 --- a/actpred/_npu_test.py +++ b/actpred/_npu_test.py @@ -57,11 +57,13 @@ model.eval() pred_learned = model.pred_token.data.unsqueeze(1).numpy() # (1,1,1,d) imu_dim = cfg.data.imu_dim -# 归一化模型:torch 参考在喂入前 z-score;HailoPredictor 传 stats_path -# 由封装内部归一化(hist_real/hist_zero 保持原始值,避免双重归一化) +# 归一化模型:torch 参考在喂入前归一化;HailoPredictor 传 normalize_mode +# 由封装内部归一化(hist_real/hist_zero 保持原始值,避免双重归一化)。 +# fixed_range 免 action_stats.npy(该模式训练不产出统计文件)。 import os stats_path = None -if cfg.data.normalize_actions: +norm_mode = cfg.data.normalize_mode if cfg.data.normalize_actions else "none" +if norm_mode == "zscore": stats_path = cfg.data.action_stats_path or os.path.join( os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy") if not os.path.isabs(stats_path): @@ -70,6 +72,8 @@ if cfg.data.normalize_actions: if not os.path.exists(stats_path): raise FileNotFoundError(f"统计文件不存在: {stats_path}") print(f"HailoPredictor stats: {stats_path}") +elif norm_mode == "fixed_range": + print("fixed_range 归一化:免 action_stats.npy,按固定物理范围 min-max") from torchvision import transforms tf = transforms.Compose([ @@ -82,9 +86,12 @@ tf = transforms.Compose([ def torch_pred(imgs, hist, use_learned_pred=True, imu=None): x = torch.stack([tf(Image.fromarray(im)) for im in imgs]).unsqueeze(0) h = torch.tensor(hist).unsqueeze(0) - if stats_path is not None: + if norm_mode == "zscore" and stats_path is not None: stats = np.load(stats_path) h = (h - torch.tensor(stats[0])) / torch.tensor(stats[1]) + elif norm_mode == "fixed_range": + from action_norm import normalize_action + h = torch.from_numpy(normalize_action(h.numpy(), cfg.data.action_dim)) ii = None if imu_dim > 0: # 与 HailoPredictor 同口径:原始姿态 → 内部归一化前需手动归一 @@ -104,7 +111,10 @@ def torch_pred(imgs, hist, use_learned_pred=True, imu=None): # R2 方案一:imu_dim>0 时每步传原始姿态(HailoPredictor 内部归一化) imu_real = np.array([5.0, -3.0] + [0.0] * 6, dtype=np.float32) -with HailoPredictor(HEF_PATH, stats_path=stats_path) as p: +# normalize_actions=False 时 normalize_mode 无意义(stats_path=None 即不归一化) +with HailoPredictor(HEF_PATH, stats_path=stats_path, + normalize_mode=(norm_mode if norm_mode != "none" + else "zscore")) as p: print(f"\ndims: frame_len={p.frame_len} img={p.image_width}x{p.image_height} " f"history={p.history_len} action_dim={p.action_dim}") t0 = time.perf_counter() @@ -130,9 +140,13 @@ with HailoPredictor(HEF_PATH, stats_path=stats_path) as p: ref = torch_pred(imgs_real, hist_real, True, imu_real) ref_zero_pred = torch_pred(imgs_real, hist_real, False, imu_real) - # torch 参考是模型原始 z 空间输出;HailoPredictor 带 stats 时已反归一化 + # torch 参考是模型原始归一化空间输出;HailoPredictor 已反归一化 # 到原始动作空间,比较前须同空间对齐,否则归一化模型下 max_diff 无意义 - if stats_path is not None: + if norm_mode == "fixed_range": + from action_norm import denormalize_action + ref = denormalize_action(ref, cfg.data.action_dim) + ref_zero_pred = denormalize_action(ref_zero_pred, cfg.data.action_dim) + elif stats_path is not None: s = np.load(stats_path) ref = ref * s[1] + s[0] ref_zero_pred = ref_zero_pred * s[1] + s[0] diff --git a/actpred/_onnx_validate.py b/actpred/_onnx_validate.py index a40bd37..cf9691d 100644 --- a/actpred/_onnx_validate.py +++ b/actpred/_onnx_validate.py @@ -25,7 +25,8 @@ model.load_state_dict(ck["model_state_dict"]) model.eval() action_stats = None -if cfg.data.normalize_actions: +norm_mode = cfg.data.normalize_mode if cfg.data.normalize_actions else "none" +if norm_mode == "zscore": import os stats_path = cfg.data.action_stats_path or os.path.join( os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy") @@ -37,6 +38,8 @@ if cfg.data.normalize_actions: raise ValueError(f"action stats 形状应为 (2, {cfg.data.action_dim})," f"实际为 {action_stats.shape}: {stats_path}") print(f"history 将 z-score 归一化({stats_path})") +elif norm_mode == "fixed_range": + print("history 将 fixed_range min-max 归一化(action_norm.py,免 stats)") sess = ort.InferenceSession(ONNX) in_names = [i.name for i in sess.get_inputs()] @@ -62,7 +65,11 @@ hist_real = np.array([ ], dtype=np.float32) hist_zero = np.zeros((5, 18), dtype=np.float32) -if action_stats is not None: +if norm_mode == "fixed_range": + from action_norm import normalize_action + hist_real = normalize_action(hist_real, cfg.data.action_dim) + hist_zero = normalize_action(hist_zero, cfg.data.action_dim) +elif action_stats is not None: hist_real = (hist_real - action_stats[0]) / action_stats[1] hist_zero = (hist_zero - action_stats[0]) / action_stats[1] diff --git a/actpred/_set.sh b/actpred/_set.sh index 3b2d3b8..a5ae0b1 100755 --- a/actpred/_set.sh +++ b/actpred/_set.sh @@ -16,6 +16,17 @@ pip install onnxscript onnxruntime -i https://pypi.tuna.tsinghua.edu.cn/simple pip check mkdir -p checkpoints data +# R2-4 防呆:确认装到的是 CPU 版 torch——requirements.txt 含 torch, +# 若被镜像源重装成 CUDA 版(无 GPU 部署会被 CUDA 依赖拖垮)在此拦截 +python3 -c " +import torch +assert torch.version.cuda is None, ( + f'检测到 CUDA 版 torch({torch.__version__}),' + '请用 _set.sh 的 CPU 源重装: pip install torch torchvision ' + '--index-url https://download.pytorch.org/whl/cpu --force-reinstall') +print('torch CPU build OK:', torch.__version__) +" + # Pre-download MobileNetV2 weights for offline fine-tuning (optional, skip if backbone=simple) python3 -c " import torch, sys, os diff --git a/actpred/action_norm.py b/actpred/action_norm.py index 96ae0a9..143e52e 100644 --- a/actpred/action_norm.py +++ b/actpred/action_norm.py @@ -54,9 +54,13 @@ def denormalize_action(norm_action, action_dim: int = None) -> np.ndarray: def default_column_weights(action_dim: int = 18) -> np.ndarray: """逐列损失权重默认表:舵机 1.0(安全关键)、spk 0.2(保留梯度)、 - 保留位 0.0(恒 0 纯噪声,参与只会稀释有效梯度)""" + 保留位 0.0(恒 0 纯噪声,参与只会稀释有效梯度)。 + action_dim>18 的扩展列默认 1.0:超出当前 18 列语义布局的通道视为 + 新执行器,静默给 0 会让其完全不参与训练(P2-a1)。""" w = np.zeros(action_dim, dtype=np.float32) w[:min(10, action_dim)] = 1.0 if action_dim > 10: w[10:min(12, action_dim)] = 0.2 + if action_dim > 18: + w[18:] = 1.0 return w diff --git a/actpred/dataset.py b/actpred/dataset.py index f812c52..492c946 100644 --- a/actpred/dataset.py +++ b/actpred/dataset.py @@ -43,6 +43,10 @@ class ActionDataset(Dataset): self.rollout_len = max(1, rollout_len) self.transform = transforms.Compose([ + # Resize 不保持宽高比(直接拉伸)是有意为之:推理端 + # (predict/onnx_infer/_onnx_validate)使用完全相同的预处理, + # 训练/推理口径必须一致;改为 letterbox 会使既有 checkpoint + # 输入分布失配,如需变更须训练/推理同步修改并重训(P2-d) transforms.Resize((image_height, image_width)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), diff --git a/actpred/export_deploy.py b/actpred/export_deploy.py index 1f1b00c..a698e95 100644 --- a/actpred/export_deploy.py +++ b/actpred/export_deploy.py @@ -13,6 +13,7 @@ Reshape 序列。此脚本逐项改造(权重数值完全不变): import argparse import os +import sys import numpy as np import torch @@ -21,6 +22,10 @@ import torch.nn as nn from config import parse_checkpoint_config from model import ActionPredictor +# 一致性校验阈值:wrapper 改造/ORT 转换与原模型输出差异超过即拦截退出, +# 防止 ManualAttnBlock 依赖的 PyTorch 布局约定变更静默穿透(v3-P2-2) +MAX_TOLERATED_DIFF = 1e-4 + IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) @@ -280,7 +285,11 @@ def main(): with torch.no_grad(): orig_out = model(stacked, dummy_history.squeeze(1), dummy_imu.squeeze(1).squeeze(1) if imu_dim > 0 else None) - print(f"wrapper 与原模型最大差异: {(ref.squeeze(1) - orig_out).abs().max():.6e}") + wrap_diff = (ref.squeeze(1) - orig_out).abs().max().item() + print(f"wrapper 与原模型最大差异: {wrap_diff:.6e}") + if wrap_diff > MAX_TOLERATED_DIFF: + sys.exit(f"错误: wrapper 与原模型差异 {wrap_diff:.3e} > " + f"{MAX_TOLERATED_DIFF:.0e}(ManualAttnBlock 布局约定可能已失效)") try: import onnxruntime as ort @@ -292,7 +301,11 @@ def main(): if imu_dim > 0: feeds["imu"] = dummy_imu.numpy() ort_out = sess.run(None, feeds)[0] - print(f"ONNX 一致性验证通过(最大差异: {np.abs(ort_out - ref.numpy()).max():.6e})") + ort_diff = np.abs(ort_out - ref.numpy()).max() + if ort_diff > MAX_TOLERATED_DIFF: + sys.exit(f"错误: ONNX 与 wrapper 差异 {ort_diff:.3e} > " + f"{MAX_TOLERATED_DIFF:.0e}") + print(f"ONNX 一致性验证通过(最大差异: {ort_diff:.6e})") except ImportError: pass diff --git a/actpred/export_onnx.py b/actpred/export_onnx.py index 0f588f1..4528f87 100644 --- a/actpred/export_onnx.py +++ b/actpred/export_onnx.py @@ -1,9 +1,14 @@ import argparse +import sys + import torch from torch.export import Dim from config import parse_checkpoint_config from model import ActionPredictor +# 一致性校验阈值:ORT 转换与原模型输出差异超过即拦截退出(v3-P2-2) +MAX_TOLERATED_DIFF = 1e-4 + def main(): parser = argparse.ArgumentParser(description="Export actpred model to ONNX") @@ -75,6 +80,9 @@ def main(): with torch.no_grad(): torch_out = model(dummy_images, dummy_history, *extra_inputs).numpy() max_diff = np.abs(ort_out - torch_out).max() + if max_diff > MAX_TOLERATED_DIFF: + sys.exit(f"错误: ONNX 与 PyTorch 输出差异 {max_diff:.3e} > " + f"{MAX_TOLERATED_DIFF:.0e}") print(f"ONNX 一致性验证通过(最大差异: {max_diff:.6e})") except ImportError: print("提示: 安装 onnxruntime 可验证导出模型一致性 (pip install onnxruntime)") diff --git a/actpred/test_actpred.py b/actpred/test_actpred.py index bafa983..140bf38 100644 --- a/actpred/test_actpred.py +++ b/actpred/test_actpred.py @@ -2011,6 +2011,10 @@ class TestFixedRangeNorm(unittest.TestCase): # 12 列:舵机 1.0 + spk 0.2 w12 = default_column_weights(12) np.testing.assert_allclose(w12[10:], 0.2) + # 20 列:超出 18 列语义布局的扩展列默认 1.0(新执行器须参与训练) + w20 = default_column_weights(20) + np.testing.assert_allclose(w20[12:18], 0.0) + np.testing.assert_allclose(w20[18:], 1.0) # ---------------- dataset 链路 ---------------- diff --git a/actpred/train.py b/actpred/train.py index 54cbad2..aae7efa 100644 --- a/actpred/train.py +++ b/actpred/train.py @@ -246,6 +246,9 @@ def main(): model.load_state_dict(resume_ckpt["model_state_dict"]) optimizer.load_state_dict(resume_ckpt["optimizer_state_dict"]) scheduler.load_state_dict(resume_ckpt["scheduler_state_dict"]) + # 注意:scheduler 状态(含 T_max)随 checkpoint 恢复,LR 余弦曲线 + # 按原调度继续;resume 时改 --epochs 只影响循环边界,不重塑 LR + # 曲线(如需新曲线请重新训练,勿 resume) start_epoch = resume_ckpt["epoch"] + 1 best_val_loss = resume_ckpt.get("best_val_loss", float("inf")) patience_counter = resume_ckpt.get("patience_counter", 0) diff --git a/docs/reviews/pybot_actpred_AUDIT_20260814_v3.md b/docs/reviews/pybot_actpred_AUDIT_20260814_v3.md index 631daa8..f9ebe4c 100644 --- a/docs/reviews/pybot_actpred_AUDIT_20260814_v3.md +++ b/docs/reviews/pybot_actpred_AUDIT_20260814_v3.md @@ -9,9 +9,10 @@ > > 本报告对照 v2 报告逐项核对修复状态,并给出本轮新发现。 > -> **2026-08-14 更新:P0-2(方案 B)、两个新 P1(v3-P1-1/v3-P1-2)、P2-a 残余(ONNX 分支) -> 与 P2-a3(column_weights 文档)已修复,附回归测试(pybot +1 / actpred +7)。 -> P0/P1 全部清零,剩余 3 项新 P2 + 5 项遗留 P2 + 2 项 P3。** +> **2026-08-14 更新:P0-2(方案 B)、两个新 P1、P2-a 残余、P2-a3 已修复; +> 3 项新 P2(验收脚本 fixed_range / diff 断言 / overrun 节流)与 5 项遗留 P2 +> (P2-a1/P2-c 残余/P2-d/P2-e/P2-f/R2-4)亦全部闭环,附回归测试(pybot +2 / actpred +7)。 +> P0/P1/P2 全部清零,仅剩 2 项 P3(边缘/环境便利)。** --- @@ -21,10 +22,10 @@ |------|--------|----------------------|------| | ~~P0(崩溃/数据损坏)~~ | 0 | ~~1~~ **0(P0-2 已按方案 B 修复)** | 见 §三 | | ~~P1(功能风险)~~ | ~~2~~ **0(均已修复)** | ~~1~~ **0(P2-a 已闭环)** | 见 §四 | -| P2(代码质量) | 3 | 5(P2-a3 已随 P0-2 一并修复) | 见 §五 | +| ~~P2(代码质量)~~ | ~~3~~ **0(均已修复)** | ~~5~~ **0(全部闭环)** | 见 §五/§六 | | P3(边缘/环境) | 2 | 0 | 见 §五 | -**重点:P0-2 曾随 85bf68f 未修复提交,本轮已按方案 B 闭环;两个新 P1 与 P2-a 残余已于本轮回合修复。** +**重点:P0-2 曾随 85bf68f 未修复提交,本轮已按方案 B 闭环;两个新 P1、P2-a 残余与全部 P2 已于本轮回合修复。** --- @@ -42,8 +43,8 @@ | R2-9 predict.py --action-dim 陷阱 | P2 | ✅ 已修复 | predict.py 已移除该参数,统一用 `predictor.action_dim` | | R1-2 parse_action_history 三处重复 | P2 | ✅ 已修复 | 三推理文件均从 [utils.py](../../actpred/utils.py) 导入 | | R2-3 空验证集无 best.pt | P2 | ✅ 已修复 | [train.py:309-324](../../actpred/train.py#L309-L324) 保底保存最终权重 | -| P2-c 闭环测试覆盖 | P2 | ⚠️ 大部分修复 | test_loop.py 已含 recorder 集成(imu_recorded/multi_recorded);**predictor 异常清理路径仍无用例** | -| P2-b numpy 三处 lazy import | P2 | ⚠️ 修复引入回归 | 改为顶层导入,**破坏零依赖承诺** → 见新发现 v3-P1-1 | +| P2-c 闭环测试覆盖 | P2 | ✅ 已修复 | test_loop.py 含 recorder 集成 + 异常清理路径用例(test_predictor_exception_still_flushes_recorder) | +| P2-b numpy 三处 lazy import | P2 | ✅ 已修复 | 顶层导入曾破坏零依赖(v3-P1-1),已恢复函数级 lazy import 并加守卫测试 | | P0-2 normalize_actions 默认值 | P0 | ✅ 已修复 | [README.md:170](../../actpred/README.md#L170) 更正为 `True` 并注明行为变更(方案 B,见 §三) | --- @@ -91,23 +92,26 @@ _build_action_vec / _grab_frame)恢复函数内 lazy import,并在导入块 ## 五、P2/P3 新发现 -### v3-P2-1 验收脚本链未适配 fixed_range +### v3-P2-1 验收脚本链未适配 fixed_range —— ✅ 已修复(2026-08-14) -**位置**:[actpred/_npu_test.py:64-71](../../actpred/_npu_test.py#L64-L71)、[_onnx_validate.py:28-39](../../actpred/_onnx_validate.py#L28-L39) +[_npu_test.py:65-76](../../actpred/_npu_test.py#L65-L76)、[_onnx_validate.py:28-42](../../actpred/_onnx_validate.py#L28-L42) +均改为按 checkpoint config 的 `normalize_mode` 分支:zscore 照旧加载 +action_stats.npy;fixed_range 走 action_norm min-max 且免 stats 文件; +_npu_test 另向 HailoPredictor 透传 normalize_mode、torch 参考输出按同 +模式反归一化后再对比。 -两脚本在 `normalize_actions=True` 时无条件 `np.load(action_stats.npy)`:fixed_range 训练不产出该文件 → `FileNotFoundError`;`_npu_test.py` 构造 HailoPredictor 时也未传 `normalize_mode`(默认 zscore),torch 参考只对 z-score 反归一化。**fixed_range 模型无法通过部署验收**(恰好是最推荐部署的模式)。建议按 checkpoint config 的 normalize_mode 分支处理(同 hailo_infer main 的自动判定逻辑)。 +### v3-P2-2 导出一致性校验只打印不拦截 —— ✅ 已修复(2026-08-14) -### v3-P2-2 导出一致性校验只打印不拦截 +[export_deploy.py:288-292,303-307](../../actpred/export_deploy.py#L288-L292) +与 [export_onnx.py:82-85](../../actpred/export_onnx.py#L82-L85) 新增 +`MAX_TOLERATED_DIFF = 1e-4` 阈值断言,超限 `sys.exit` 拦截(P2-f 的 +ManualAttnBlock 布局约定风险一并闭环)。 -**位置**:[export_deploy.py:283](../../actpred/export_deploy.py#L283)、[export_deploy.py:295](../../actpred/export_deploy.py#L295)、[export_onnx.py:59](../../actpred/export_onnx.py#L59) +### v3-P2-3 run_loop overrun 逐步告警日志噪音 + 建议阈值偏离约束 —— ✅ 已修复(2026-08-14) -wrapper vs 原模型、ORT vs wrapper 的 max diff 均仅 print,进程照常退出 0。ManualAttnBlock 依赖 PyTorch 内部布局约定(v2 P2-f 遗留),一旦上游变更布局,转换错误在自动化流水线中完全无感。建议加阈值断言(如 diff > 1e-4 → `SystemExit`)。 - -### v3-P2-3 run_loop overrun 逐步告警日志噪音 + 建议阈值偏离约束 - -**位置**:[robot.py:308-311](../../pybot/pybot/robot.py#L308-L311) - -每个 overrun 步都 `log.warning`——10Hz 持续 50% overrun 时每秒 5 条告警刷屏;且工程约束要求"overrun 比例 >10% 才建议降 hz",当前 [L318-321](../../pybot/pybot/robot.py#L318-L321) 只要 overrun>0 就给建议。建议:逐步告警改节流(如每 100 步汇总一次),最终建议仅在比例 >10% 时输出。 +[robot.py:310-317](../../pybot/pybot/robot.py#L310-L317) 逐步告警节流为 +第 1 次 + 每 100 次汇总;[robot.py:324-332](../../pybot/pybot/robot.py#L324-L332) +最终建议仅在 overrun 比例 >10% 时输出(否则 info 级汇总),对齐工程约束。 ### v3-P3-1 dfc_compile.py --output 无 .hef 后缀时产物互相覆盖 @@ -123,30 +127,32 @@ wrapper vs 原模型、ORT vs wrapper 的 max diff 均仅 print,进程照常 --- -## 六、v2 遗留 P2(仍未修复,维持原判级) +## 六、v2 遗留 P2(已全部修复,2026-08-14) -| # | 位置 | 问题 | 备注 | +| # | 位置 | 问题 | 处理 | |---|------|------|------| -| P2-a1 | [action_norm.py:55-62](../../actpred/action_norm.py#L55-L62) | `default_column_weights` 对 action_dim>18 的超出列权重恒 0 | 未来扩展性 | -| P2-a3 | [README.md:172](../../actpred/README.md#L172) | ~~column_weights 始终生效(含不归一化训练)未在文档注明~~ ✅ 已随 P0-2 一并修复 | 已注明"始终生效" | -| P2-d | [dataset.py:46](../../actpred/dataset.py#L46) | Resize 不保持宽高比 | 训练/推理口径一致,仅变形质量 nit | -| P2-e | [train.py:236,248](../../actpred/train.py#L236) | resume 时 scheduler T_max 恢复旧值,改 --epochs 不影响 LR 曲线 | 语义需在文档明确 | -| P2-f | [export_deploy.py:42-59](../../actpred/export_deploy.py#L42-L59) | ManualAttnBlock 依赖 PyTorch in_proj 内部布局 | 数值验证已缓解,配合 v3-P2-2 加断言更稳 | -| R2-4 | actpred/_set.sh | CUDA 版 torch 误装风险 | 安装脚本注意事项 | +| P2-a1 | [action_norm.py:55-66](../../actpred/action_norm.py#L55-L66) | ~~`default_column_weights` 对 action_dim>18 的超出列权重恒 0~~ | ✅ 扩展列默认 1.0(新执行器须参与训练),补 20 列断言 | +| P2-a3 | [README.md:172](../../actpred/README.md#L172) | ~~column_weights 始终生效(含不归一化训练)未在文档注明~~ | ✅ 已随 P0-2 一并修复 | +| P2-c 残余 | test_loop.py | ~~predictor 异常清理路径无用例~~ | ✅ 新增 test_predictor_exception_still_flushes_recorder(异常传播 + finally 落盘 1 步) | +| P2-d | [dataset.py:46-49](../../actpred/dataset.py#L46-L49) | ~~Resize 不保持宽高比~~ | ✅ 注明有意为之(训练/推理同口径,变更须同步重训),不改行为 | +| P2-e | [train.py:248-251](../../actpred/train.py#L248-L251) | ~~resume 时 scheduler T_max 恢复旧值~~ | ✅ 语义已在代码注释 + README --resume 行明确 | +| P2-f | export_deploy.py | ~~ManualAttnBlock 依赖 PyTorch in_proj 内部布局~~ | ✅ 随 v3-P2-2 阈值断言闭环(布局失效即拦截退出) | +| R2-4 | [_set.sh:19-28](../../actpred/_set.sh#L19-L28) | ~~CUDA 版 torch 误装风险~~ | ✅ 安装后断言 `torch.version.cuda is None`,误装即拦截并给出修复命令 | --- ## 七、修复优先级 -| 优先级 | 项 | 理由 | +| 优先级 | 项 | 状态 | |--------|-----|------| -| ~~P0~~ | ~~P0-2 README 与默认值对齐(方案 B)~~ ✅ 已修复 | README 默认值更正 + 行为变更注明 | -| ~~P1~~ | ~~v3-P1-1 robot.py numpy lazy import~~ ✅ 已修复 | 零依赖承诺已恢复,附子进程回归测试 | -| ~~P1~~ | ~~v3-P1-2 ONNX predict_delta + 元数据~~ ✅ 已修复 | 元数据写入/识别/残差加回全链路 + 7 用例 | -| P2 | v3-P2-1 验收脚本 fixed_range 适配 | fixed_range 是最推荐部署模式,验收链不能缺席 | -| P2 | v3-P2-2 导出 diff 阈值断言 | 一行拦截,防布局约定变更静默穿透 | -| P2 | v3-P2-3 overrun 告警节流 + 10% 阈值 | 对齐工程约束,消除日志刷屏 | -| P3 | v3-P3-1/-P3-2 | 边缘情况与开发环境便利 | +| ~~P0~~ | P0-2 README 与默认值对齐(方案 B) | ✅ 已修复 | +| ~~P1~~ | v3-P1-1 robot.py numpy lazy import | ✅ 已修复(附子进程回归测试) | +| ~~P1~~ | v3-P1-2 ONNX predict_delta + 元数据 | ✅ 已修复(全链路 + 7 用例) | +| ~~P2~~ | v3-P2-1 验收脚本 fixed_range 适配 | ✅ 已修复 | +| ~~P2~~ | v3-P2-2 导出 diff 阈值断言 | ✅ 已修复(P2-f 一并闭环) | +| ~~P2~~ | v3-P2-3 overrun 告警节流 + 10% 阈值 | ✅ 已修复 | +| ~~P2~~ | P2-a1/P2-c 残余/P2-d/P2-e/R2-4 | ✅ 全部闭环(见 §六) | +| P3 | v3-P3-1 dfc_compile --output 后缀校验 / v3-P3-2 conftest 内置 fcntl mock | 未修复(边缘/环境便利) | --- diff --git a/pybot/pybot/robot.py b/pybot/pybot/robot.py index 65d13f8..d36e094 100644 --- a/pybot/pybot/robot.py +++ b/pybot/pybot/robot.py @@ -308,9 +308,13 @@ class RobotStruct: time.sleep(remain) else: overrun += 1 - log.warning("loop overrun: step %d took %.1fms > %.1fms " - "(推理+执行超过周期,实际频率已低于 %.1fHz)", - step, (dt - remain) * 1000, dt * 1000, hz) + # 告警节流:第 1 次及每 100 次汇总一次,避免持续 overrun + # 刷屏(50% overrun @10Hz 时原实现每秒 5 条告警) + if overrun == 1 or overrun % 100 == 0: + log.warning("loop overrun ×%d: step %d took %.1fms > " + "%.1fms(推理+执行超过周期)", + overrun, step, (dt - remain) * 1000, + dt * 1000) finally: # max_steps 正常退出与 Ctrl-C(KeyboardInterrupt)都要落盘, # 否则闭环录制的 actions.npy/imu.npy 全部丢失 @@ -318,9 +322,14 @@ class RobotStruct: path = recorder.flush() log.info("episode saved: %s", path) if overrun: - log.warning("closed loop overrun %d/%d steps (%.0f%%)," - "建议降低 --hz 或检查推理后端延迟", - overrun, step, 100.0 * overrun / max(step, 1)) + ratio = overrun / max(step, 1) + if ratio > 0.10: # 工程约束:overrun 比例 >10% 才建议降 hz + log.warning("closed loop overrun %d/%d steps (%.0f%%)," + "建议降低 --hz 或检查推理后端延迟", + overrun, step, 100.0 * ratio) + else: + log.info("closed loop overrun %d/%d steps (%.1f%%)", + overrun, step, 100.0 * ratio) log.info("closed loop stop after %d steps", step) def _loop_recent_frames(self, camera_var, F): diff --git a/pybot/tests/test_loop.py b/pybot/tests/test_loop.py index 8e9cc4c..ff1775c 100644 --- a/pybot/tests/test_loop.py +++ b/pybot/tests/test_loop.py @@ -489,3 +489,27 @@ def test_robot_importable_without_numpy(): r = subprocess.run([sys.executable, "-c", child], capture_output=True, text=True, cwd=repo) assert r.returncode == 0 and "ok" in r.stdout, r.stderr + + +def test_predictor_exception_still_flushes_recorder(mock_i2c, tmp_path): + """P2-c:predictor 中途抛异常时 run_loop 的 finally 仍落盘, + 已录制 steps 不丢失且异常照常向上传播(由 with robot 清理硬件)""" + from pybot.recorder import EpisodeRecorder + + class RaisingPredictor(MockPredictor): + def predict(self, images, action_history): + if self._step >= 1: + raise RuntimeError("backend boom") + return super().predict(images, action_history) + + robot = _make_robot(LOOP_MT, mock_i2c) + robot.active() + pred = RaisingPredictor(frame_len=3, history_len=2, action_dim=4) + rec = EpisodeRecorder(str(tmp_path), action_dim=4) + with pytest.raises(RuntimeError, match="backend boom"): + robot.run_loop(pred, hz=1000.0, max_steps=5, recorder=rec) + robot.deactive() + + # 第 1 步已录制(第 2 步 predict 抛异常),finally flush 已落盘 + acts = np.load(tmp_path / "episode_0000" / "actions.npy") + assert acts.shape[0] == 1