diff --git a/actpred/README.md b/actpred/README.md index f46163d..0db7cba 100644 --- a/actpred/README.md +++ b/actpred/README.md @@ -1,19 +1,26 @@ # actpred — 动作预测模块 -输入摄像头图像 + 历史动作序列,预测下一个连续动作(回归)。 +输入多帧摄像头图像 + 历史动作序列,预测下一时间步的连续动作(回归)。 -纯神经网络实现(PyTorch),支持轻量 CNN 或 MobileNetV2 图像编码,MLP/GRU 动作历史编码。总参数量 ~140K(默认)至 ~2.3M。 +纯神经网络实现(PyTorch):轻量 CNN 或 MobileNetV2 逐帧编码,图像/动作 token 进轻量 +Transformer(pre-LN)自注意力融合,取 [PRED] token 回归下一步动作。 +默认配置总参数量 ~450K。 ## 架构 ``` -图像 (3×64×64) ──► CNN 编码器 ──► img_feat (128) - │ - ├─ concat ──► 融合头 ──► 预测动作 (action_dim) - │ -动作历史 (H×D) ──► MLP/GRU ──► act_feat (64) +F 帧图像 (F×3×64×64) ─► 共享 CNN 编码器 ─► F 个 token ─► proj → d_model ┐ + ├─► [img×F, act×H, PRED] + 位置编码 +动作历史 (H×D) ─► Linear proj → d_model ─► H 个 token ────────┤ │ +可学习 [PRED] token ────────────────────────────────────────┘ ▼ + TransformerEncoder (pre-LN, 2 层, 4 头, d_model=128) + │ + 取 PRED 位置输出 ─► MLP 头 ─► 预测动作 (action_dim) ``` +token 序列长度固定为 F+H+1(默认 3+5+1=9),除 batch 外无动态 shape,对 NPU(如树莓派 +AI 扩展板)部署友好。 + ## 快速开始 ```bash @@ -23,8 +30,10 @@ cd actpred && bash _set.sh # 训练 bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 -# 推理(--action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步) -bash _run.sh --model checkpoints/best_inference.pt --image frame.jpg \ +# 推理(--images 按时间顺序旧→新传入 frame_len 个图像路径,冷启动时可重复传同一帧; +# --action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步) +bash _run.sh --model checkpoints/best_inference.pt \ + --images frame_t-2.jpg frame_t-1.jpg frame_t.jpg \ --action-history "45,0,45,0,45,0,45,0,90,90,10,200,0,0,0,0,0,0:45,0,45,0,45,0,45,0,90,90,0,20,0,0,0,0,0,0:45,0,45,0,45,0,45,0,90,90,0,0,0,0,0,0,0,0:45,0,45,0,45,0,45,0,90,90,0,0,0,0,0,0,0,0:45,0,45,0,45,0,45,0,90,90,0,0,0,0,0,0,0,0" # 导出 ONNX(如安装了 onnxruntime,导出后自动验证一致性) @@ -39,7 +48,7 @@ python test_actpred.py ## 预训练(从 .mt 脚本,无需图像) -从 bot4cj 的 `.mt` 动作脚本中提取 `@Action` 矩阵,用零图像占位预训练动作动力学(history → next action), +从 bot4cj 的 `.mt` 动作脚本中提取 `@Action` 矩阵,用全零多帧图像占位预训练动作动力学(history → next action), 保存的 checkpoint 与正常训练格式完全兼容,可直接用于推理或作为微调起点。 ```bash @@ -74,7 +83,8 @@ data/ ... ``` -每个 timestep 的样本:当前图像 + 前 `history_len` 步动作 → 当前步动作。 +每个 timestep 的样本:帧 `[t-frame_len+1, t]`(旧→新)+ 前 `history_len` 步动作 → 当前步动作。 +采样起点为 `max(history_len, frame_len-1)`。 ### 数据采集 @@ -96,8 +106,11 @@ recorder.flush() | `--image-size` | 64 | 图像缩放尺寸 | | `--action-dim` | 18 | 动作向量维度(对齐 bot4cj 18 列:10 舵机 + 2 云台 + 扬声器频率 + 延时 + 4 预留)| | `--history-len` | 5 | 历史动作窗口长度 | +| `--frame-len` | 3 | 输入图像帧数(含当前帧,旧帧在前)| | `--backbone` | simple | CNN 类型 (simple/mobilenet) | -| `--history-encoder` | mlp | 历史编码器 (mlp/gru) | +| `--attn-dim` | 128 | 注意力 token 维度 d_model(须能被 attn-heads 整除)| +| `--attn-heads` | 4 | 注意力头数 | +| `--attn-layers` | 2 | TransformerEncoder 层数 | | `--epochs` | 100 | 训练轮数 | | `--lr` | 0.001 | 学习率 | | `--batch-size` | 32 | 批次大小 | @@ -124,7 +137,8 @@ recorder.flush() from predict import ActionPredictorInference predictor = ActionPredictorInference("checkpoints/best_inference.pt") -print(predictor.action_dim, predictor.history_len) # 公共属性 -action = predictor.predict(image_rgb, action_history) # np.ndarray (action_dim,) +print(predictor.action_dim, predictor.history_len, predictor.frame_len) # 公共属性 +# images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧;冷启动可重复同一帧) +action = predictor.predict(images, action_history) # np.ndarray (action_dim,) # action_history 形状须为 (history_len, action_dim) ``` diff --git a/actpred/config.py b/actpred/config.py index f6e1e98..395aa54 100644 --- a/actpred/config.py +++ b/actpred/config.py @@ -8,6 +8,7 @@ class DataConfig: image_size: int = 64 action_dim: int = 18 history_len: int = 5 + frame_len: int = 3 data_dir: str = "./data" normalize_actions: bool = False action_stats_path: Optional[str] = None @@ -17,10 +18,11 @@ class DataConfig: class ModelConfig: cnn_backbone: str = "simple" cnn_feature_dim: int = 128 - action_hidden_dim: int = 64 fused_hidden_dim: int = 128 dropout: float = 0.1 - history_encoder: str = "mlp" + attn_dim: int = 128 + attn_heads: int = 4 + attn_layers: int = 2 @dataclass @@ -60,15 +62,17 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser: parser.add_argument("--image-size", type=int, default=None) parser.add_argument("--action-dim", type=int, default=None) parser.add_argument("--history-len", type=int, default=None) + parser.add_argument("--frame-len", type=int, default=None) parser.add_argument("--data-dir", type=str, default=None) parser.add_argument("--normalize-actions", action=argparse.BooleanOptionalAction, default=None) parser.add_argument("--action-stats-path", type=str, default=None) parser.add_argument("--backbone", type=str, default=None, choices=["simple", "mobilenet"]) parser.add_argument("--cnn-feature-dim", type=int, default=None) - parser.add_argument("--action-hidden-dim", type=int, default=None) parser.add_argument("--fused-hidden-dim", type=int, default=None) parser.add_argument("--dropout", type=float, default=None) - parser.add_argument("--history-encoder", type=str, default=None, choices=["mlp", "gru"]) + parser.add_argument("--attn-dim", type=int, default=None) + parser.add_argument("--attn-heads", type=int, default=None) + parser.add_argument("--attn-layers", type=int, default=None) parser.add_argument("--batch-size", type=int, default=None) parser.add_argument("--epochs", type=int, default=None) parser.add_argument("--lr", type=float, default=None) @@ -95,11 +99,14 @@ def validate_config_values(data_cfg: DataConfig, model_cfg: ModelConfig, checks = [ ("image_size", data_cfg.image_size, 1, 1024, int), ("history_len", data_cfg.history_len, 1, 1024, int), + ("frame_len", data_cfg.frame_len, 1, 1024, int), ("action_dim", data_cfg.action_dim, 1, 1024, int), ("cnn_feature_dim", model_cfg.cnn_feature_dim, 1, 4096, int), - ("action_hidden_dim", model_cfg.action_hidden_dim, 1, 4096, int), ("fused_hidden_dim", model_cfg.fused_hidden_dim, 1, 4096, int), ("dropout", model_cfg.dropout, 0.0, 1.0, float), + ("attn_dim", model_cfg.attn_dim, 1, 4096, int), + ("attn_heads", model_cfg.attn_heads, 1, 64, int), + ("attn_layers", model_cfg.attn_layers, 1, 64, int), ] for name, val, lo, hi, typ in checks: if typ is int: @@ -110,6 +117,11 @@ def validate_config_values(data_cfg: DataConfig, model_cfg: ModelConfig, raise ValueError( f"checkpoint config {name}={val} 超出合理范围 [{lo}, {hi}]") + if model_cfg.attn_dim % model_cfg.attn_heads != 0: + raise ValueError( + f"attn_dim={model_cfg.attn_dim} 必须能被 attn_heads=" + f"{model_cfg.attn_heads} 整除") + if train_cfg is not None: t_checks = [ ("batch_size", train_cfg.batch_size, 1, 65536, int), @@ -162,15 +174,17 @@ def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config: "image_size": ("data", "image_size"), "action_dim": ("data", "action_dim"), "history_len": ("data", "history_len"), + "frame_len": ("data", "frame_len"), "data_dir": ("data", "data_dir"), "normalize_actions": ("data", "normalize_actions"), "action_stats_path": ("data", "action_stats_path"), "backbone": ("model", "cnn_backbone"), "cnn_feature_dim": ("model", "cnn_feature_dim"), - "action_hidden_dim": ("model", "action_hidden_dim"), "fused_hidden_dim": ("model", "fused_hidden_dim"), "dropout": ("model", "dropout"), - "history_encoder": ("model", "history_encoder"), + "attn_dim": ("model", "attn_dim"), + "attn_heads": ("model", "attn_heads"), + "attn_layers": ("model", "attn_layers"), "batch_size": ("train", "batch_size"), "epochs": ("train", "epochs"), "lr": ("train", "lr"), diff --git a/actpred/dataset.py b/actpred/dataset.py index 54a14ae..fd4e924 100644 --- a/actpred/dataset.py +++ b/actpred/dataset.py @@ -17,10 +17,12 @@ def _natural_key(p): class ActionDataset(Dataset): def __init__(self, data_dir: str, history_len: int, action_dim: int, image_size: int = 64, normalize_actions: bool = False, - action_stats_path: str = None, episode_indices: list = None): + action_stats_path: str = None, episode_indices: list = None, + frame_len: int = 3): self.history_len = history_len self.action_dim = action_dim self.image_size = image_size + self.frame_len = frame_len self.transform = transforms.Compose([ transforms.Resize((image_size, image_size)), @@ -51,8 +53,11 @@ class ActionDataset(Dataset): print(f"WARNING: {ep_dir} 动作帧数 {n_frames} 与图像数 {n_images} 不一致," f"按较少者({min(n_frames, n_images)})使用,请检查录制完整性") n = min(n_frames, n_images) - for t in range(history_len, n): - self.samples.append((image_files[t], actions[t - history_len:t], actions[t])) + # 每个样本:帧 [t-frame_len+1, t](旧→新)+ 动作历史 [t-history_len, t) → 动作 t + start = max(history_len, frame_len - 1) + for t in range(start, n): + self.samples.append((image_files[t - frame_len + 1: t + 1], + actions[t - history_len:t], actions[t])) self._action_mean = None self._action_std = None @@ -77,15 +82,18 @@ class ActionDataset(Dataset): return len(self.samples) def __getitem__(self, idx): - image_path, action_history, target = self.samples[idx] - image = Image.open(image_path).convert("RGB") - image = self.transform(image) + image_paths, action_history, target = self.samples[idx] + frames = [] + for p in image_paths: + image = Image.open(p).convert("RGB") + frames.append(self.transform(image)) + images = torch.stack(frames) # (F, 3, S, S) action_history = torch.tensor(action_history, dtype=torch.float32) target = torch.tensor(target, dtype=torch.float32) if self._action_mean is not None: action_history = (action_history - self._action_mean) / self._action_std target = (target - self._action_mean) / self._action_std - return {"image": image, "action_history": action_history, "target": target} + return {"images": images, "action_history": action_history, "target": target} def get_action_stats(self): if self._action_mean is not None: diff --git a/actpred/export_onnx.py b/actpred/export_onnx.py index ac367fd..06befac 100644 --- a/actpred/export_onnx.py +++ b/actpred/export_onnx.py @@ -19,16 +19,17 @@ def main(): model.load_state_dict(checkpoint["model_state_dict"]) model.eval() - dummy_image = torch.randn(1, 3, data_cfg.image_size, data_cfg.image_size) + dummy_images = torch.randn( + 1, data_cfg.frame_len, 3, data_cfg.image_size, data_cfg.image_size) dummy_history = torch.randn(1, data_cfg.history_len, data_cfg.action_dim) torch.onnx.export( - model, (dummy_image, dummy_history), + model, (dummy_images, dummy_history), args.output, - input_names=["image", "action_history"], + input_names=["images", "action_history"], output_names=["predicted_action"], dynamic_axes={ - "image": {0: "batch"}, + "images": {0: "batch"}, "action_history": {0: "batch"}, "predicted_action": {0: "batch"}, }, @@ -41,11 +42,11 @@ def main(): import onnxruntime as ort import numpy as np sess = ort.InferenceSession(args.output) - img_np = dummy_image.numpy() + img_np = dummy_images.numpy() hist_np = dummy_history.numpy() - ort_out = sess.run(None, {"image": img_np, "action_history": hist_np})[0] + ort_out = sess.run(None, {"images": img_np, "action_history": hist_np})[0] with torch.no_grad(): - torch_out = model(dummy_image, dummy_history).numpy() + torch_out = model(dummy_images, dummy_history).numpy() max_diff = np.abs(ort_out - torch_out).max() print(f"ONNX 一致性验证通过(最大差异: {max_diff:.6e})") except ImportError: diff --git a/actpred/model.py b/actpred/model.py index 30378c4..7a6d6fc 100644 --- a/actpred/model.py +++ b/actpred/model.py @@ -53,34 +53,7 @@ class MobileNetEncoder(nn.Module): return self.proj(x) -class MLPHistoryEncoder(nn.Module): - def __init__(self, input_dim: int, hidden_dim: int = 64, dropout: float = 0.1): - super().__init__() - self.net = nn.Sequential( - nn.Linear(input_dim, hidden_dim), - nn.ReLU(inplace=True), - nn.Dropout(dropout), - nn.Linear(hidden_dim, hidden_dim), - nn.ReLU(inplace=True), - ) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return self.net(x) - - -class GRUHistoryEncoder(nn.Module): - def __init__(self, input_dim: int, hidden_dim: int = 64, dropout: float = 0.1): - super().__init__() - self.gru = nn.GRU(input_size=input_dim, hidden_size=hidden_dim, - num_layers=1, batch_first=True) - self.dropout = nn.Dropout(dropout) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - _, h = self.gru(x) - return self.dropout(h.squeeze(0)) - - -class FusionHead(nn.Module): +class PredictionHead(nn.Module): def __init__(self, input_dim: int, hidden_dim: int, output_dim: int, dropout: float = 0.1): super().__init__() self.net = nn.Sequential( @@ -97,6 +70,13 @@ class FusionHead(nn.Module): class ActionPredictor(nn.Module): + """多帧图像 + 历史动作的 Transformer 注意力融合预测器 + + token 序列:[img×F, act×H, PRED],长度固定为 F+H+1, + 取 PRED 位置输出进预测头回归下一时间步动作。 + 除 batch 维外全部维度静态,保证 ONNX/NPU 部署友好。 + """ + def __init__(self, model_cfg: ModelConfig, data_cfg: DataConfig, pretrained: bool = True): super().__init__() @@ -106,26 +86,44 @@ class ActionPredictor(nn.Module): else: self.image_encoder = SimpleCNN(model_cfg.cnn_feature_dim) - history_input_dim = data_cfg.history_len * data_cfg.action_dim - if model_cfg.history_encoder == "gru": - self.action_encoder = GRUHistoryEncoder( - data_cfg.action_dim, model_cfg.action_hidden_dim, model_cfg.dropout) - else: - self.action_encoder = MLPHistoryEncoder( - history_input_dim, model_cfg.action_hidden_dim, model_cfg.dropout) + d = model_cfg.attn_dim + self.img_proj = nn.Linear(model_cfg.cnn_feature_dim, d) + self.act_proj = nn.Linear(data_cfg.action_dim, d) - fused_input = model_cfg.cnn_feature_dim + model_cfg.action_hidden_dim - self.head = FusionHead( - fused_input, model_cfg.fused_hidden_dim, data_cfg.action_dim, model_cfg.dropout) + seq_len = data_cfg.frame_len + data_cfg.history_len + 1 + self.pred_token = nn.Parameter(torch.zeros(1, 1, d)) + self.pos_embed = nn.Parameter(torch.zeros(1, seq_len, d)) + nn.init.trunc_normal_(self.pred_token, std=0.02) + nn.init.trunc_normal_(self.pos_embed, std=0.02) - self._use_gru = model_cfg.history_encoder == "gru" + encoder_layer = nn.TransformerEncoderLayer( + d_model=d, nhead=model_cfg.attn_heads, + dim_feedforward=d * 2, dropout=model_cfg.dropout, + batch_first=True, norm_first=True) + self.encoder = nn.TransformerEncoder( + encoder_layer, num_layers=model_cfg.attn_layers, + enable_nested_tensor=False) + + self.head = PredictionHead( + d, model_cfg.fused_hidden_dim, data_cfg.action_dim, model_cfg.dropout) + + self._frame_len = data_cfg.frame_len self._history_len = data_cfg.history_len self._action_dim = data_cfg.action_dim + self._image_size = data_cfg.image_size + self._cnn_feature_dim = model_cfg.cnn_feature_dim - def forward(self, image: torch.Tensor, action_history: torch.Tensor) -> torch.Tensor: - img_feat = self.image_encoder(image) - if self._use_gru: - act_feat = self.action_encoder(action_history) - else: - act_feat = self.action_encoder(action_history.flatten(1)) - return self.head(torch.cat([img_feat, act_feat], dim=-1)) + def forward(self, images: torch.Tensor, action_history: torch.Tensor) -> torch.Tensor: + # images: (B, F, 3, S, S) action_history: (B, H, D) + b = images.size(0) + frames = images.reshape( + b * self._frame_len, 3, self._image_size, self._image_size) + img_feat = self.image_encoder(frames).reshape( + b, self._frame_len, self._cnn_feature_dim) + img_tokens = self.img_proj(img_feat) + act_tokens = self.act_proj(action_history) + pred_token = self.pred_token.expand(b, -1, -1) + tokens = torch.cat([img_tokens, act_tokens, pred_token], dim=1) + tokens = tokens + self.pos_embed + out = self.encoder(tokens) + return self.head(out[:, -1]) diff --git a/actpred/predict.py b/actpred/predict.py index 165681d..c9eb971 100644 --- a/actpred/predict.py +++ b/actpred/predict.py @@ -28,6 +28,7 @@ class ActionPredictorInference: self.action_dim = self._data_cfg.action_dim self.history_len = self._data_cfg.history_len + self.frame_len = self._data_cfg.frame_len self._transform = transforms.Compose([ transforms.Resize((self._data_cfg.image_size, self._data_cfg.image_size)), @@ -61,13 +62,23 @@ class ActionPredictorInference: self._action_std = stats[1].astype(np.float32) @torch.no_grad() - def predict(self, image: np.ndarray, action_history: np.ndarray) -> np.ndarray: + def predict(self, images, action_history: np.ndarray) -> np.ndarray: + """images: 长度为 frame_len 的 RGB 图像序列(旧→新,末帧为当前帧)""" + if not isinstance(images, (list, tuple)): + images = [images] + if len(images) != self._data_cfg.frame_len: + raise ValueError( + f"images 应为 {self._data_cfg.frame_len} 帧,实际为 {len(images)} 帧。" + f"冷启动时可重复传入同一帧。") expected_shape = (self._data_cfg.history_len, self._data_cfg.action_dim) if action_history.shape != expected_shape: raise ValueError( f"action_history 形状应为 {expected_shape},实际为 {action_history.shape}") - img = Image.fromarray(image) if isinstance(image, np.ndarray) else image - img_tensor = self._transform(img).unsqueeze(0).to(self._device) + frames = [] + for image in images: + img = Image.fromarray(image) if isinstance(image, np.ndarray) else image + frames.append(self._transform(img)) + img_tensor = torch.stack(frames).unsqueeze(0).to(self._device) # (1, F, 3, S, S) history = torch.tensor(action_history, dtype=torch.float32) if self._action_mean is not None: history = (history - self._action_mean) / self._action_std @@ -93,7 +104,9 @@ def parse_action_history(s: str, action_dim: int) -> np.ndarray: def main(): parser = argparse.ArgumentParser(description="actpred inference") parser.add_argument("--model", required=True, help="checkpoint path") - parser.add_argument("--image", required=True, help="image file path") + parser.add_argument("--images", required=True, nargs="+", + help="按时间顺序(旧→新)传入 frame_len 个图像路径;" + "冷启动时可重复传同一帧路径") parser.add_argument("--action-history", required=True, help="冒号分隔各步,每步 action_dim 个值,共 history_len 步。" "e.g. '0,0,0,0:1,0,0,0' (action_dim=4, history_len=2)") @@ -106,9 +119,13 @@ def main(): predictor = ActionPredictorInference(args.model, args.device) action_dim = args.action_dim or predictor.action_dim - image = Image.open(args.image).convert("RGB") + if len(args.images) != predictor.frame_len: + raise SystemExit( + f"--images 需传入 {predictor.frame_len} 个路径(旧→新)," + f"实际为 {len(args.images)} 个。冷启动时可重复传同一帧路径。") + images = [np.array(Image.open(p).convert("RGB")) for p in args.images] history = parse_action_history(args.action_history, action_dim) - predicted = predictor.predict(np.array(image), history) + predicted = predictor.predict(images, history) if args.output_format == "json": print(json.dumps({"predicted_action": predicted.tolist()}, indent=2)) diff --git a/actpred/pretrain_mt.py b/actpred/pretrain_mt.py index dbeb29e..ed18375 100644 --- a/actpred/pretrain_mt.py +++ b/actpred/pretrain_mt.py @@ -76,24 +76,25 @@ class MTActionDataset(Dataset): } -def pretrain_one_epoch(model, loader, optimizer, device, image_size, +def pretrain_one_epoch(model, loader, optimizer, device, image_size, frame_len, max_grad_norm=None, noise_std=0.0): model.train() total_loss = 0.0 count = 0 - zero_image = None + zero_images = None for batch in tqdm(loader, desc=" pretrain", leave=False): history = batch["action_history"].to(device) target = batch["target"].to(device) if noise_std > 0: history = history + torch.randn_like(history) * noise_std target = target + torch.randn_like(target) * noise_std - if zero_image is None or zero_image.size(0) != history.size(0): - zero_image = torch.zeros( - history.size(0), 3, image_size, image_size, device=device + if zero_images is None or zero_images.size(0) != history.size(0): + zero_images = torch.zeros( + history.size(0), frame_len, 3, image_size, image_size, + device=device, ) optimizer.zero_grad() - pred = model(zero_image, history) + pred = model(zero_images, history) loss = torch.nn.functional.mse_loss(pred, target) loss.backward() if max_grad_norm: @@ -105,19 +106,20 @@ def pretrain_one_epoch(model, loader, optimizer, device, image_size, @torch.no_grad() -def validate(model, loader, device, image_size): +def validate(model, loader, device, image_size, frame_len): model.eval() total_loss = 0.0 count = 0 - zero_image = None + zero_images = None for batch in tqdm(loader, desc=" val ", leave=False): history = batch["action_history"].to(device) target = batch["target"].to(device) - if zero_image is None or zero_image.size(0) != history.size(0): - zero_image = torch.zeros( - history.size(0), 3, image_size, image_size, device=device + if zero_images is None or zero_images.size(0) != history.size(0): + zero_images = torch.zeros( + history.size(0), frame_len, 3, image_size, image_size, + device=device, ) - pred = model(zero_image, history) + pred = model(zero_images, history) loss = torch.nn.functional.mse_loss(pred, target) total_loss += loss.item() * history.size(0) count += history.size(0) @@ -198,8 +200,10 @@ def main(): print(f"Model params: {n_params:,}") print(f"Config: action_dim={cfg.data.action_dim}, " f"history_len={cfg.data.history_len}, " + f"frame_len={cfg.data.frame_len}, " f"backbone={cfg.model.cnn_backbone}, " - f"encoder={cfg.model.history_encoder}") + f"attn={cfg.model.attn_layers}x{cfg.model.attn_heads}h" + f"@{cfg.model.attn_dim}") print(f"Pre-training: noise_std={args.noise_std}") optimizer = torch.optim.AdamW( @@ -222,9 +226,11 @@ def main(): f"lr={optimizer.param_groups[0]['lr']:.6f}") train_loss = pretrain_one_epoch( model, train_loader, optimizer, device, - cfg.data.image_size, cfg.train.max_grad_norm, args.noise_std, + cfg.data.image_size, cfg.data.frame_len, + cfg.train.max_grad_norm, args.noise_std, ) - val_loss = validate(model, val_loader, device, cfg.data.image_size) + val_loss = validate(model, val_loader, device, + cfg.data.image_size, cfg.data.frame_len) scheduler.step() print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}") diff --git a/actpred/test_actpred.py b/actpred/test_actpred.py index 370eb65..a92a6ad 100644 --- a/actpred/test_actpred.py +++ b/actpred/test_actpred.py @@ -79,45 +79,57 @@ class TestParseActionHistory(unittest.TestCase): class TestActionPredictor(unittest.TestCase): - """测试模型前向传播""" + """测试多帧注意力模型前向传播""" - def test_forward_simple_mlp(self): - data_cfg = DataConfig(image_size=64, action_dim=18, history_len=5) - model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="mlp") + def test_forward_multi_frame(self): + data_cfg = DataConfig(image_size=64, action_dim=18, history_len=5, frame_len=3) + model_cfg = ModelConfig(cnn_backbone="simple") model = ActionPredictor(model_cfg, data_cfg) model.eval() - image = torch.randn(2, 3, 64, 64) + images = torch.randn(2, 3, 3, 64, 64) history = torch.randn(2, 5, 18) with torch.no_grad(): - out = model(image, history) + out = model(images, history) self.assertEqual(out.shape, (2, 18)) - def test_forward_simple_gru(self): - data_cfg = DataConfig(image_size=64, action_dim=18, history_len=5) - model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="gru") + def test_forward_single_frame_degenerate(self): + """frame_len=1 退化为单帧输入也应正常前向""" + data_cfg = DataConfig(image_size=32, action_dim=4, history_len=3, frame_len=1) + model_cfg = ModelConfig(cnn_backbone="simple") model = ActionPredictor(model_cfg, data_cfg) model.eval() - image = torch.randn(2, 3, 64, 64) - history = torch.randn(2, 5, 18) + images = torch.randn(2, 1, 3, 32, 32) + history = torch.randn(2, 3, 4) with torch.no_grad(): - out = model(image, history) - self.assertEqual(out.shape, (2, 18)) + out = model(images, history) + self.assertEqual(out.shape, (2, 4)) def test_forward_dynamic_batch(self): - data_cfg = DataConfig(image_size=32, action_dim=4, history_len=3) - model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="mlp") + data_cfg = DataConfig(image_size=32, action_dim=4, history_len=3, frame_len=2) + model_cfg = ModelConfig(cnn_backbone="simple") model = ActionPredictor(model_cfg, data_cfg) model.eval() for batch_size in [1, 4, 8]: - image = torch.randn(batch_size, 3, 32, 32) + images = torch.randn(batch_size, 2, 3, 32, 32) history = torch.randn(batch_size, 3, 4) with torch.no_grad(): - out = model(image, history) + out = model(images, history) self.assertEqual(out.shape, (batch_size, 4)) + def test_custom_attn_config(self): + """自定义 attn_dim/heads/layers 应可构建并前向""" + data_cfg = DataConfig(image_size=32, action_dim=4, history_len=3, frame_len=2) + model_cfg = ModelConfig(attn_dim=64, attn_heads=2, attn_layers=1) + model = ActionPredictor(model_cfg, data_cfg) + model.eval() + + with torch.no_grad(): + out = model(torch.randn(1, 2, 3, 32, 32), torch.randn(1, 3, 4)) + self.assertEqual(out.shape, (1, 4)) + class TestDataRecorder(unittest.TestCase): """测试数据录制器""" @@ -169,17 +181,30 @@ class TestActionDataset(unittest.TestCase): def test_dataset_loading(self): with tempfile.TemporaryDirectory() as tmpdir: self._create_dummy_episode(Path(tmpdir), n_frames=10, action_dim=4) - ds = ActionDataset(tmpdir, history_len=3, action_dim=4, image_size=32) - self.assertEqual(len(ds), 7) # 10 - 3 = 7 samples + ds = ActionDataset(tmpdir, history_len=3, action_dim=4, image_size=32, + frame_len=3) + # 采样起点 max(history_len, frame_len-1) = 3 → 10 - 3 = 7 samples + self.assertEqual(len(ds), 7) sample = ds[0] - self.assertIn("image", sample) + self.assertIn("images", sample) self.assertIn("action_history", sample) self.assertIn("target", sample) - self.assertEqual(sample["image"].shape, (3, 32, 32)) + self.assertEqual(sample["images"].shape, (3, 3, 32, 32)) self.assertEqual(sample["action_history"].shape, (3, 4)) self.assertEqual(sample["target"].shape, (4,)) + def test_frame_len_gt_history_shifts_start(self): + """frame_len-1 > history_len 时采样起点由帧窗口决定""" + with tempfile.TemporaryDirectory() as tmpdir: + self._create_dummy_episode(Path(tmpdir), n_frames=10, action_dim=4) + ds = ActionDataset(tmpdir, history_len=3, action_dim=4, image_size=32, + frame_len=6) + # 起点 max(3, 5) = 5 → 10 - 5 = 5 samples + self.assertEqual(len(ds), 5) + sample = ds[0] + self.assertEqual(sample["images"].shape, (6, 3, 32, 32)) + def test_episode_count(self): with tempfile.TemporaryDirectory() as tmpdir: self._create_dummy_episode(Path(tmpdir)) @@ -241,17 +266,24 @@ class TestConfig(unittest.TestCase): self.assertEqual(cfg.data.image_size, 64) self.assertEqual(cfg.data.action_dim, 18) self.assertEqual(cfg.data.history_len, 5) + self.assertEqual(cfg.data.frame_len, 3) self.assertEqual(cfg.model.cnn_backbone, "simple") - self.assertEqual(cfg.model.history_encoder, "mlp") + self.assertEqual(cfg.model.attn_dim, 128) + self.assertEqual(cfg.model.attn_heads, 4) + self.assertEqual(cfg.model.attn_layers, 2) self.assertEqual(cfg.train.epochs, 100) def test_apply_overrides(self): parser = build_argparser("test") - args = parser.parse_args(["--epochs", "50", "--lr", "0.01", "--backbone", "mobilenet"]) + args = parser.parse_args(["--epochs", "50", "--lr", "0.01", + "--backbone", "mobilenet", + "--frame-len", "4", "--attn-dim", "64"]) cfg = apply_overrides(Config(), args) self.assertEqual(cfg.train.epochs, 50) self.assertEqual(cfg.train.lr, 0.01) self.assertEqual(cfg.model.cnn_backbone, "mobilenet") + self.assertEqual(cfg.data.frame_len, 4) + self.assertEqual(cfg.model.attn_dim, 64) def test_normalize_actions_flag(self): """--normalize-actions 和 --no-normalize-actions 都应可用""" @@ -421,18 +453,23 @@ class TestValidateConfigValues(unittest.TestCase): def test_out_of_range_or_non_int_raises(self): for field, value in [("image_size", 99999), ("image_size", 0), - ("history_len", -1), ("action_dim", "18")]: + ("history_len", -1), ("frame_len", 0), + ("frame_len", 99999), ("action_dim", "18")]: cfg = Config() setattr(cfg.data, field, value) with self.assertRaises(ValueError, msg=f"{field}={value}"): validate_config_values(cfg.data, cfg.model) def test_model_field_out_of_range_raises(self): - """model 字段越界(fused_hidden_dim 超限、dropout 越界)应抛 ValueError""" + """model 字段越界(fused_hidden_dim 超限、dropout 越界、attn_* 越界)应抛 ValueError""" for field, value in [("fused_hidden_dim", 10 ** 9), ("fused_hidden_dim", 0), ("cnn_feature_dim", 99999), - ("action_hidden_dim", -1), + ("attn_dim", 0), + ("attn_dim", 99999), + ("attn_heads", 0), + ("attn_heads", 128), + ("attn_layers", 0), ("dropout", 1.5), ("dropout", -0.1)]: cfg = Config() @@ -440,6 +477,14 @@ class TestValidateConfigValues(unittest.TestCase): with self.assertRaises(ValueError, msg=f"{field}={value}"): validate_config_values(cfg.data, cfg.model) + def test_attn_dim_not_divisible_by_heads_raises(self): + """attn_dim 不能被 attn_heads 整除应抛 ValueError""" + cfg = Config() + cfg.model.attn_dim = 130 + cfg.model.attn_heads = 4 + with self.assertRaisesRegex(ValueError, "整除"): + validate_config_values(cfg.data, cfg.model) + def test_train_field_out_of_range_raises(self): """train 字段越界(lr 为负、val_split>1 等)应抛 ValueError""" for field, value in [("lr", -1.0), ("lr", 0.0), @@ -474,6 +519,15 @@ class TestParseCheckpointConfig(unittest.TestCase): with self.assertRaisesRegex(ValueError, "evil_extra_key"): parse_checkpoint_config(d) + def test_legacy_checkpoint_config_rejected(self): + """旧架构 checkpoint(含 history_encoder/action_hidden_dim)应拒载""" + cfg = Config() + d = cfg.to_dict() + d["model"]["history_encoder"] = "mlp" + d["model"]["action_hidden_dim"] = 64 + with self.assertRaisesRegex(ValueError, "history_encoder"): + parse_checkpoint_config(d) + def test_missing_section_raises(self): cfg = Config() d = cfg.to_dict() @@ -519,11 +573,13 @@ class TestActionPredictorInference(unittest.TestCase): """测试推理封装端到端:checkpoint 保存 → 加载 → 推理""" @staticmethod - def _make_checkpoint(tmpdir, normalize_actions=False, image_size=32): + def _make_checkpoint(tmpdir, normalize_actions=False, image_size=32, + frame_len=3): cfg = Config() cfg.data.image_size = image_size cfg.data.action_dim = 4 cfg.data.history_len = 3 + cfg.data.frame_len = frame_len cfg.data.normalize_actions = normalize_actions model = ActionPredictor(cfg.model, cfg.data) model.eval() @@ -533,17 +589,19 @@ class TestActionPredictorInference(unittest.TestCase): return path, model, cfg def test_end_to_end_predict(self): - """加载 checkpoint 后推理输出形状正确、数值有限且可复现""" + """加载 checkpoint 后多帧推理输出形状正确、数值有限且可复现""" with tempfile.TemporaryDirectory() as tmpdir: path, _, _ = self._make_checkpoint(tmpdir) predictor = ActionPredictorInference(path, device="cpu") self.assertEqual(predictor.action_dim, 4) self.assertEqual(predictor.history_len, 3) + self.assertEqual(predictor.frame_len, 3) - image = np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8) + images = [np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8) + for _ in range(3)] history = np.random.randn(3, 4).astype(np.float32) - pred1 = predictor.predict(image, history) - pred2 = predictor.predict(image, history) + pred1 = predictor.predict(images, history) + pred2 = predictor.predict(images, history) self.assertEqual(pred1.shape, (4,)) self.assertTrue(np.all(np.isfinite(pred1))) np.testing.assert_array_equal(pred1, pred2) # eval 模式确定性 @@ -563,9 +621,18 @@ class TestActionPredictorInference(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: path, _, _ = self._make_checkpoint(tmpdir) predictor = ActionPredictorInference(path, device="cpu") - image = np.zeros((32, 32, 3), dtype=np.uint8) + images = [np.zeros((32, 32, 3), dtype=np.uint8)] * 3 with self.assertRaisesRegex(ValueError, "action_history"): - predictor.predict(image, np.zeros((2, 4), dtype=np.float32)) + predictor.predict(images, np.zeros((2, 4), dtype=np.float32)) + + def test_wrong_frame_count_raises(self): + """传入帧数与 frame_len 不一致应抛 ValueError""" + with tempfile.TemporaryDirectory() as tmpdir: + path, _, _ = self._make_checkpoint(tmpdir) + predictor = ActionPredictorInference(path, device="cpu") + images = [np.zeros((32, 32, 3), dtype=np.uint8)] * 2 + with self.assertRaisesRegex(ValueError, "帧"): + predictor.predict(images, np.zeros((3, 4), dtype=np.float32)) def test_missing_action_stats_raises(self): """启用归一化但缺 action_stats.npy 应抛 FileNotFoundError""" @@ -594,9 +661,9 @@ class TestActionPredictorInference(unittest.TestCase): image = np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8) history = np.random.randn(3, 4).astype(np.float32) pred_raw = ActionPredictorInference( - raw_path, device="cpu").predict(image, history) + raw_path, device="cpu").predict([image] * 3, history) pred_norm = ActionPredictorInference( - norm_path, device="cpu").predict(image, history) + norm_path, device="cpu").predict([image] * 3, history) np.testing.assert_allclose(pred_norm, pred_raw, rtol=1e-5, atol=1e-6) def test_stats_shape_mismatch_raises(self): diff --git a/actpred/train.py b/actpred/train.py index b8d61aa..50b8ffe 100644 --- a/actpred/train.py +++ b/actpred/train.py @@ -25,18 +25,18 @@ def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = Non total_loss = 0.0 count = 0 for batch in tqdm(loader, desc=" train", leave=False): - image = batch["image"].to(device) + images = batch["images"].to(device) history = batch["action_history"].to(device) target = batch["target"].to(device) optimizer.zero_grad() - pred = model(image, history) + pred = model(images, history) loss = torch.nn.functional.mse_loss(pred, target) loss.backward() if max_grad_norm: torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm) optimizer.step() - total_loss += loss.item() * image.size(0) - count += image.size(0) + total_loss += loss.item() * images.size(0) + count += images.size(0) return total_loss / max(count, 1) @@ -46,13 +46,13 @@ def validate(model, loader, device): total_loss = 0.0 count = 0 for batch in tqdm(loader, desc=" val ", leave=False): - image = batch["image"].to(device) + images = batch["images"].to(device) history = batch["action_history"].to(device) target = batch["target"].to(device) - pred = model(image, history) + pred = model(images, history) loss = torch.nn.functional.mse_loss(pred, target) - total_loss += loss.item() * image.size(0) - count += image.size(0) + total_loss += loss.item() * images.size(0) + count += images.size(0) if count == 0: return float("inf") # 空验证集不参与比较,避免假 best return total_loss / count @@ -83,7 +83,7 @@ def main(): train_ds = ActionDataset( cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim, cfg.data.image_size, cfg.data.normalize_actions, - cfg.data.action_stats_path, train_indices) + cfg.data.action_stats_path, train_indices, cfg.data.frame_len) if cfg.data.normalize_actions and not cfg.data.action_stats_path: # 归一化统计量必须只用训练集计算,再供验证集复用,否则 val loss 不可信 stats_path = os.path.join(cfg.train.checkpoint_dir, "action_stats.npy") @@ -93,7 +93,7 @@ def main(): val_ds = ActionDataset( cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim, cfg.data.image_size, cfg.data.normalize_actions, - cfg.data.action_stats_path, val_indices) + cfg.data.action_stats_path, val_indices, cfg.data.frame_len) print(f"Samples: {len(train_ds)} train, {len(val_ds)} val") if len(train_ds) == 0: diff --git a/docs/INDEX.md b/docs/INDEX.md deleted file mode 100644 index 723484e..0000000 --- a/docs/INDEX.md +++ /dev/null @@ -1,165 +0,0 @@ -# micro_servers 文档索引 - -所有项目文档的统一入口。 - -## 目录结构 - -``` -docs/ -├── INDEX.md # 本文件(文档总索引) -├── security/ # 安全审计与加固(29 篇) -├── reviews/ # 代码审查日志(4 篇) -└── reports/ # 分析报告与开发规划(14 篇) -``` - ---- - -## 一、项目总览 - -| 文档 | 路径 | 说明 | -|------|------|------| -| 项目 README | [../README.md](../README.md) | 架构总览、服务说明、安装运行指南 | - ---- - -## 二、安全文档 (`security/`) - -### 全局审计与加固 - -| 文档 | 说明 | -|------|------| -| [DEPLOY_SECURITY_AUDIT_20260801.md](security/DEPLOY_SECURITY_AUDIT_20260801.md) | 部署配置安全审计(docker-compose、systemd、防火墙、TLS 等) | -| [DEPS_SECURITY_AUDIT_20260801.md](security/DEPS_SECURITY_AUDIT_20260801.md) | 依赖安全审计(第三方库版本、已知漏洞、许可证) | -| [SECURITY_HARDENING_PLAN_20260803.md](security/SECURITY_HARDENING_PLAN_20260803.md) | 安全加固计划(内网通信安全加固开发计划) | - -### 各模块安全审计报告 - -| 模块 | 文档 | 审计范围 | -|------|------|----------| -| waf | [waf_SECURITY_AUDIT_20260801.md](security/waf_SECURITY_AUDIT_20260801.md) | Go WAF 网关(TLS、coraza、反向代理) | -| gateway | [gateway_SECURITY_AUDIT.md](security/gateway_SECURITY_AUDIT.md) | C++20 应用网关安全审计合并文档(v0.8.0 三轮审计合并:初版/复审/增量复审;v0.9.0 纯转发,认证迁 ca) | -| ca | [ca_SECURITY_AUDIT.md](security/ca_SECURITY_AUDIT.md) | C++20 中央认证服务(Central Auth,v1.0.0),承接原 gateway 认证职责;含迁移接收项与 ca 相关开放项 | -| core | [core_SECURITY_AUDIT_20260801.md](security/core_SECURITY_AUDIT_20260801.md) | C++20 进程托管服务(PMC/Nidus) | -| db | [db_SECURITY_AUDIT_20260801.md](security/db_SECURITY_AUDIT_20260801.md) | Python 数据库服务(pymsg、SQLAlchemy、日志收集) | -| db2 | [db2_SECURITY_AUDIT_20260806.md](security/db2_SECURITY_AUDIT_20260806.md) | C++20 数据库 RPC 服务(db 的 C++ 重实现,SQLite C API) | -| pymsg | [pymsg_SECURITY_AUDIT_20260801.md](security/pymsg_SECURITY_AUDIT_20260801.md) | Python RPC 基础库(长度前缀帧、MsgClient/Server) | -| dnfs | [dnfs_SECURITY_AUDIT_20260801.md](security/dnfs_SECURITY_AUDIT_20260801.md) | Python 树状网络文件系统(**已废弃,生产用 dnfs2**;2026-08-10 复核为全量 16 项发现) | -| dnfs2 | [dnfs2_SECURITY_AUDIT_20260808.md](security/dnfs2_SECURITY_AUDIT_20260808.md) | C++20 树状网络文件系统(dnfs 重实现,**生产在跑**,评级 A) | -| static | [static_SECURITY_AUDIT_20260801.md](security/static_SECURITY_AUDIT_20260801.md) | Python 前端托管/文件服务 | -| fsys | [fsys_SECURITY_AUDIT_20260808.md](security/fsys_SECURITY_AUDIT_20260808.md) | Python 文件系统服务 | -| ai_review | [ai_review_SECURITY_AUDIT_20260801.md](security/ai_review_SECURITY_AUDIT_20260801.md) | Python AI 内容审核(DeepSeek + RAG) | -| smtp | [smtp_SECURITY_AUDIT_20260801.md](security/smtp_SECURITY_AUDIT_20260801.md) | Python 邮件服务(验证码发送/校验)初版 + 0809 复审合并版 | -| smtp3 | [smtp3_SECURITY_AUDIT_20260809.md](security/smtp3_SECURITY_AUDIT_20260809.md) | C++20 邮件服务(smtp 重写),2 项 P0 + P1-2 + 全部 P3 当日已修复,仅剩 P1-1(TLS 校验)待修 | -| captcha | [captcha_SECURITY_AUDIT_20260801.md](security/captcha_SECURITY_AUDIT_20260801.md) | Python 验证码服务 | -| captcha2 | [captcha2_SECURITY_AUDIT_20260809.md](security/captcha2_SECURITY_AUDIT_20260809.md) | C++20 验证码服务(captcha 重写,FreeType + stb,含 P0 大小写缺陷发现) | -| confs | [confs_SECURITY_AUDIT_20260801.md](security/confs_SECURITY_AUDIT_20260801.md) | Python 统一配置服务 | -| libmsg | [libmsg_SECURITY_AUDIT_20260806.md](security/libmsg_SECURITY_AUDIT_20260806.md) | C++ RPC 基础库(pymsg 协议帧、MsgClient/Server、RpcClient/Server) | -| libhttp | [libhttp_SECURITY_AUDIT_20260803.md](security/libhttp_SECURITY_AUDIT_20260803.md) | C++ HTTP/WebSocket 库(请求/响应/WS/静态文件) | -| libsqlite3 | [libsqlite3_SECURITY_AUDIT_20260806.md](security/libsqlite3_SECURITY_AUDIT_20260806.md) | C++ SQLite3 封装(Header-only) | -| libth | [libth_SECURITY_AUDIT_20260804.md](security/libth_SECURITY_AUDIT_20260804.md) | C++ 线程库(Thread、Cv_wait、Fsm) | -| aibox | [aibox_SECURITY_AUDIT_20260801.md](security/aibox_SECURITY_AUDIT_20260801.md) | Python AI 智能体系统(WebSocket) | -| BotOps | [BotOps_SECURITY_AUDIT_20260801.md](security/BotOps_SECURITY_AUDIT_20260801.md) | BotOps 运维控制面板 | -| actpred | [actpred_SECURITY_AUDIT_20260808.md](security/actpred_SECURITY_AUDIT_20260808.md) | Python 动作预测模块(PyTorch 训练/推理、.mt 解析、ONNX 导出) | - ---- - -## 三、代码审查 (`reviews/`) - -| 文档 | 说明 | -|------|------| -| [core_CODE_REVIEW_LOG_20260730.md](reviews/core_CODE_REVIEW_LOG_20260730.md) | Core 模块代码审查日志(2026-07-30,自动化审查) | -| [DEPLOY_DEFECT_AUDIT_20260806.md](reviews/DEPLOY_DEFECT_AUDIT_20260806.md) | 部署缺陷审查(2026-08-06,首次原生部署实录,含修复记录) | -| [actpred_CODE_AUDIT_20260808.md](reviews/actpred_CODE_AUDIT_20260808.md) | actpred 模块代码审计(2026-08-08,两轮合并:第 1 轮 16 项 + 第 2 轮复审 11 项,共 27 项全部修复) | -| [bot4cj_CODE_AUDIT_20260808.md](reviews/bot4cj_CODE_AUDIT_20260808.md) | bot4cj 模块代码审计(2026-08-08,仓颉机器人硬件脚本解析器,15 项发现,待修复) | - ---- - -## 四、分析报告与开发规划 (`reports/`) - -| 文档 | 说明 | -|------|------| -| [blackbird_research_report.md](reports/blackbird_research_report.md) | blackbird 项目研究报告(安全架构分析、改进建议、里程碑规划) | -| [core_service_registry_dev_doc.md](reports/core_service_registry_dev_doc.md) | Core 模块注册中心功能扩展开发文档(服务注册/发现/健康守护/自动重启) | -| [auth_service_plan.md](reports/auth_service_plan.md) | 认证独立化(CA 模块)开发文档(waf→ca→gateway 前置拓扑;gateway 退 8445 纯转发不验签;下游/WAF/前端零改动) | -| [aibox_open_dev_plan.md](reports/aibox_open_dev_plan.md) | aibox 开放版开发计划(命令注入漏洞修复、安全沙箱方案) | -| [aibox_file_network_cli_refactor.md](reports/aibox_file_network_cli_refactor.md) | aibox 文件/网络操作命令行化重构计划 | -| [blackbird_promo_full.md](reports/blackbird_promo_full.md) | blackbird 项目完整版宣传文案 | -| [bot4cj_analysis_20260525_00.md](reports/bot4cj_analysis_20260525_00.md) | bot4cj 机器人模块分析报告(Markdown) | -| [libcrypt_CBC_deprecation_dev_doc.md](reports/libcrypt_CBC_deprecation_dev_doc.md) | libcrypt CBC 模式缺陷分析与 AES-GCM 迁移开发文档 | -| [bot4cj_analysis_20260525_00.html](reports/bot4cj_analysis_20260525_00.html) | bot4cj 机器人模块分析报告(HTML) | -| [actpred_pretrain_test_20260808.md](reports/actpred_pretrain_test_20260808.md) | actpred 预训练与推理测试报告(action_dim=18 变更、.mt 解析器、预训练、推理基准、NPU 调研) | -| [captcha_cpp_rewrite_dev_doc.md](reports/captcha_cpp_rewrite_dev_doc.md) | captcha 模块 C++20 重写开发方案(FreeType + stb_image_write 替代 Pillow,协议兼容零改动) | -| [smtp_cpp_rewrite_dev_doc.md](reports/smtp_cpp_rewrite_dev_doc.md) | smtp 模块 C++20 重写开发方案(SMTP_SSL 客户端手写,审计 7 项遗留一并在重写中修复,零新增系统依赖) | -| [libhttp_remote_addr_dev_doc.md](reports/libhttp_remote_addr_dev_doc.md) | libhttp 对端地址透传开发方案(X-Remote-Addr 可信头注入,非破坏;gateway trust_xff 配套,闭环审计 A4) | -| [confs2_cpp_rewrite_dev_doc.md](reports/confs2_cpp_rewrite_dev_doc.md) | confs 模块 C++20 重写开发方案(libmsg RpcServer 替代 pymsg,协议兼容零改动,审计 7 项遗留一并修复,零新增系统依赖,未来可合入 core 注册中心) | -| [fsys_public_frontend_research_20260810.md](reports/fsys_public_frontend_research_20260810.md) | fsys/public 前端研究报告(Vanilla 技术栈、HttpOnly Cookie 认证体系、dnfs/aibox/view/mgmt 页面分析、8 项问题与改进建议) | - ---- - -## 五、各模块 README(保留在原目录) - -各模块根目录的 `README.md` 保留在原位,方便 GitHub 直接浏览: - -### 核心服务 - -| 模块 | 路径 | 说明 | -|------|------|------| -| core | [../core/README.md](../core/README.md) | 进程托管服务(PMC/Nidus) | -| waf | [../waf/README.md](../waf/README.md) | WAF 网关(Go, coraza) | -| gateway | [../gateway/README.md](../gateway/README.md) | 应用层网关(C++20) | -| db | [../db/README.md](../db/README.md) | 通用数据库服务(pymsg) | -| db2 | [../db2/README.md](../db2/README.md) | C++20 数据库 RPC 服务(db 重实现) | -| confs | [../confs/README.md](../confs/README.md) | 统一配置服务 | -| static | [../static/README.md](../static/README.md) | 前端托管服务 | -| dnfs | [../dnfs/README.md](../dnfs/README.md) | 树状网络文件系统 | -| dnfs2 | [../dnfs2/README.md](../dnfs2/README.md) | C++20 树状菜单服务(dnfs 重实现) | -| ai_review | [../ai_review/README.md](../ai_review/README.md) | AI 内容审核 | -| captcha | [../captcha/README.md](../captcha/README.md) | 验证码服务 | -| smtp | [../smtp/README.md](../smtp/README.md) | 邮件服务 | -| redirect80 | [../redirect80/README.md](../redirect80/README.md) | HTTP 重定向服务 | - -### 共享库 - -| 库 | 路径 | 说明 | -|----|------|------| -| pymsg | [../pymsg/README.md](../pymsg/README.md) | Python RPC 基础库 | -| libhttp | [../libhttp/README.md](../libhttp/README.md) | C++ HTTP/WebSocket 库 | -| libcrypt | [../libcrypt/README.md](../libcrypt/README.md) | C++ 加密库 | -| libth | [../libth/README.md](../libth/README.md) | C++ 线程库 | -| libsqlite3 | [../libsqlite3/README.md](../libsqlite3/README.md) | C++ SQLite3 封装 | -| libmsg | [../libmsg/README.md](../libmsg/README.md) | C++ RPC 基础库(pymsg 协议 C++ 实现) | -| libtry | [../libtry/](../libtry/) | 嵌入式脚本解释器 | - -### 其他模块 - -| 模块 | 路径 | 说明 | -|------|------|------| -| aibox | [../aibox/README.md](../aibox/README.md) | AI 智能体系统 | -| bot4cj | [../bot4cj/README.md](../bot4cj/README.md) | 机器人硬件脚本解析器(仓颉) | -| BotOps | [../BotOps/README.md](../BotOps/README.md) | aibox 智能体 TUI 前端(Textual) | -| actpred | [../actpred/README.md](../actpred/README.md) | 动作预测神经网络(PyTorch) | -| lci | [../lci/README.md](../lci/README.md) | 嵌入式帧缓冲图形界面 | -| fsys | [../fsys/README.md](../fsys/README.md) | 文件系统服务 | -| tests | [../tests/blackbox/README.md](../tests/blackbox/README.md) | 黑盒测试套件 | - -### Robot 文档 (README.robot.md) - -| 模块 | 路径 | -|------|------| -| pymsg | [../pymsg/README.robot.md](../pymsg/README.robot.md) | -| libhttp | [../libhttp/README.robot.md](../libhttp/README.robot.md) | -| libtry | [../libtry/README.robot.md](../libtry/README.robot.md) | -| aibox | [../aibox/README.robot.md](../aibox/README.robot.md) | -| bot4cj | [../bot4cj/README.robot.md](../bot4cj/README.robot.md) | - ---- - -## 六、aibox 技能与任务文档 - -aibox 模块的技能提示词和任务模板属于运行时数据,保留在原目录: - -| 类型 | 路径 | -|------|------| -| 技能格式说明 | [../aibox/SKILLS_FORMAT.md](../aibox/SKILLS_FORMAT.md) | -| 技能目录 | [../aibox/skills/](../aibox/skills/) | -| 任务目录 | [../aibox/tasks/](../aibox/tasks/) | diff --git a/docs/reports/actpred_attention_rewrite_test_20260809.md b/docs/reports/actpred_attention_rewrite_test_20260809.md new file mode 100644 index 0000000..f428cb8 --- /dev/null +++ b/docs/reports/actpred_attention_rewrite_test_20260809.md @@ -0,0 +1,212 @@ +# actpred 多帧注意力架构改造测试报告 + +| 项目 | 值 | +|------|-----| +| 日期 | 2026-08-09 | +| 模块 | actpred | +| 测试人 | qing | +| 环境 | Intel Xeon E3-1200 v2 (Ivy Bridge), 4 核, Linux 24.04, Python 3.x, PyTorch (CPU-only) | +| 关联文件 | `config.py`, `model.py`, `dataset.py`, `train.py`, `pretrain_mt.py`, `predict.py`, `export_onnx.py`, `test_actpred.py` | +| 前置报告 | [actpred_pretrain_test_20260808.md](actpred_pretrain_test_20260808.md) | + +--- + +## 一、改造背景与目标 + +原架构为"单帧 CNN + 历史动作 MLP/GRU + concat 融合",仅支持单帧图像输入。 +本次改造目标: + +1. 引入注意力机制,支持**多帧图像 + 历史动作序列**联合建模,预测下一时间步动作 +2. 保持 checkpoint 格式(`model_state_dict` + `config`)、CLI 工作流与 ONNX 导出链路不变 +3. 保持除 batch 维外全静态 shape,面向树莓派 AI 扩展板(NPU)部署友好 +4. 直接替换旧架构(用户决策):删除 MLP/GRU 编码器与 concat 路径,旧 checkpoint 作废 + +## 二、新架构 + +``` +F 帧图像 (F×3×64×64) ─► 共享 CNN 编码器 ─► F 个 token ─► proj → d_model ┐ + ├─► [img×F, act×H, PRED] + 位置编码 +动作历史 (H×D) ─► Linear proj → d_model ─► H 个 token ──────────┤ │ +可学习 [PRED] token ─────────────────────────────────────────────────┘ ▼ + TransformerEncoder (pre-LN, 2 层, 4 头, d_model=128) + │ + 取 PRED 位置输出 ─► MLP 头 ─► 预测动作 (action_dim) +``` + +| 设计点 | 取值/说明 | +|--------|----------| +| token 序列 | `[img×F, act×H, PRED]`,长度固定 F+H+1(默认 3+5+1=9) | +| 帧序约定 | 旧→新,末帧为当前帧;冷启动可重复传同一帧 | +| 注意力 | `nn.TransformerEncoder`,pre-LN(norm_first),dim_feedforward=2×d_model | +| 位置编码 | 可学习 `pos_embed (1, F+H+1, d)`,trunc_normal 初始化 | +| 输出 | 取末位 PRED token 过 MLP 头(d → 128 → 64 → action_dim) | +| 静态性 | forward 中 reshape 仅 batch 维动态,其余编译期固定 | +| 参数量 | 409,042(旧架构 142,034,约 2.9 倍) | + +### 新增配置参数 + +| 参数 | 默认值 | 校验范围 | +|------|--------|---------| +| `--frame-len` | 3 | [1, 1024] | +| `--attn-dim` | 128 | [1, 4096],且须能被 attn-heads 整除 | +| `--attn-heads` | 4 | [1, 64] | +| `--attn-layers` | 2 | [1, 64] | + +删除参数:`--history-encoder`、`--action-hidden-dim`。 +旧 checkpoint 因 config 含未知键(`history_encoder`/`action_hidden_dim`)在加载时被 +`parse_checkpoint_config` 以明确 ValueError 拒载,符合"作废"预期(有专项测试覆盖)。 + +## 三、单元测试 + +``` +$ python test_actpred.py +Ran 56 tests in 5.959s +OK +``` + +**56 项测试全部通过**(安装 onnxscript/onnxruntime 后无跳过项)。本次新增/改写用例: + +| 用例 | 验证点 | +|------|--------| +| `test_forward_multi_frame` | (2,3,3,64,64) + (2,5,18) → (2,18) | +| `test_forward_single_frame_degenerate` | frame_len=1 退化单帧仍正常前向 | +| `test_forward_dynamic_batch` | batch 1/4/8 动态(frame_len=2) | +| `test_custom_attn_config` | attn_dim=64/heads=2/layers=1 可构建前向 | +| `test_attn_dim_not_divisible_by_heads_raises` | 130 % 4 ≠ 0 → ValueError | +| `test_dataset_loading` | 多帧样本 shape (3,3,32,32),batch key 改为 `images` | +| `test_frame_len_gt_history_shifts_start` | 采样起点 max(history_len, frame_len-1) 正确 | +| `test_legacy_checkpoint_config_rejected` | 旧架构 checkpoint 拒载 | +| `test_wrong_frame_count_raises` | 推理帧数不符 → ValueError | +| `test_end_to_end_predict` 等推理用例 | 多帧输入端到端、归一化等价性、确定性 | + +## 四、预训练测试 + +### 4.1 训练配置 + +| 参数 | 值 | +|------|-----| +| 数据来源 | `../bot4cj/scripts/*.mt`(13 文件) | +| train/val 划分 | **文件级**(11 训练文件 / 2 验证文件,杜绝滑动窗口泄漏) | +| 输入 | 全零多帧图像 (B, 3, 3, 64, 64) + action_history (B, 5, 18) | +| 优化器/调度 | AdamW (lr=0.001, wd=1e-4) + CosineAnnealingLR | +| 数据增强 | Gaussian noise (std=1.0) | +| batch_size / epochs / patience | 8 / 200 / 30 | +| 随机种子 | 42 | + +### 4.2 训练结果 + +| 指标 | 值 | +|------|-----| +| 最终轮数 | 65(早停,patience=30) | +| 最优 val_loss | **2476.75** | +| 产出 | `checkpoints/pretrain_best.pt`(含 optimizer)、`checkpoints/pretrain_best_inference.pt` | + +> **关于 val_loss 数值的说明**:本次 2476.75 与前次报告的 190.58 不可直接对比—— +> 前次数值系样本级滑动窗口划分导致的数据泄漏虚低值(已在 0808 审计中定性); +> 本次为文件级划分(验证集是训练中完全未见过的动作脚本),且模型更大(409K vs 142K) +> 而数据仅 13 个脚本,属"诚实的困难任务"数值。注意力架构的真实收益需在真实图像数据 +> 微调后评估。 + +## 五、推理链路验证 + +### 5.1 CLI 全链路 + +``` +$ bash _run.sh --model checkpoints/pretrain_best_inference.pt \ + --images /tmp/f0.jpg /tmp/f1.jpg /tmp/f2.jpg \ + --action-history "45,0,...(5 步 × 18 值)" +18.3430 10.1614 17.3654 ... (18 维输出,数值有限) +``` + +✅ 3 帧 + 5 步历史全链路输出正常。 + +### 5.2 错误场景 + +``` +$ ... --images /tmp/f0.jpg ... +--images 需传入 3 个路径(旧→新),实际为 1 个。冷启动时可重复传同一帧路径。 +``` + +✅ 帧数不符时报错信息明确,含冷启动指引。 + +## 六、ONNX 导出验证 + +``` +$ python export_onnx.py --model checkpoints/pretrain_best_inference.pt --output /tmp/actpred_attn.onnx +ONNX model exported to: /tmp/actpred_attn.onnx +ONNX 一致性验证通过(最大差异: 7.629395e-06) +``` + +| 项目 | 结果 | +|------|------| +| 导出 | ✅ 成功(opset 17,输入 `images (batch,3,3,64,64)` + `action_history (batch,5,18)`) | +| onnxruntime 一致性 | ✅ 最大差异 7.63e-06(< 1e-4 阈值) | +| 动态维 | 仅 batch,NPU 编译友好 | + +注:本次为环境补装了 `onnxscript` 与 `onnxruntime`(torch ≥ 2.12 ONNX 导出依赖)。 + +## 七、CPU 推理性能基准 + +测试方法:warmup 20 次后测 200 次取平均,输入随机 3 帧 (64,64,3) uint8 + (5,18) float32。 + +| 环节 | 本次(注意力,3 帧) | 前次(concat,单帧) | +|------|---------------------|---------------------| +| 参数量 | 409,042 | 142,034 | +| 端到端推理(含预处理) | **4.25 ms (235 FPS)** | 1.61 ms (622 FPS) | +| 纯前向 | 2.77 ms (360 FPS) | 1.09 ms (916 FPS) | +| image_encoder 占比 | 1.30 ms(47%,3 帧) | 0.82 ms(75%,1 帧) | + +评估: + +- 235 FPS 对 bot4cj 30 FPS 控制循环仍有 **7.8 倍余量**,实时性满足 +- 延迟增长主因:CNN 由 1 帧变 3 帧(约 3×0.43ms)+ Transformer 编码器(约 1.5ms) +- image_encoder 占比由 75% 降至 47%,注意力部分成为新的次要热点; + 部署到树莓派 AI 扩展板后两者均可被 NPU 加速(静态 shape 已保证) + +## 八、结论与下一步 + +### 8.1 测试结论 + +| 测试项 | 结果 | +|--------|------| +| 多帧注意力模型前向 | ✅ 通过(多帧/单帧退化/动态 batch/自定义 attn 配置) | +| 配置校验 | ✅ 通过(frame_len/attn_* 值域 + 整除校验 + 旧 checkpoint 拒载) | +| 多帧数据集采样 | ✅ 通过(采样起点与样本数断言) | +| 单元测试 | ✅ 56/56 通过 | +| 预训练 | ✅ 通过(65 epoch 早停,文件级划分无泄漏) | +| 推理 CLI | ✅ 通过(正常 + 错误场景) | +| ONNX 导出 | ✅ 通过(一致性 7.63e-06) | +| CPU 性能 | ✅ 通过(235 FPS,7.8 倍实时余量) | + +### 8.2 已知局限 + +1. **预训练图像分支未习得**:全零多帧占位下 image_encoder 与图像 token 的注意力权重 + 无有效梯度信号,真实收益依赖后续真实图像微调 +2. **数据量极少**:13 个 .mt 脚本对 409K 参数模型明显不足,val_loss 绝对值偏高属预期 +3. **旧 checkpoint 全部作废**:已删除并重训预训练 checkpoint;如有外部持有旧 + `best_inference.pt` 需同步告知重训 + +### 8.3 下一步建议 + +1. **采集真实多帧数据**:用 `DataRecorder` 录制 bot4cj 摄像头 episode,以本次预训练 + checkpoint 为起点微调(勿开 `--normalize-actions`) +2. **树莓派 AI 扩展板部署验证**:将 ONNX 模型走扩展板厂商工具链编译,实测 NPU 延迟 +3. **帧数消融**:真实数据到位后对比 frame_len=1/3/5 的验证误差,确认多帧收益 +4. **扩充 .mt 脚本**:增加动作脚本数量,缓解预训练数据不足 + +--- + +## 附录:文件变更清单 + +| 文件 | 类型 | 说明 | +|------|------|------| +| `actpred/config.py` | 修改 | 新增 frame_len/attn_*;删除 history_encoder/action_hidden_dim;整除校验 | +| `actpred/model.py` | 重写 | 多帧 Transformer 注意力架构;删除 MLP/GRU 编码器与 concat 融合 | +| `actpred/dataset.py` | 修改 | 多帧窗口采样;batch key `image` → `images` | +| `actpred/train.py` | 修改 | 多帧适配(frame_len 透传、batch key) | +| `actpred/pretrain_mt.py` | 修改 | 全零多帧占位;打印信息更新 | +| `actpred/predict.py` | 修改 | `predict(images, history)` 多帧 API;CLI `--image` → `--images` | +| `actpred/export_onnx.py` | 修改 | 多帧 dummy 输入;input_names 更新 | +| `actpred/test_actpred.py` | 修改 | 56 项用例(新增多帧/校验/拒载/帧数错误等) | +| `actpred/README.md` | 修改 | 架构图、CLI 示例、配置表、API 文档同步 | +| `actpred/checkpoints/pretrain_best*.pt` | 重新生成 | 旧架构 checkpoint 已删除并重训 | diff --git a/docs/reports/actpred_effectiveness_evaluation_20260809.md b/docs/reports/actpred_effectiveness_evaluation_20260809.md new file mode 100644 index 0000000..f2d04ae --- /dev/null +++ b/docs/reports/actpred_effectiveness_evaluation_20260809.md @@ -0,0 +1,265 @@ +# actpred 模块效果评估报告 + +> 日期:2026-08-09 +> 评估范围:actpred 模块架构、代码质量、预训练模型实际预测能力、部署就绪度 +> 评估方法:源码审阅 + 单元测试 + 预训练 checkpoint 全量滑动窗口定量评估 + +--- + +## 一、模块定位 + +actpred 是 micro_servers 的独立/边缘模块,纯离线 CLI 工具,不参与微服务调用链。 +核心功能:输入多帧摄像头图像 + 历史动作序列 → 预测下一时间步的连续动作(回归)。 +面向树莓派 Dashun 蜘蛛机器人(配合 bot4cj 模块),目标部署场景为嵌入式机器人实时控制。 + +--- + +## 二、架构评估 + +### 2.1 模型架构 + +多帧 Transformer 注意力融合(2026-08 重写): + +``` +F 帧图像 (F×3×64×64) → 共享 CNN 编码器 → F 个 token → proj → d_model ┐ + ├→ [img×F, act×H, PRED] + 位置编码 +动作历史 (H×D) → Linear proj → d_model → H 个 token ─┤ │ +可学习 [PRED] token ─────────────────────────────────────────┘ ▼ + TransformerEncoder (pre-LN, 2层, 4头, d=128) + │ + 取 PRED 位置输出 → MLP 头 → 预测动作 +``` + +### 2.2 关键指标 + +| 指标 | 数值 | +|------|------| +| 参数量 | ~450K | +| CPU 端到端推理 | 4.25 ms(235 FPS) | +| CPU 纯 forward | 2.77 ms | +| image_encoder 占比 | 47%(pure forward) | +| ONNX 导出 | opset 17,仅 batch 维动态 | +| ONNX 一致性 | max diff ~1.5e-8 | +| 单元测试 | 56 个,全部通过 | + +### 2.3 架构评价 + +| 维度 | 评级 | 说明 | +|------|------|------| +| 模型设计 | A | 多帧注意力融合合理,token 序列固定长度 F+H+1,NPU 友好 | +| 推理速度 | A | 4.25ms 端到端,满足实时控制需求(>100 FPS) | +| ONNX 部署 | A- | 静态 shape 导出正确,版本转换有轻微告警不影响功能 | +| 安全性 | A | 零网络攻击面 + weights_only + 完整配置校验链 | + +--- + +## 三、代码质量评估 + +### 3.1 代码规模 + +| 文件 | 行数 | 职责 | +|------|------|------| +| config.py | 205 | 三层 dataclass 配置 + CLI + 校验 | +| model.py | 130 | SimpleCNN / MobileNetV2 / Transformer 模型 | +| dataset.py | 151 | 数据集加载 + DataRecorder | +| train.py | 187 | 训练入口(episode 级划分) | +| pretrain_mt.py | 289 | .mt 预训练入口(文件级划分) | +| predict.py | 138 | 推理封装 + CLI | +| export_onnx.py | 58 | ONNX 导出 + 一致性验证 | +| mt_parser.py | 133 | .mt @Action 矩阵解析器 | +| test_actpred.py | 722 | 单元测试(56 cases) | +| **合计** | **2013** | | + +### 3.2 优点 + +1. **安全性设计扎实** + - `torch.load(..., weights_only=True)` 全局使用 + - `parse_checkpoint_config` 实现键集合 + 值域 + 整除性三重校验 + - 旧架构 checkpoint 被未知键检查拒载 + +2. **数据泄漏已修复** + - pretrain_mt.py 采用文件级 train/val 划分(非样本级滑动窗口) + - train.py 采用 episode 级划分,归一化统计量仅用训练集计算 + +3. **工程化成熟** + - Config dataclass 三层结构 + CLI override + checkpoint roundtrip + - DataRecorder 原子 mkdir 防并发竞态 + - mt_parser `-` 前向填充取 @Servo 初始角度 + +4. **测试覆盖全面** + - 56 单元测试,覆盖前向传播、数据加载、配置校验、mt 解析、推理端到端、ONNX 导出 + - 边界条件充分(空数据集、维度不匹配、帧数不一致、未知键、旧 checkpoint 拒载等) + +### 3.3 可改进项 + +1. image_encoder 仍为推理瓶颈(占 pure forward 的 47%),后续可 INT8 量化 +2. 训练仅用 CosineAnnealingLR,未配置 warmup +3. ONNX 导出 opset 17 版本转换有告警(`axes_input_to_attribute` 断言失败后 fallback),不影响正确性但值得跟进 +4. `requirements.txt` 版本范围宽松(`torch>=2.0,<3.0`),生产环境建议锁定 + +--- + +## 四、预训练模型定量评估 + +### 4.1 训练数据概况 + +``` +.mt 脚本文件数: 13 +总动作行数: 77 +训练数据列数: 12(补零到 18) +文件级划分后: ~10 文件训练 / ~3 文件验证 +训练样本数: ~50(滑动窗口) +模型参数量: 450K +样本/参数比: 1:9000(严重欠定) +``` + +各 .mt 文件数据量: + +| 文件 | 行数 | 动作范围 | +|------|------|----------| +| jump.mt | 5 | [-180, 200] | +| left-hand.mt | 4 | [-180, 1000] | +| left-turn.mt | 11 | [-180, 200] | +| lie-down.mt | 6 | [-180, 200] | +| rght-hand.mt | 4 | [-180, 1000] | +| std.mt | 1 | [-180, 200] | +| stdd.mt | 7 | [-180, 200] | +| t0.mt | 10 | [-180, 500] | +| t1.mt | 1 | [10, 20] | +| tilt-b/f/l/r.mt | 各 7 | [-180, 200] | + +### 4.2 全量滑动窗口评估 + +使用预训练 checkpoint `pretrain_best_inference.pt`,对所有 .mt 文件做 history_len=5 滑动窗口预测: + +| 脚本 | 预测数 | 平均 L2 误差 | +|------|--------|-------------| +| left-turn.mt | 6 | 117.9 | +| lie-down.mt | 1 | 107.8 | +| stdd.mt | 2 | 138.7 | +| t0.mt | 5 | 239.1 | +| tilt-b.mt | 2 | 193.9 | +| tilt-f.mt | 2 | 201.4 | +| tilt-l.mt | 2 | 210.9 | +| tilt-r.mt | 2 | 183.4 | + +### 4.3 与 Baseline 对比 + +| 方法 | Mean L2 Error | 相对比率 | +|------|---------------|----------| +| **模型预测** | **175.8** | **0.76x** | +| Naive(复制上一步) | 230.0 | 1.0x(基准) | +| 全局均值预测器 | 251.2 | 1.09x | + +模型比 "复制上一步" 好 24%,比 "全局均值" 好 30%。但评估集仅 22 个预测点,统计意义有限。 + +### 4.4 预测分布分析 + +| 指标 | 预测 | 实际 | +|------|------|------| +| 数值范围 | [-164.5, 176.3] | [-180.0, 500.0] | +| 各维 std(舵机 0-9) | 12~57 | 0~95 | +| 各维 std(扬声器/延时 10-11) | 4~58 | 4~139 | +| 各维 std(预留 12-17) | 1~6 | 0 | + +**核心问题**:预测严重趋于均值(regression to the mean),预测 std 仅为实际 std 的 ~50%。极端值(如扬声器频率 1000Hz、延时 200ms)完全无法捕获。 + +### 4.5 典型预测案例 + +left-turn.mt 滑动窗口预测(前 6 维): + +``` +t=5: pred=[18.0, 10.3, 17.1, -1.3, 16.5, 11.2] actual=[45, 0, 45, 45, 45, 0] L2=129.4 +t=6: pred=[16.2, 8.5, 15.5, -0.4, 15.1, 8.9] actual=[45, 0, 0, 45, 45, 0] L2=127.3 +t=7: pred=[15.7, 8.0, 15.0, -0.3, 14.7, 8.3] actual=[45, 0, 0, 0, 45, 0] L2=109.1 +``` + +预测值集中在 8~18 附近,而实际动作在 0~45 之间跳变,模型未能跟踪动作变化趋势。 + +--- + +## 五、分场景效果预测 + +### 场景 A:当前预训练模型直接部署 + +**评级:不可用** + +- image_encoder 在零图像上训练,真实摄像头输入产生随机特征 +- 预测值被压缩到均值附近 ±15°,无法执行跳变动作 +- 扬声器频率预测几乎为 0 +- 预期表现:蜘蛛机器人缓慢无规律蠕动,无法完成有意义动作 + +### 场景 B:初步采集真实数据微调(~1K 帧,1-2 episodes) + +**评级:有限可用** + +- 450K 参数在 1K 帧上可学到基本运动模式 +- 平滑动作(站立→缓走)MAE ~15-25°/关节 +- 急变动作(跳跃、急转)MAE ~40-60°/关节 +- image_encoder 开始学习真实视觉特征 + +### 场景 C:充分数据采集(~10K 帧,10+ episodes,多场景) + +**评级:基本可用** + +- 平滑动作 MAE ~5-15°/关节 +- 急变动作 MAE ~20-40°/关节 +- 配合 noise_std 数据增强和早停,泛化可接受 +- SimpleCNN 表达力上限开始制约视觉理解 + +### 场景 D:场景 C + MobileNetV2 + 归一化动作 + +**评级:较好** + +- MobileNetV2 ImageNet 预训练权重提供强视觉先验 +- 归一化动作空间让 MSE loss 对各维度公平 +- 平滑动作 MAE ~3-8°/关节 + +### 场景 E:理想状态(~100K 帧,充分多样性) + +**评级:生产可用** + +- MAE ~1-5°/关节 +- 可完成绝大部分预设动作 + +--- + +## 六、核心瓶颈排序 + +| 优先级 | 瓶颈 | 严重度 | 说明 | +|--------|------|--------|------| +| 1 | 数据量 | 致命 | 77 行 / 13 文件,450K 参数 vs ~50 样本,样本/参数比 1:9000 | +| 2 | 零图像预训练的 image_encoder | 严重 | 47% 推理算力浪费在未训练的随机 CNN 上 | +| 3 | 动作空间异质性 | 中等 | dim 0-9 角度 [0-200°]、dim 10-11 频率/延时 [0-1000]、dim 12-17 全零,未归一化导致 MSE 被大数值维度主导 | +| 4 | 编舞脚本 vs 反应式行为 | 中等 | .mt 是预设动作编排,不含视觉反馈逻辑,模型学到的是 "动作惯性" 而非 "视觉→动作" 映射 | +| 5 | SimpleCNN 表达力 | 轻微 | 4 层 Conv 对细粒度空间信息编码不足 | + +--- + +## 七、量化预测总结 + +| 阶段 | 数据量 | 可用度 | 舵机 MAE | 能否完成预设动作 | +|------|--------|--------|----------|-----------------| +| 当前预训练 | 77 行 / 零图像 | 不可用 | >50° | 不能 | +| 初步采集 | ~1K 帧 | 有限 | 15-40° | 部分(简单动作) | +| 充分采集 | ~10K 帧 | 基本可用 | 5-20° | 大部分 | +| 充分 + MobileNet | ~10K 帧 | 较好 | 3-10° | 大部分 | +| 理想状态 | ~100K 帧 | 生产可用 | 1-5° | 绝大部分 | + +--- + +## 八、建议优先级 + +1. **最高优先**:实现遥操作/示教采集,尽快积累真实 (图像, 动作) 配对数据 +2. **高优先**:采集前开启 `--normalize-actions`,平衡各维度 loss 权重 +3. **中优先**:用真实数据微调后再评估,决定是否换 backbone +4. **低优先**:考虑预训练时冻结 image_encoder(减少无效梯度),或换恒等映射替代零图像 + +--- + +## 九、结论 + +actpred 模块架构设计合理,代码质量高,安全防护到位,推理速度满足实时需求。 +当前最大短板是 **数据量严重不足**(77 行零图像预训练),导致模型预测趋于均值、直接部署不可用。 +采集 10K 帧以上真实数据微调后,预计可达实用水平。 +模块已具备从 "实验原型" 向 "生产部署" 过渡的架构基础,下一步关键路径是 **数据采集 → 微调验证 → NPU 部署测试**。 diff --git a/docs/reports/bot4cj_python_migration_dev_doc.md b/docs/reports/bot4cj_python_migration_dev_doc.md new file mode 100644 index 0000000..39cbefb --- /dev/null +++ b/docs/reports/bot4cj_python_migration_dev_doc.md @@ -0,0 +1,400 @@ +# bot4cj 模块 Python 迁移开发方案 + +> 文档类型:功能开发计划 +> 模块:bot4cj → pybot(Python 迁移) +> 作者:自动生成 +> 日期:2026-08-09 + +--- + +## 1. 概述 + +### 1.1 背景与动机 + +`bot4cj` 是基于仓颉(Cangjie)编程语言和 Linux I2C 接口的机器人硬件脚本解析器,通过 `.mt` 矩阵脚本描述机器人硬件构成与动作序列,驱动 PCA9685 舵机控制板、MPU6050 传感器等真实硬件(宿主机为树莓派 aarch64)。 + +将其迁移到 Python 的动机: + +| 收益 | 说明 | +|------|------| +| 工具链门槛消除 | 仓颉编译器(cjc 1.1.3)+ cjpm + 4 个仓颉扩展包(ctype4cj/pthread4cj/spi4cj/lua4cj)→ 树莓派系统自带 Python3,零编译 | +| 与 actpred 生态打通 | actpred(动作预测,PyTorch)已有 Python 版 `.mt` 解析器(`mt_parser.py`),迁移后"大脑(actpred)↔ 身体(bot4cj)"共享同一语言与数据结构,DRL 闭环开发成本大幅降低 | +| Lua 扩展层可消除 | 仓颉版引入 Lua 是为了运行时可扩展硬件驱动;Python 本身即脚本语言,硬件驱动直接写成 Python 插件模块,砍掉 lua4cj + liblua5.4 + C 函数注册整层 | +| C/C++ 共享库大部分可消除 | libi2c4cj.so 的功能可用 `fcntl.ioctl` + `os.read/write` 纯 Python 实现;矩阵/FFT/CNN 由 numpy/PyTorch(actpred 已有)承担 | +| 审计遗留一并修复 | [bot4cj 代码审计](../reviews/bot4cj_CODE_AUDIT_20260808.md) 15 项发现(3 P0 + 5 P1 + 7 P2)全部待修复,迁移时逐项闭环 | +| 与项目主流技术栈对齐 | micro_servers 业务服务以 Python/C++20 为主,仓颉是唯一的孤立技术栈,维护人手受限 | + +**性能评估**:bot4cj 的执行节奏由硬件决定(舵机 50Hz PWM、I2C 100kHz~400kHz、动作行间 20~500ms 延时),解析与调度不是瓶颈,Python 的性能完全够用;且 Python 插件替代 Lua 桥接后,单次硬件调用的框架开销反而下降约两个数量级(详见附录 A 的量化分析)。这与 db2/captcha2 等"Python→C++ 求性能"的方向相反,是"仓颉→Python 求生态与可维护性"。 + +### 1.2 设计目标 + +1. **`.mt` 脚本零改动**:现有 `scripts/*.mt` 全部原样可用,块语法(`@Alias`/`@I2C`/`@PCA9685`/`@MPU6050_BIND`/`@Speaker`/`@Servo`/`@Action`)、`-` 占位符语义、`#` 注释、`;` 行结束符完全兼容。 +2. **CLI 兼容**:保持 `cat scripts/std.mt | ./bot` 的 stdin 管道用法与 `--help`/`--test` 分支。 +3. **零第三方依赖的核心**:解析器 + 硬件层仅用标准库(`fcntl`/`os`/`struct`/`threading`/`json`/`argparse`),树莓派上无需 venv 即可运行;numpy 仅测试/工具链可选。 +4. **硬件插件化(Python 替代 Lua)**:未知 `@Block` 路由到 `modules/.py` 插件,保留 build/call/destroy 三段生命周期协议。 +5. **审计 15 项发现全部修复**:见 §5 修复映射表。 +6. **可测试**:pytest 单元测试覆盖解析器、占位符语义、Servo 校准、插件加载;硬件层可用 Mock I2C 在无硬件的 x86 上跑通全流程。 + +### 1.3 不做什么 + +- **不迁移 math/NN/CNN/FFT/CUDA 层**(`src/math/*.cj`、`src/cpp/cnn/`、`src/cpp/fft*`、`src/cuda/`):这部分是为 DRL 预留的自研神经网络库,功能已被 actpred(PyTorch)完全覆盖且质量更高。DRL 闭环未来直接调 actpred,不重复造轮子。相应地 `--test nn/cnn/cnn1` 分支不迁移。 +- **不迁移 `.bot` 旧版脚本支持**:`scripts/*.bot` 是历史遗留,仓颉版本身也未解析它。 +- **不改 `.mt` 语法**:`@Action_Auto` 等语法演进(旧分析 D11)留待后续版本。 +- **不引入 smbus2/RPi.GPIO 等三方库**:I2C 用 `fcntl.ioctl(fd, I2C_SLAVE=0x0703, addr)` 直接实现,与仓颉版 `I2C.cj`/`I2C.c` 行为逐字节对齐。 +- **不删除 bot4cj 目录**:遵循项目"原版与重写版并存 → 验证通过后切换"的渐进策略(同 db→db2、captcha→captcha2、smtp→smtp3)。 + +--- + +## 2. 现状分析 + +### 2.1 仓颉版架构分层 + +``` +.mt 矩阵脚本(硬件定义 + 动作序列) + ↓ +MatrixParser(P.cj,FSM 逐字符解析)→ MatrixObject 链表 + ↓ +RobotStruct(r.cj)createHardVars():按 @块名分发到硬件变量工厂 + MatrixVars_Hardware 基类 + 三闭包:rise/things/free + ↓ +硬件实现 hd/*.cj:I2C(20 槽锁数组)、Servo、PCA9685、MPU6050、Speaker + + Lua 扩展 modules/*.lua(未知 @Block 的通用驱动,经 C 函数注册调 I2C/SPI) + ↓ +系统库 .so:libi2c4cj(I2C glibc 封装)、libsnd4cj(音频)、libmt4cj/libfft4cj/libcnn4cj(数学) +``` + +### 2.2 迁移范围源码清单 + +| 仓颉源文件 | 行数 | 职责 | 迁移去向 | +|-----------|------|------|---------| +| `src/main.cj` | 178 | CLI 入口、stdin 管道、--test 分支 | `pybot/main.py` | +| `src/P.cj` | 264 | MatrixParser(FSM)+ MatrixObject + MatrixSaver + 解析异常 | `pybot/parser.py` | +| `src/r.cj` | 1072 | RobotStruct + 硬件变量基类 + 6 个工厂 + Lua 状态机桥接 | `pybot/robot.py` + `pybot/hardvars.py` | +| `src/hd/I2C.cj` + `src/cpp/I2C.c` | 340+ | I2C glibc 封装 + 每 fd 互斥锁 | `pybot/hd/i2c.py` | +| `src/hd/Servo.cj` | 31 | max/min/offset 校准 + 负 max 角度翻转 | `pybot/hd/servo.py` | +| `src/hd/MPU6050.cj` | 244 | 6 轴传感器 + 后台采样线程 + 卡尔曼 | `pybot/hd/mpu6050.py` | +| `src/hd/snd.cj` + `src/cpp/snd.cpp` | 23+ | 扬声器(仓颉侧仅 sleep 占位) | `pybot/hd/speaker.py` | +| `src/hd/PCA9685.cj` | — | 已标记"由 Lua 取代" | 不迁移(走插件) | +| `modules/*.lua`(PCA9685/ADS1115/ADS1256/SPI/TEST) | — | Lua 硬件驱动 | `pybot/modules/*.py` 插件 | +| `src/math/*.cj`、`src/cpp/cnn|fft|Matrix|kalman`、`src/cuda/` | ~2000 | 自研 NN/数学库 | **不迁移**(actpred 承担);卡尔曼滤波例外,MPU6050 需要,~40 行纯 Python | + +### 2.3 关键语义(必须逐项对齐) + +1. **占位符 `-`**:递归沿用上一行同列值;首行为 `-` 时,`parseMt` 解析为 `0`(数值列),`getMt` 返回字面 `-`。 + ⚠️ 注意与 actpred `mt_parser.py` 的差异:后者首行 `-` 用 `@Servo` 初始角度填充(这是 ML 数据管线的语义)。**pybot 运行时语义以仓颉版为准(填 0)**,因为运行时 Servo 有 max/min 钳位保护;数据管线语义留在 actpred 不变。 +2. **@Action 列名 `dev.param`**:点号拆分为硬件名 + 参数名;参数名经 `@Alias` 表转换(`A`→`angle`、`F`→`frequency`)后传入硬件。 +3. **每行执行**:同一行按硬件聚合参数字典,逐硬件调用一次 `play()`;行间时延由 `spk.delay` 参数承担。 +4. **硬件生命周期**:`active()` 按创建顺序 build,`deactive()` 倒序 destroy,且必须在 `try/finally` 中保证执行。 +5. **Servo 角度算法**:`a = angle + offset; a = clamp(a, |min|, |max|); if max < 0: a = 180 - a`。 +6. **PCA9685 脉宽映射**:50Hz、0°→0.5ms、180°→2.5ms、`offval = width * 4096 / 20`、寄存器 `0x06 + 4*channel` 写 4 字节。 +7. **MPU6050**:±500°/s、±8g、10ms 采样周期、卡尔曼滤波(Q=20, R=200)、roll/pitch 由加速度计反正切求得。 +8. **I2C 互斥**:同一 fd 上"设地址 + 读/写寄存器"必须是原子序列(多设备共享总线)。 + +### 2.4 审计遗留(迁移时修复的输入清单) + +来自 [bot4cj_CODE_AUDIT_20260808.md](../reviews/bot4cj_CODE_AUDIT_20260808.md):3 项 P0(S01 列数校验恒假 / S02 调试 print 污染 stdout / S03 扬声器无实声)、5 项 P1(S04 转义不一致 / S05 MPU6050 地址重复 / S06 有符号读寄存器 / S07 Lua 路径硬编码 / S08 文档滞后)、7 项 P2(无测试、注释代码、锁扫描 O(n)、无编译告警、消费端报错不可读、线程退出、GC 析构不确定)。 + +--- + +## 3. 目标架构 + +### 3.1 目录结构 + +``` +pybot/ +├── pybot/ +│ ├── __init__.py +│ ├── main.py # CLI 入口(argparse:--help / --test / -i / -o 预留) +│ ├── parser.py # MatrixParser + MatrixObject + MatrixSaver + 解析异常 +│ ├── robot.py # RobotStruct(createHardVars / active / run / deactive / save) +│ ├── hardvars.py # HardwareVar 基类 + Alias/I2C/Servo/Speaker/MPU6050_BIND 工厂 +│ ├── plugin.py # Python 插件加载器(替代 Lua 状态机桥接) +│ └── hd/ +│ ├── __init__.py +│ ├── i2c.py # fcntl.ioctl I2C + 每 fd 锁(dict + threading.Lock) +│ ├── servo.py # 舵机校准与翻转 +│ ├── mpu6050.py # 传感器 + threading.Thread 采样 + kalman +│ ├── kalman.py # 一维卡尔曼滤波(~40 行) +│ └── speaker.py # 正弦波发声(可选 alsaaudio,默认 aplay 子进程 / 静默降级) +├── modules/ # 硬件驱动插件(Python 替代 Lua) +│ ├── PCA9685.py +│ ├── ADS1115.py +│ ├── ADS1256.py +│ └── SPI.py +├── scripts -> ../bot4cj/scripts # 软链或复制,.mt 脚本共享 +├── tests/ +│ ├── test_parser.py +│ ├── test_hardvars.py +│ ├── test_servo.py +│ └── mock_i2c.py # Mock I2C 总线(记录寄存器写序列,供断言) +├── _run.sh # cat x.mt | bash _run.sh(exec python3 -m pybot) +├── _set.sh # 无编译;可选建 venv 装 pytest +├── requirements.txt # 核心为空;[dev] pytest +└── README.md +``` + +### 3.2 数据流(与仓颉版一致) + +``` +stdin → MatrixParser.parse()(逐块产出 MatrixObject) + → RobotStruct.create_hardvars()(按块名分发工厂;未知块 → plugin.load("modules/.py")) + → robot.active()(按序 build) + → robot.run()(逐行 @Action:聚合参数 → 逐硬件 play(dict)) + → robot.deactive()(倒序 destroy,try/finally 保证) +``` + +### 3.3 关键映射决策 + +| 仓颉版机制 | Python 版方案 | 理由 | +|-----------|--------------|------| +| `JsonValue` 统一通信协议 | 原生 `dict`(进程内直接传递) | 无跨语言边界后无需序列化;插件协议文档化 dict 键约定 | +| 三闭包 rise/things/free | `HardwareVar` 基类三方法 `build()/play(params)/destroy()`,子类覆写 | Python 惯用法;闭包捕获改为实例属性 | +| Lua 状态机 + C 函数注册(i2c_open 等 11 个) | `importlib` 动态加载 `modules/.py`,插件直接 `from pybot.hd import i2c` | 消除整个 FFI 层;插件与核心同语言 | +| 20 槽 I2C 锁数组(O(n) 线性扫描) | `dict[fd → threading.Lock]` + 全局注册锁 | 修复审计 Q03;容量不再受限 | +| pthread_create/pthread_cancel(MPU6050 采样) | `threading.Thread(daemon=True)` + `threading.Event` 停止标志 | 修复审计 Q06/D3,安全退出 | +| 仓颉 GC 终结器不确定 | 上下文管理器 `with RobotStruct(...) as robot:` + `atexit` 兜底 | 修复审计 Q07/D1,确定性释放 fd | +| cjpm build + Makefile .so | 无构建步骤 | 树莓派上 `git pull` 即部署 | + +### 3.4 插件协议(modules/*.py,替代 Lua build/call/destroy) + +```python +# modules/PCA9685.py —— 与 PCA9685.lua 逐语义对齐 +def build(ctx: dict) -> dict: + """ctx: 元数据(fd/address/... 来自 @PCA9685 块行 + 引用硬件的 play() 展开) + 初始化:重启 → SLEEP → 设 prescale(50Hz) → 唤醒""" +def call(ctx: dict) -> dict: + """ctx 额外含实时参数(channel/angle);写 4 个 PWM 寄存器""" +def destroy(ctx: dict) -> dict: + """复位 PCA9685""" +``` + +- 插件查找顺序(修复审计 S07):`$PYBOT_MODULES_PATH`(冒号分隔多路径)→ 包内置 `modules/` → 当前工作目录 `./modules/`。 +- 加载失败抛出含插件名与搜索路径的 `PluginNotFound` 异常。 + +--- + +## 4. 详细设计 + +### 4.1 parser.py + +- **MatrixObject**:`name: str` + `rows: list[list[str]]`;提供 `__getitem__((row, col))`、`index_of_col(row, key)`、`save(writer)`。越界访问抛 `MatrixIndexError(块名, 行, 列)`(修复审计 Q05 的报错可读性)。 +- **MatrixParser**:保持 FSM 逐字符解析(非正则),状态与仓颉版一致(nl/cm/nm 三标志),原因:`@` 块嵌套注释、行内注释、任意空白分隔的兼容性已被现有脚本验证。 + - 修复 S01:`submit_line` 用 `len(grp) != len(rows[-1])` 校验列数,不一致抛 `DoesNotSync(行号, 期望列数, 实际列数)`。 + - 修复 S02:删除逐字符 debug 打印;提供 `--verbose` 时输出到 **stderr**(stdout 保留给未来管道通信)。 + - 修复 S04:Python 无 Rune/String 混淆问题,换行统一 `"\n"` 字符比较;补充单测覆盖"漏写分号跨行"场景。 +- **占位符语义**(§2.3-1):`get_mt(obj, row, col)` 递归向上;`parse_mt(obj, row, col, type)` 首行 `-` 返回 `type("0")`。 +- **MatrixSaver**:保持与仓颉版相同的输出格式(`@Name` / 空格分隔 / `;` 结尾 / `@` 收尾),保证 `--test matrix_o/matrix_2` 的解析↔序列化往返可用。 + +### 4.2 hd/i2c.py + +```python +I2C_SLAVE = 0x0703 +class I2CBus: + def __init__(self, path): self.fd = os.open(path, os.O_RDWR); _register_lock(self.fd) + def set_addr(self, addr): fcntl.ioctl(self.fd, I2C_SLAVE, addr) + def write_reg(self, reg, val): os.write(self.fd, bytes([reg, val])) + def read_reg(self, reg) -> int: # 返回 0..255 无符号(修复 S06) + os.write(self.fd, bytes([reg])); return os.read(self.fd, 1)[0] + def transaction(self): # with bus.transaction(): 原子序列(设地址+读写) +``` + +- 锁注册表:`_LOCKS: dict[int, threading.Lock]` + `_MAIN_LOCK`;`transaction()` 上下文管理器持锁,替代仓颉版 `I2cLock_lock/unlock` 手动配对。 +- `read_reg` 天然返回无符号 int(`bytes[0]` ∈ 0..255),16 位拼接 `(hh << 8) | ll` 后用 `struct.unpack('>h')` 转有符号——修复 S06 的符号扩展错误且保留传感器数据的正确符号。 + +### 4.3 hd/servo.py + +与 `Servo.cj` 逐行对齐(§2.3-5),构造参数 `max/min/offset/set_angle_callback`。补单测:正装(max=90)、反装(max=-180 翻转)、offset 漂移、越界钳位。 + +### 4.4 hd/mpu6050.py + +- 寄存器常量与 `MPU6050.cj` 一致;**修复 S05**:`ADDR_0 = 0x68`、`ADDR_1 = 0x69`。 +- 采样线程:`threading.Thread` + `self._stop = threading.Event()`,循环 `while not self._stop.wait(0.01)`(10ms 周期且可即时打断)——修复 Q06。 +- 读传感器整体包在 `bus.transaction()` 中(设地址 + 14 字节连读),比仓颉版逐寄存器 12 次 I2C 往返更高效且原子。 +- 卡尔曼滤波参数保持 Q=20、R=200;roll/pitch 公式不变。 +- 数据出口:`latest()` 返回带 `threading.Lock` 保护的最近一帧滤波值(仓颉版 mutex 字段从未真正使用,此处补全)。 + +### 4.5 hd/speaker.py(决策 S03) + +仓颉版 `play()` 仅 sleep(实声 C++ 函数被注释)。Python 版决策:**实现真实发声,带静默降级**。 + +- 首选:标准库 `wave` + `subprocess.run(["aplay", "-q", tmp.wav])`——树莓派自带 alsa-utils,零 pip 依赖;正弦波生成用 `math.sin`(每次 ≤ 数秒 @48kHz,纯 Python 可接受),保留 5ms 淡入淡出。 +- `aplay` 不存在或播放失败:log 一次警告后仅 sleep(delay),行为与仓颉版一致(延时职责不丢失)。 +- `delay` 语义不变:`play()` 阻塞 `delay` 毫秒(发声与延时并行时取延时为准)。 + +### 4.6 robot.py / hardvars.py + +- `RobotStruct` 保持仓颉版结构:`C: list[MatrixObject]` + `M: dict[str, HardwareVar]`(Python dict 有序,天然满足"倒序 destroy"需求)。 +- `create_hardvars()` 分发表:`{"I2C": I2CVar, "Alias": AliasVar, "Servo": ServoVar, "Speaker": SpeakerVar, "MPU6050_BIND": MPU6050Var}`;`Action` 跳过;未知块 → `plugin.PluginVar`。 +- 重名键检查(仓颉版 FIXME r.cj:911):同名硬件变量重复定义时,先 `destroy()` 旧值再覆盖,并输出 stderr 警告。 +- `run()` 逐行执行 `@Action`;列名合法性(必须含一个 `.`)在**执行前**整表校验一次,报错含列号与列名(配合 Q05 修复)。 +- 上下文管理器:`__enter__`→`active()`,`__exit__`→`deactive()`;`main.py` 用 `with` 替代仓颉版 try/finally。 + +### 4.7 main.py + +| 仓颉版 CLI | Python 版 | 说明 | +|-----------|-----------|------| +| (无参)stdin 管道 | 同 | `cat std.mt | ./bot` 不变;另支持 `pybot path/to/x.mt` 位置参数(增强,非破坏) | +| `--help` | argparse 自动生成 | 文案更新 | +| `--test nn/cnn/cnn1` | **移除**(见 §1.3) | NN 由 actpred 承担 | +| `--test matrix_o/matrix_2` | `--test roundtrip ` | 解析↔序列化往返测试,路径改为参数(仓颉版硬编码 `ss/test.mt`) | +| `-i` / `-o`(DRL 预留) | 保留参数位,标注 reserved | 未来对接 actpred | +| (新增)`--dry-run` | 解析 + 构建硬件变量但不开 I2C,Mock 总线打印动作序列 | 无硬件调试 .mt 脚本 | +| (新增)`--verbose` | FSM 与硬件调用日志到 stderr | 替代 S02 删掉的调试打印 | + +### 4.8 日志规范 + +- 所有诊断输出走 `logging`(stderr),stdout 保留纯净——为未来 actpred 管道集成预留(审计 §六 的阻塞项 S02 根治)。 + +--- + +## 5. 审计发现修复映射表 + +| 审计 ID | 级别 | 仓颉版问题 | Python 版修复方式 | 落点 | +|---------|------|-----------|------------------|------| +| S01 | P0 | submitLine 列数校验恒假 | `len(grp) != len(rows[-1])` 显式校验 + DoesNotSync 带期望/实际列数 | parser.py | +| S02 | P0 | parse() 逐字符 print 污染 stdout | 移除;--verbose 走 stderr logging | parser.py / main.py | +| S03 | P0 | 扬声器仅 sleep 无实声 | wave + aplay 实现发声,缺 aplay 静默降级为 sleep | hd/speaker.py | +| S04 | P1 | r"\n" 与 r'\n' 转义不一致 | Python 无此坑;补"漏分号跨行"单测防回归 | tests/test_parser.py | +| S05 | P1 | MPU6050 ADDR_1 应为 0x69 | `ADDR_1 = 0x69` | hd/mpu6050.py | +| S06 | P1 | readI2cReg 返回有符号 Int8 | read_reg 返回 0..255;16 位拼接后 struct 显式转有符号 | hd/i2c.py | +| S07 | P1 | Lua 路径硬编码 ./modules/ | `$PYBOT_MODULES_PATH` → 包内置 → cwd 三级查找 | plugin.py | +| S08 | P1 | README 已知问题表滞后 | pybot README 重写;bot4cj README 标注维护状态 | README.md | +| Q01 | P2 | 无单元测试 | pytest:解析器/占位符/Servo/插件/Mock I2C 全流程 | tests/ | +| Q02 | P2 | 大量注释旧代码 | 新代码库不携带 | — | +| Q03 | P2 | I2C 锁 O(n) 线性扫描 | dict[fd → Lock] | hd/i2c.py | +| Q04 | P2 | Makefile 告警关闭 | 无 C 编译;CI 跑 `python -m pyflakes`(可选) | _set.sh | +| Q05 | P2 | 列数不匹配报错不可读 | MatrixIndexError 带块名/行/列 + run() 前整表校验 | parser.py / robot.py | +| Q06 | P2 | pthread_cancel 非安全点退出 | threading.Event 优雅停止 | hd/mpu6050.py | +| Q07 | P2 | GC 析构不确定,fd 泄漏 | with 上下文管理器 + atexit 兜底 | robot.py / main.py | + +--- + +## 6. 测试计划 + +### 6.1 单元测试(无硬件,x86 可跑) + +| 用例组 | 覆盖 | +|--------|------| +| test_parser | 全部 `scripts/*.mt` 解析成功;列数不一致抛 DoesNotSync;漏分号跨行;注释/空行/CRLF;roundtrip(解析→save→再解析等价) | +| test_placeholder | `-` 递归沿用;首行 `-` 数值列=0;与 actpred mt_parser 语义差异的显式断言(防止两边被误"统一") | +| test_servo | 正装/反装翻转/offset/钳位边界 | +| test_hardvars | @Alias 转换;@Action 列名拆分;同行参数聚合;重名覆盖警告 | +| test_plugin | 插件三级路径查找;PluginNotFound 报错内容;PCA9685.py 用 Mock I2C 断言寄存器写序列(prescale=121、reg=0x06+4*ch、offval 字节序) | +| test_mpu6050 | Mock 总线注入 14 字节样本 → 有符号拼接正确(高字节 ≥ 0x80 场景,回归 S06);Event 停止 1s 内退出 | + +### 6.2 实机验证(树莓派 + Dashun 蜘蛛机器人) + +1. `cat scripts/std.mt | bash _run.sh`:站立姿态与仓颉版目测一致(10 舵机角度逐一对照 --verbose 日志)。 +2. `jump.mt` / `lie-down.mt` / `tilt-*.mt` 逐个回放,动作时序无可感知差异。 +3. 双 MPU6050(0x68 + 0x69)同总线并行采样(回归 S05),roll/pitch 数值与仓颉版单设备输出同量级。 +4. 扬声器 `spk.F` 发声可闻(回归 S03),无爆音(淡入淡出生效)。 +5. Ctrl-C 中断:舵机停止、I2C fd 全部释放(`ls /proc//fd` 验证)、MPU6050 线程退出。 + +### 6.3 交叉验证 + +- `--dry-run` 模式下用 Mock 总线记录全部 I2C 写序列,与仓颉版(打补丁记录)同脚本输出逐字节 diff——作为迁移等价性的最强证据(可参考 captcha2 迁移的像素级验证经验)。 + +--- + +## 7. 里程碑 + +| 阶段 | 内容 | 产出 | +|------|------|------| +| M1 解析器 | parser.py + 全量单测 + roundtrip | 全部 scripts/*.mt 解析通过 | +| M2 硬件抽象 | hardvars.py + robot.py + plugin.py + Mock I2C | --dry-run 全流程可跑(x86) | +| M3 硬件落地 | i2c.py / servo.py / mpu6050.py / speaker.py + modules/*.py | 树莓派实机 std.mt 站立 | +| M4 等价性验证 | §6.2 实机回放 + §6.3 写序列 diff | 验证报告 | +| M5 切换 | README/INDEX 更新、bot4cj 标注 legacy、aibox/BotOps 若有引用改指 pybot | 并存期结束评估 | + +--- + +## 8. 风险与对策 + +| 风险 | 影响 | 对策 | +|------|------|------| +| Python GIL + 解释器抖动影响动作时序 | 舵机动作节奏轻微不均 | 动作行间延时 ≥ 20ms,抖动 <1ms 不可感知;MPU6050 采样用 Event.wait 定时,不忙等;量化依据见附录 A | +| aplay 子进程首次启动延迟 | 首个音符滞后 | build() 时预热(播放 0 长度静音);或后续可选接 alsaaudio | +| 仓颉版行为的未记录细节 | 等价性缺口 | §6.3 写序列逐字节 diff 兜底 | +| .mt 脚本方言分裂(pybot vs actpred 解析器) | 语义漂移 | test_placeholder 显式断言两者差异;长期可评估 actpred 复用 pybot parser 的只读模式 | +| 树莓派 Python 版本参差 | 语法兼容 | 目标 Python ≥ 3.9(树莓派 OS Bullseye 起自带 3.9) | + +--- + +## 9. 与 actpred 的集成展望(非本期范围) + +迁移完成后,"大脑-身体"闭环的最短路径: + +``` +actpred predict.py(图像+历史 → 动作向量 action_dim=18) + → 向量转 @Action 单行(pybot 提供 action_row_from_vector() 工具函数) + → pybot RobotStruct 增量执行(对应仓颉版 TODO:@Action 位置指示器) + → MPU6050 姿态反馈回流 actpred(DRL 奖励信号) +``` + +同语言后,上述管线可在单进程内完成,无需序列化协议——这正是本次迁移最大的战略收益。 + +--- + +## 附录 A:性能评估依据 + +迁移评审中最常见的两个疑虑是"Python 做驱动会不会慢"与"Python 替代 Lua 会不会引入开销"。本附录给出量化分析,结论:**两者均不构成风险,后者反而是性能收益**。 + +### A.1 Python 做硬件驱动的时间尺度分析 + +bot4cj 架构中所有微秒级时序都已下沉到专用硬件芯片(PCA9685 自主生成 PWM 波形),主机侧只发送设定值,该路径的瓶颈是 I2C 总线物理速率,与宿主语言无关: + +| 环节 | 耗时量级 | 由谁承担 | +|------|---------|---------| +| 舵机 PWM 波形(50Hz、0.5~2.5ms 脉宽) | µs 级精度 | PCA9685 硬件芯片,主机不参与 | +| I2C 写一个寄存器(100kHz 标准模式) | ~200–300 µs/次 | 总线物理速率,语言无关 | +| Python 一次 `os.write`/`ioctl` 调用开销 | ~2–5 µs | 解释器 + 系统调用 | +| 设一个舵机角度(4 次寄存器写) | ~1 ms | 总线 ≈ 99%,Python ≈ 1% | +| 舵机机械转动 60° | ~100–200 ms | 物理惯性 | +| `.mt` 动作行间延时 | 20–500 ms | 脚本显式指定 | + +Python 解释开销比总线时间低约两个数量级,比机械动作低四五个数量级。逐项检查潜在慢点: + +- **MPU6050 10ms 采样循环**:14 字节连读 ≈ 1.5–2ms 总线时间 + 卡尔曼滤波 6 次浮点(纯 Python 数 µs),周期内富余充足;且连读比仓颉版逐寄存器 12 次往返(每次重发从机地址)更快。 +- **正弦波生成**:48kHz 下每秒音频约 4.8 万次 `math.sin`,纯 Python 10–30ms 算完,发声前一次性生成,可接受。 +- **GIL/GC**:仅主线程 + 1 个采样线程,GIL 无竞争;GC 停顿亚毫秒级,低于 `sleep()` 本身的 ms 级精度。 + +Python 不适用的边界(bot4cj 均不涉及):主机 GPIO bit-bang 生成 PWM/WS2812 单线协议、>1kHz 闭环控制、硬实时保证(Linux 非 RTOS,仓颉版同样做不到)。若未来 DRL 需要高频控制环,正确做法是下沉到协处理器(如 RP2040),属架构问题而非语言问题。 + +### A.2 Python 插件替代 Lua 桥接的开销对比 + +单论解释器速度 Lua 5.4 快于 CPython,但现有 Lua 路径的开销主要在跨语言桥接而非驱动代码本身。仓颉版每次硬件调用([r.cj MtVars_Lua.things](../../bot4cj/src/r.cj#L311-L331))的实际成本: + +| 步骤 | 实现 | 开销量级 | +|------|------|---------| +| 元数据深拷贝 | `JsonValue.fromStr(metadata.toJsonString())`——JSON 序列化+反序列化一整轮 | ~10–50 µs | +| 参数推入 Lua 表 | 每键 `mallocCString` + `Lua_pushstring/setfield` + `free`,跨仓颉→C→Lua 三层 FFI | ~几 µs/键 | +| `Lua_pcallk` + 返回表遍历 | `Lua_next` 循环 + 每键值 `Lua_tostring` 转仓颉 String | ~几 µs | +| 强制调试打印 | 仓颉侧 2 次 `println` + Lua 侧 `print_table` 全表打印 | ~几十 µs 至 ms 级(终端 I/O) | +| Lua 内 4 次 `i2c_write_reg` | 每次跨 Lua→C→仓颉 @C 三层 | 桥接 ~1 µs + 总线 ~250 µs | + +合计框架开销 ~0.1–1 ms/次,与 I2C 总线时间同量级。Python 插件路径的对应成本: + +| 步骤 | 实现 | 开销量级 | +|------|------|---------| +| 上下文构造 | `{**metadata, **params}` dict 合并(同进程传引用,无序列化) | ~0.5 µs | +| 插件调用 | 同解释器直接函数调用 | ~0.1 µs | +| I2C 写 | 插件直接 `i2c.write_reg()`,一层 `os.write` 到底 | 系统调用 ~2 µs + 总线 ~250 µs | +| 诊断输出 | 默认关闭,`--verbose` 时走 stderr logging(即审计 S02 修复) | 0 | + +框架开销 ~1–2 µs/次,比 Lua 桥接低约两个数量级。插件内部计算(脉宽换算、移位掩码)在两种解释器上的差距为百纳秒级,相对 1ms 总线时间可忽略。 + +**结论**:Lua 层在仓颉版存在的唯一理由是"编译型宿主做不了运行时扩展",为此付出 JSON 往返 + 五层 FFI(仓颉→C→Lua→C→仓颉→glibc)的税;宿主换成 Python 后调用链缩短为 Python→glibc 一层,此项迁移是删除一整层间接性,而非语言互换。 + +--- + +## 参考文档 + +- [bot4cj 代码审计报告](../reviews/bot4cj_CODE_AUDIT_20260808.md)(15 项发现) +- [bot4cj 深度分析报告](bot4cj_analysis_20260525_00.md)(D1-D29) +- [actpred 与 bot4cj 研究报告](actpred_bot4cj_research_20260809.md) +- [bot4cj README.robot.md](../../bot4cj/README.robot.md) +- [actpred mt_parser.py](../../actpred/mt_parser.py)(Python 解析器先例) + +--- + +*本文档为开发计划,实施前需结合实际硬件环境评估。*