actpred: 代码审计(16项发现全部修复)+ 文档更新

审计报告: docs/reviews/actpred_CODE_AUDIT_20260808.md
- P0(2项): episode索引错位(dataset.py)、推理归一化静默失败(predict.py)
- P1(6项): 空训练集校验、图像自然排序、空样本防御、形状校验、
  MobileNet下载错误提示、stats路径校验
- P2(8项): README示例修正、公共属性、glob优化、版本约束、
  单元测试(17项)、GPU安装说明、BooleanOptionalAction、ONNX验证

新增文件: actpred/test_actpred.py (17项单元测试, 全部通过)
更新文档: README.md配置表扩充、INDEX.md、审计报告修复总结
This commit is contained in:
qing committed 2026-08-08 16:05:01 +00:00
1 parent 1e34348d6c
commit 3d03f7a1ce
19 files changed
+850 -177

No files matched your search

+18 -4
View File
@@ -17,18 +17,21 @@
## 快速开始
```bash
# 安装
# 安装(默认 CPU-only;如需 GPU 支持,编辑 _set.sh 按注释说明操作)
cd actpred && bash _set.sh
# 训练
bash _run.sh train --data-dir ./data --epochs 100 --action-dim 8
# 推理
# 推理(--action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步)
bash _run.sh --model checkpoints/best_inference.pt --image frame.jpg \
--action-history "45,0,45,0,45,0,45,0:45,10,45,10,45,10,45,10"
--action-history "0,0,0,0,0,0,0,0:1,0,0,0,0,0,0,0:0,1,0,0,0,0,0,0:0,0,1,0,0,0,0,0:0,0,0,1,0,0,0,0"
# 导出 ONNX
# 导出 ONNX(如安装了 onnxruntime,导出后自动验证一致性)
bash _run.sh export --model checkpoints/best_inference.pt --output model.onnx
# 运行单元测试
python test_actpred.py
```
## 数据格式
@@ -72,6 +75,15 @@ recorder.flush()
| `--epochs` | 100 | 训练轮数 |
| `--lr` | 0.001 | 学习率 |
| `--batch-size` | 32 | 批次大小 |
| `--normalize-actions` | False | 动作归一化(`--no-normalize-actions` 关闭) |
| `--dropout` | 0.1 | Dropout 率 |
| `--weight-decay` | 0.0001 | 权重衰减 |
| `--val-split` | 0.15 | 验证集比例 |
| `--patience` | 15 | 早停耐心值 |
| `--max-grad-norm` | 1.0 | 梯度裁剪上限 |
| `--seed` | 42 | 随机种子 |
| `--num-workers` | 2 | DataLoader 工作进程数 |
| `--checkpoint-dir` | ./checkpoints | 检查点保存目录 |
## 编程 API
@@ -79,5 +91,7 @@ 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,)
# action_history 形状须为 (history_len, action_dim)
```
+1
View File
@@ -4,6 +4,7 @@ python3 -m venv venv
source venv/bin/activate
# CPU-only PyTorch (no CUDA dependency)
# 如需 GPU 支持,注释掉下面一行并改用:pip install torch torchvision
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
# Remaining deps from Tsinghua mirror
+1 -1
View File
@@ -61,7 +61,7 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser:
parser.add_argument("--action-dim", type=int, default=None)
parser.add_argument("--history-len", type=int, default=None)
parser.add_argument("--data-dir", type=str, default=None)
parser.add_argument("--normalize-actions", action="store_true", 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)
+21 -6
View File
@@ -1,4 +1,5 @@
import os
import re
import numpy as np
from pathlib import Path
from PIL import Image
@@ -8,6 +9,11 @@ from torch.utils.data import Dataset
from torchvision import transforms
def _natural_key(p):
"""自然排序键:'1.jpg' < '2.jpg' < ... < '10.jpg'"""
return [int(t) if t.isdigit() else t for t in re.split(r'(\d+)', p.name)]
class ActionDataset(Dataset):
def __init__(self, data_dir: str, history_len: int, action_dim: int,
image_size: int = 64, normalize_actions: bool = False,
@@ -22,8 +28,7 @@ class ActionDataset(Dataset):
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])
data_path = Path(data_dir)
episodes = sorted([d for d in data_path.iterdir() if d.is_dir()])
episodes = ActionDataset._valid_episode_dirs(data_dir)
if episode_indices is not None:
episodes = [episodes[i] for i in episode_indices]
@@ -36,7 +41,7 @@ class ActionDataset(Dataset):
actions = np.load(actions_path).astype(np.float32)
n_frames = len(actions)
image_files = sorted(list(images_dir.glob("*.jpg")) + list(images_dir.glob("*.png")),
key=lambda p: p.name)
key=_natural_key)
n_images = len(image_files)
n = min(n_frames, n_images)
for t in range(history_len, n):
@@ -45,11 +50,18 @@ class ActionDataset(Dataset):
self._action_mean = None
self._action_std = None
if normalize_actions:
if action_stats_path and os.path.exists(action_stats_path):
if action_stats_path:
if not os.path.exists(action_stats_path):
raise FileNotFoundError(
f"指定的 action_stats_path 不存在: {action_stats_path}")
stats = np.load(action_stats_path)
self._action_mean = stats[0]
self._action_std = stats[1]
else:
if not self.samples:
raise ValueError(
f"数据集为空(0 样本),无法计算归一化统计量。"
f"请检查 data_dir={data_dir} 中的 episode 数据。")
all_actions = np.stack([s[2] for s in self.samples])
self._action_mean = all_actions.mean(axis=0)
self._action_std = all_actions.std(axis=0) + 1e-8
@@ -92,8 +104,11 @@ class DataRecorder:
self._frame_idx = 0
self._actions = []
ep_idx = 0
while (self.output_dir / f"episode_{ep_idx:04d}").exists():
ep_idx += 1
for d in self.output_dir.glob("episode_*"):
try:
ep_idx = max(ep_idx, int(d.name.split("_")[1]) + 1)
except (ValueError, IndexError):
pass
self.episode_dir = self.output_dir / f"episode_{ep_idx:04d}"
self.images_dir = self.episode_dir / "images"
self.images_dir.mkdir(parents=True, exist_ok=True)
+15
View File
@@ -36,6 +36,21 @@ def main():
)
print(f"ONNX model exported to: {args.output}")
# 可选:验证导出的 ONNX 模型一致性
try:
import onnxruntime as ort
import numpy as np
sess = ort.InferenceSession(args.output)
img_np = dummy_image.numpy()
hist_np = dummy_history.numpy()
ort_out = sess.run(None, {"image": img_np, "action_history": hist_np})[0]
with torch.no_grad():
torch_out = model(dummy_image, dummy_history).numpy()
max_diff = np.abs(ort_out - torch_out).max()
print(f"ONNX 一致性验证通过(最大差异: {max_diff:.6e})")
except ImportError:
print("提示: 安装 onnxruntime 可验证导出模型一致性 (pip install onnxruntime)")
if __name__ == "__main__":
main()
+10 -1
View File
@@ -30,7 +30,16 @@ class MobileNetEncoder(nn.Module):
def __init__(self, feature_dim: int = 128):
super().__init__()
from torchvision.models import mobilenet_v2, MobileNet_V2_Weights
backbone = mobilenet_v2(weights=MobileNet_V2_Weights.DEFAULT)
try:
backbone = mobilenet_v2(weights=MobileNet_V2_Weights.DEFAULT)
except Exception as e:
raise RuntimeError(
"MobileNetV2 预训练权重下载失败,可能是网络受限。"
"请在联网环境中预下载权重"
"(运行 torchvision.models.mobilenet_v2(weights='IMAGENET1K_V1')),"
"并将 ~/.cache/torch/hub/checkpoints/ 下的权重文件"
"拷贝到目标机器同路径。"
) from e
self.features = backbone.features
self.pool = nn.AdaptiveAvgPool2d(1)
self.proj = nn.Linear(1280, feature_dim)
+14 -5
View File
@@ -25,6 +25,9 @@ class ActionPredictorInference:
self._model.load_state_dict(checkpoint["model_state_dict"])
self._model.eval()
self.action_dim = self._data_cfg.action_dim
self.history_len = self._data_cfg.history_len
self._transform = transforms.Compose([
transforms.Resize((self._data_cfg.image_size, self._data_cfg.image_size)),
transforms.ToTensor(),
@@ -35,15 +38,20 @@ class ActionPredictorInference:
self._action_std = None
stats_path = os.path.join(os.path.dirname(os.path.abspath(checkpoint_path)),
"action_stats.npy")
try:
if self._data_cfg.normalize_actions:
if not os.path.exists(stats_path):
raise FileNotFoundError(
f"模型训练时启用了动作归一化,但未找到统计文件: {stats_path}")
stats = np.load(stats_path)
self._action_mean = stats[0]
self._action_std = stats[1]
except (FileNotFoundError, ValueError):
pass
@torch.no_grad()
def predict(self, image: np.ndarray, action_history: np.ndarray) -> np.ndarray:
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)
history = torch.tensor(action_history, dtype=torch.float32)
@@ -72,7 +80,8 @@ def main():
parser.add_argument("--model", required=True, help="checkpoint path")
parser.add_argument("--image", required=True, help="image file path")
parser.add_argument("--action-history", required=True,
help="colon-separated action vectors, e.g. '45,0,45,0:45,10,45,10'")
help="冒号分隔各步,每步 action_dim 个值,共 history_len 步。"
"e.g. '0,0,0,0:1,0,0,0' (action_dim=4, history_len=2)")
parser.add_argument("--action-dim", type=int, default=None,
help="override action dim (auto-detected from checkpoint if omitted)")
parser.add_argument("--output-format", choices=["json", "raw"], default="json")
@@ -80,7 +89,7 @@ def main():
args = parser.parse_args()
predictor = ActionPredictorInference(args.model, args.device)
action_dim = args.action_dim or predictor._data_cfg.action_dim
action_dim = args.action_dim or predictor.action_dim
image = Image.open(args.image).convert("RGB")
history = parse_action_history(args.action_history, action_dim)
+5 -5
View File
@@ -1,8 +1,8 @@
# Core
torch>=2.0
torchvision>=0.15
numpy>=1.24
Pillow>=9.0
torch>=2.0,<3.0
torchvision>=0.15,<1.0
numpy>=1.24,<3.0
Pillow>=9.0,<12.0
# Training utilities
tqdm>=4.60
tqdm>=4.60,<5.0
+219
View File
@@ -0,0 +1,219 @@
"""actpred 模块单元测试
运行方式:
cd actpred && python -m pytest test_actpred.py -v
# 或
cd actpred && python test_actpred.py
"""
import os
import tempfile
import unittest
from pathlib import Path
import numpy as np
from PIL import Image
import torch
from config import Config, DataConfig, ModelConfig, build_argparser, apply_overrides
from dataset import _natural_key, ActionDataset, DataRecorder
from model import ActionPredictor
from predict import parse_action_history
class TestNaturalKey(unittest.TestCase):
"""测试自然排序键函数"""
def test_natural_order_no_padding(self):
"""无前导零的文件名应按数字序排列"""
files = [Path(f"{i}.jpg") for i in [3, 1, 10, 2, 20]]
sorted_files = sorted(files, key=_natural_key)
names = [f.name for f in sorted_files]
self.assertEqual(names, ["1.jpg", "2.jpg", "3.jpg", "10.jpg", "20.jpg"])
def test_natural_order_with_padding(self):
"""有前导零的文件名排序也应正确"""
files = [Path(f"{i:04d}.jpg") for i in [3, 1, 10, 2, 20]]
sorted_files = sorted(files, key=_natural_key)
names = [f.name for f in sorted_files]
self.assertEqual(names, ["0001.jpg", "0002.jpg", "0003.jpg", "0010.jpg", "0020.jpg"])
class TestParseActionHistory(unittest.TestCase):
"""测试动作历史解析函数"""
def test_single_step(self):
history = parse_action_history("1,2,3,4", action_dim=4)
self.assertEqual(history.shape, (1, 4))
np.testing.assert_array_equal(history[0], [1, 2, 3, 4])
def test_multi_step(self):
history = parse_action_history("1,0,0,0:0,1,0,0:0,0,1,0", action_dim=4)
self.assertEqual(history.shape, (3, 4))
def test_wrong_dim_raises(self):
with self.assertRaises(AssertionError):
parse_action_history("1,2,3", action_dim=4)
class TestActionPredictor(unittest.TestCase):
"""测试模型前向传播"""
def test_forward_simple_mlp(self):
data_cfg = DataConfig(image_size=64, action_dim=8, history_len=5)
model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="mlp")
model = ActionPredictor(model_cfg, data_cfg)
model.eval()
image = torch.randn(2, 3, 64, 64)
history = torch.randn(2, 5, 8)
with torch.no_grad():
out = model(image, history)
self.assertEqual(out.shape, (2, 8))
def test_forward_simple_gru(self):
data_cfg = DataConfig(image_size=64, action_dim=8, history_len=5)
model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="gru")
model = ActionPredictor(model_cfg, data_cfg)
model.eval()
image = torch.randn(2, 3, 64, 64)
history = torch.randn(2, 5, 8)
with torch.no_grad():
out = model(image, history)
self.assertEqual(out.shape, (2, 8))
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")
model = ActionPredictor(model_cfg, data_cfg)
model.eval()
for batch_size in [1, 4, 8]:
image = torch.randn(batch_size, 3, 32, 32)
history = torch.randn(batch_size, 3, 4)
with torch.no_grad():
out = model(image, history)
self.assertEqual(out.shape, (batch_size, 4))
class TestDataRecorder(unittest.TestCase):
"""测试数据录制器"""
def test_record_and_flush(self):
with tempfile.TemporaryDirectory() as tmpdir:
recorder = DataRecorder(tmpdir, action_dim=4)
for _ in range(10):
img = np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8)
action = np.random.randn(4).astype(np.float32)
recorder.record(img, action)
episode_path = recorder.flush()
self.assertTrue(os.path.exists(os.path.join(episode_path, "actions.npy")))
images_dir = os.path.join(episode_path, "images")
self.assertTrue(os.path.isdir(images_dir))
self.assertEqual(len(os.listdir(images_dir)), 10)
actions = np.load(os.path.join(episode_path, "actions.npy"))
self.assertEqual(actions.shape, (10, 4))
def test_multiple_episodes_increment_index(self):
with tempfile.TemporaryDirectory() as tmpdir:
r1 = DataRecorder(tmpdir, action_dim=4)
r1.flush()
r2 = DataRecorder(tmpdir, action_dim=4)
r2.flush()
self.assertNotEqual(r1.episode_dir, r2.episode_dir)
self.assertIn("episode_0001", str(r2.episode_dir))
class TestActionDataset(unittest.TestCase):
"""测试数据集加载"""
@staticmethod
def _create_dummy_episode(base_dir, n_frames=10, action_dim=4, image_size=32):
ep_dir = base_dir / "episode_0001"
images_dir = ep_dir / "images"
images_dir.mkdir(parents=True, exist_ok=True)
actions = []
for i in range(n_frames):
img = Image.fromarray(
np.random.randint(0, 255, (image_size, image_size, 3), dtype=np.uint8))
img.save(images_dir / f"{i:04d}.jpg")
actions.append(np.random.randn(action_dim).astype(np.float32))
np.save(ep_dir / "actions.npy", np.array(actions))
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
sample = ds[0]
self.assertIn("image", sample)
self.assertIn("action_history", sample)
self.assertIn("target", sample)
self.assertEqual(sample["image"].shape, (3, 32, 32))
self.assertEqual(sample["action_history"].shape, (3, 4))
self.assertEqual(sample["target"].shape, (4,))
def test_episode_count(self):
with tempfile.TemporaryDirectory() as tmpdir:
self._create_dummy_episode(Path(tmpdir))
count = ActionDataset.get_episode_count(tmpdir)
self.assertEqual(count, 1)
def test_empty_stats_raises(self):
"""空数据集计算归一化统计量应报错"""
with tempfile.TemporaryDirectory() as tmpdir:
with self.assertRaises(ValueError):
ActionDataset(tmpdir, history_len=5, action_dim=4,
normalize_actions=True)
def test_invalid_stats_path_raises(self):
"""指定的 stats 路径不存在应报错"""
with tempfile.TemporaryDirectory() as tmpdir:
with self.assertRaises(FileNotFoundError):
ActionDataset(tmpdir, history_len=3, action_dim=4,
normalize_actions=True,
action_stats_path="/nonexistent/path.npy")
class TestConfig(unittest.TestCase):
"""测试配置和 CLI 参数"""
def test_default_config(self):
cfg = Config()
self.assertEqual(cfg.data.image_size, 64)
self.assertEqual(cfg.data.action_dim, 8)
self.assertEqual(cfg.data.history_len, 5)
self.assertEqual(cfg.model.cnn_backbone, "simple")
self.assertEqual(cfg.model.history_encoder, "mlp")
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"])
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")
def test_normalize_actions_flag(self):
"""--normalize-actions 和 --no-normalize-actions 都应可用"""
parser = build_argparser("test")
args = parser.parse_args(["--normalize-actions"])
self.assertTrue(args.normalize_actions)
parser = build_argparser("test")
args = parser.parse_args(["--no-normalize-actions"])
self.assertFalse(args.normalize_actions)
parser = build_argparser("test")
args = parser.parse_args([])
self.assertIsNone(args.normalize_actions)
if __name__ == "__main__":
unittest.main(verbosity=2)
+4
View File
@@ -95,6 +95,10 @@ def main():
cfg.data.action_stats_path, val_indices)
print(f"Samples: {len(train_ds)} train, {len(val_ds)} val")
if len(train_ds) == 0:
raise ValueError(
f"训练集为空(0 样本)。请检查 data_dir={cfg.data.data_dir} "
f"及 episode 数据完整性。")
if len(val_ds) == 0:
print("WARNING: validation set is empty (episodes too short/few); "
"model selection is disabled, last epoch will be saved.")