feat(actpred): 动作预测模块入库(PyTorch 训练/预测/ONNX 导出)
This commit is contained in:
1 parent
f93ae59521
commit
575c2a27a4
11 files changed
+752
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# Virtual environment
|
||||
venv/
|
||||
.env/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Checkpoints and trained models
|
||||
checkpoints/
|
||||
*.pt
|
||||
*.pth
|
||||
*.onnx
|
||||
|
||||
# Dataset (too large for git)
|
||||
data/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,83 @@
|
||||
# actpred — 动作预测模块
|
||||
|
||||
输入摄像头图像 + 历史动作序列,预测下一个连续动作(回归)。
|
||||
|
||||
纯神经网络实现(PyTorch),支持轻量 CNN 或 MobileNetV2 图像编码,MLP/GRU 动作历史编码。总参数量 ~140K(默认)至 ~2.3M。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
图像 (3×64×64) ──► CNN 编码器 ──► img_feat (128)
|
||||
│
|
||||
├─ concat ──► 融合头 ──► 预测动作 (action_dim)
|
||||
│
|
||||
动作历史 (H×D) ──► MLP/GRU ──► act_feat (64)
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
cd actpred && bash _set.sh
|
||||
|
||||
# 训练
|
||||
bash _run.sh train --data-dir ./data --epochs 100 --action-dim 8
|
||||
|
||||
# 推理
|
||||
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"
|
||||
|
||||
# 导出 ONNX
|
||||
bash _run.sh export --model checkpoints/best_inference.pt --output model.onnx
|
||||
```
|
||||
|
||||
## 数据格式
|
||||
|
||||
```
|
||||
data/
|
||||
episode_001/
|
||||
images/
|
||||
0000.jpg
|
||||
0001.jpg
|
||||
...
|
||||
actions.npy # shape: (T, action_dim) float32
|
||||
episode_002/
|
||||
...
|
||||
```
|
||||
|
||||
每个 timestep 的样本:当前图像 + 前 `history_len` 步动作 → 当前步动作。
|
||||
|
||||
### 数据采集
|
||||
|
||||
```python
|
||||
from dataset import DataRecorder
|
||||
|
||||
recorder = DataRecorder("./data", action_dim=8)
|
||||
for image, action in robot_loop():
|
||||
recorder.record(image, action)
|
||||
recorder.flush()
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
所有超参数通过 CLI 参数覆盖,默认值见 `config.py`:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--image-size` | 64 | 图像缩放尺寸 |
|
||||
| `--action-dim` | 8 | 动作向量维度 |
|
||||
| `--history-len` | 5 | 历史动作窗口长度 |
|
||||
| `--backbone` | simple | CNN 类型 (simple/mobilenet) |
|
||||
| `--history-encoder` | mlp | 历史编码器 (mlp/gru) |
|
||||
| `--epochs` | 100 | 训练轮数 |
|
||||
| `--lr` | 0.001 | 学习率 |
|
||||
| `--batch-size` | 32 | 批次大小 |
|
||||
|
||||
## 编程 API
|
||||
|
||||
```python
|
||||
from predict import ActionPredictorInference
|
||||
|
||||
predictor = ActionPredictorInference("checkpoints/best_inference.pt")
|
||||
action = predictor.predict(image_rgb, action_history) # np.ndarray (action_dim,)
|
||||
```
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
source ./venv/bin/activate
|
||||
|
||||
if [ "${1:-}" = "train" ]; then
|
||||
shift
|
||||
exec python3 train.py "$@"
|
||||
elif [ "${1:-}" = "export" ]; then
|
||||
shift
|
||||
exec python3 export_onnx.py "$@"
|
||||
else
|
||||
exec python3 predict.py "$@"
|
||||
fi
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# CPU-only PyTorch (no CUDA dependency)
|
||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# Remaining deps from Tsinghua mirror
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
mkdir -p checkpoints data
|
||||
@@ -0,0 +1,110 @@
|
||||
import argparse
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataConfig:
|
||||
image_size: int = 64
|
||||
action_dim: int = 8
|
||||
history_len: int = 5
|
||||
data_dir: str = "./data"
|
||||
normalize_actions: bool = False
|
||||
action_stats_path: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
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"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainConfig:
|
||||
batch_size: int = 32
|
||||
epochs: int = 100
|
||||
lr: float = 1e-3
|
||||
weight_decay: float = 1e-4
|
||||
val_split: float = 0.15
|
||||
checkpoint_dir: str = "./checkpoints"
|
||||
patience: int = 15
|
||||
num_workers: int = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
data: DataConfig = field(default_factory=DataConfig)
|
||||
model: ModelConfig = field(default_factory=ModelConfig)
|
||||
train: TrainConfig = field(default_factory=TrainConfig)
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
return cls(
|
||||
data=DataConfig(**d.get("data", {})),
|
||||
model=ModelConfig(**d.get("model", {})),
|
||||
train=TrainConfig(**d.get("train", {})),
|
||||
)
|
||||
|
||||
|
||||
def build_argparser(description: str = "") -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
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("--data-dir", type=str, default=None)
|
||||
parser.add_argument("--normalize-actions", action="store_true", 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("--batch-size", type=int, default=None)
|
||||
parser.add_argument("--epochs", type=int, default=None)
|
||||
parser.add_argument("--lr", type=float, default=None)
|
||||
parser.add_argument("--weight-decay", type=float, default=None)
|
||||
parser.add_argument("--val-split", type=float, default=None)
|
||||
parser.add_argument("--checkpoint-dir", type=str, default=None)
|
||||
parser.add_argument("--patience", type=int, default=None)
|
||||
parser.add_argument("--num-workers", type=int, default=None)
|
||||
parser.add_argument("--save-config", type=str, default=None, help="保存配置到 JSON 文件")
|
||||
return parser
|
||||
|
||||
|
||||
def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config:
|
||||
mapping = {
|
||||
"image_size": ("data", "image_size"),
|
||||
"action_dim": ("data", "action_dim"),
|
||||
"history_len": ("data", "history_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"),
|
||||
"batch_size": ("train", "batch_size"),
|
||||
"epochs": ("train", "epochs"),
|
||||
"lr": ("train", "lr"),
|
||||
"weight_decay": ("train", "weight_decay"),
|
||||
"val_split": ("train", "val_split"),
|
||||
"checkpoint_dir": ("train", "checkpoint_dir"),
|
||||
"patience": ("train", "patience"),
|
||||
"num_workers": ("train", "num_workers"),
|
||||
}
|
||||
args_dict = vars(args)
|
||||
for arg_name, (section, field) in mapping.items():
|
||||
val = args_dict.get(arg_name)
|
||||
if val is not None:
|
||||
setattr(getattr(cfg, section), field, val)
|
||||
return cfg
|
||||
@@ -0,0 +1,104 @@
|
||||
import os
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
import torch
|
||||
from torch.utils.data import Dataset
|
||||
from torchvision import transforms
|
||||
|
||||
|
||||
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):
|
||||
self.history_len = history_len
|
||||
self.action_dim = action_dim
|
||||
self.image_size = image_size
|
||||
|
||||
self.transform = transforms.Compose([
|
||||
transforms.Resize((image_size, image_size)),
|
||||
transforms.ToTensor(),
|
||||
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()])
|
||||
if episode_indices is not None:
|
||||
episodes = [episodes[i] for i in episode_indices]
|
||||
|
||||
self.samples = []
|
||||
for ep_dir in episodes:
|
||||
actions_path = ep_dir / "actions.npy"
|
||||
images_dir = ep_dir / "images"
|
||||
if not actions_path.exists() or not images_dir.is_dir():
|
||||
continue
|
||||
actions = np.load(actions_path).astype(np.float32)
|
||||
n_frames = len(actions)
|
||||
image_files = sorted(images_dir.glob("*.jpg")) + sorted(images_dir.glob("*.png"))
|
||||
n_images = len(image_files)
|
||||
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]))
|
||||
|
||||
self._action_mean = None
|
||||
self._action_std = None
|
||||
if normalize_actions:
|
||||
if action_stats_path and os.path.exists(action_stats_path):
|
||||
stats = np.load(action_stats_path)
|
||||
self._action_mean = stats[0]
|
||||
self._action_std = stats[1]
|
||||
else:
|
||||
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
|
||||
|
||||
def __len__(self):
|
||||
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)
|
||||
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}
|
||||
|
||||
def get_action_stats(self):
|
||||
if self._action_mean is not None:
|
||||
return np.stack([self._action_mean, self._action_std])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_episode_count(data_dir: str) -> int:
|
||||
data_path = Path(data_dir)
|
||||
return len([d for d in data_path.iterdir() if d.is_dir()])
|
||||
|
||||
|
||||
class DataRecorder:
|
||||
def __init__(self, output_dir: str, action_dim: int):
|
||||
self.output_dir = Path(output_dir)
|
||||
self.action_dim = action_dim
|
||||
self._frame_idx = 0
|
||||
self._actions = []
|
||||
ep_idx = 0
|
||||
while (self.output_dir / f"episode_{ep_idx:04d}").exists():
|
||||
ep_idx += 1
|
||||
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)
|
||||
|
||||
def record(self, image: np.ndarray, action: np.ndarray):
|
||||
img = Image.fromarray(image)
|
||||
img.save(self.images_dir / f"{self._frame_idx:04d}.jpg", quality=90)
|
||||
self._actions.append(action.astype(np.float32))
|
||||
self._frame_idx += 1
|
||||
|
||||
def flush(self):
|
||||
if self._actions:
|
||||
actions = np.array(self._actions, dtype=np.float32)
|
||||
np.save(self.episode_dir / "actions.npy", actions)
|
||||
return str(self.episode_dir)
|
||||
@@ -0,0 +1,44 @@
|
||||
import argparse
|
||||
import torch
|
||||
from config import DataConfig, ModelConfig
|
||||
from model import ActionPredictor
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Export actpred model to ONNX")
|
||||
parser.add_argument("--model", required=True, help="checkpoint path (best_inference.pt)")
|
||||
parser.add_argument("--output", default="model.onnx", help="output ONNX file path")
|
||||
args = parser.parse_args()
|
||||
|
||||
checkpoint = torch.load(args.model, map_location="cpu", weights_only=True)
|
||||
cfg_dict = checkpoint["config"]
|
||||
data_cfg = DataConfig(**cfg_dict["data"])
|
||||
model_cfg = ModelConfig(**cfg_dict["model"])
|
||||
|
||||
model = ActionPredictor(model_cfg, data_cfg)
|
||||
model.load_state_dict(checkpoint["model_state_dict"])
|
||||
model.eval()
|
||||
|
||||
dummy_image = torch.randn(1, 3, data_cfg.image_size, data_cfg.image_size)
|
||||
if model_cfg.history_encoder == "gru":
|
||||
dummy_history = torch.randn(1, data_cfg.history_len, data_cfg.action_dim)
|
||||
else:
|
||||
dummy_history = torch.randn(1, data_cfg.history_len, data_cfg.action_dim)
|
||||
|
||||
torch.onnx.export(
|
||||
model, (dummy_image, dummy_history),
|
||||
args.output,
|
||||
input_names=["image", "action_history"],
|
||||
output_names=["predicted_action"],
|
||||
dynamic_axes={
|
||||
"image": {0: "batch"},
|
||||
"action_history": {0: "batch"},
|
||||
"predicted_action": {0: "batch"},
|
||||
},
|
||||
opset_version=17,
|
||||
)
|
||||
print(f"ONNX model exported to: {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,117 @@
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from config import DataConfig, ModelConfig
|
||||
|
||||
|
||||
class SimpleCNN(nn.Module):
|
||||
def __init__(self, feature_dim: int = 128):
|
||||
super().__init__()
|
||||
self.net = nn.Sequential(
|
||||
nn.Conv2d(3, 16, 3, stride=2, padding=1),
|
||||
nn.BatchNorm2d(16),
|
||||
nn.ReLU(inplace=True),
|
||||
nn.Conv2d(16, 32, 3, stride=2, padding=1),
|
||||
nn.BatchNorm2d(32),
|
||||
nn.ReLU(inplace=True),
|
||||
nn.Conv2d(32, 64, 3, stride=2, padding=1),
|
||||
nn.BatchNorm2d(64),
|
||||
nn.ReLU(inplace=True),
|
||||
nn.Conv2d(64, feature_dim, 3, stride=2, padding=1),
|
||||
nn.BatchNorm2d(feature_dim),
|
||||
nn.ReLU(inplace=True),
|
||||
nn.AdaptiveAvgPool2d(1),
|
||||
)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return self.net(x).flatten(1)
|
||||
|
||||
|
||||
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)
|
||||
self.features = backbone.features
|
||||
self.pool = nn.AdaptiveAvgPool2d(1)
|
||||
self.proj = nn.Linear(1280, feature_dim)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
x = self.features(x)
|
||||
x = self.pool(x).flatten(1)
|
||||
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):
|
||||
def __init__(self, input_dim: int, hidden_dim: int, output_dim: int, 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 // 2),
|
||||
nn.ReLU(inplace=True),
|
||||
nn.Linear(hidden_dim // 2, output_dim),
|
||||
)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return self.net(x)
|
||||
|
||||
|
||||
class ActionPredictor(nn.Module):
|
||||
def __init__(self, model_cfg: ModelConfig, data_cfg: DataConfig):
|
||||
super().__init__()
|
||||
if model_cfg.cnn_backbone == "mobilenet":
|
||||
self.image_encoder = MobileNetEncoder(model_cfg.cnn_feature_dim)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
self._use_gru = model_cfg.history_encoder == "gru"
|
||||
self._history_len = data_cfg.history_len
|
||||
self._action_dim = data_cfg.action_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))
|
||||
@@ -0,0 +1,94 @@
|
||||
import argparse
|
||||
import json
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torchvision import transforms
|
||||
|
||||
from config import Config, ModelConfig, DataConfig
|
||||
from model import ActionPredictor
|
||||
|
||||
|
||||
class ActionPredictorInference:
|
||||
def __init__(self, checkpoint_path: str, device: str = None):
|
||||
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
|
||||
cfg_dict = checkpoint["config"]
|
||||
self._data_cfg = DataConfig(**cfg_dict["data"])
|
||||
self._model_cfg = ModelConfig(**cfg_dict["model"])
|
||||
|
||||
if device is None:
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
self._device = torch.device(device)
|
||||
|
||||
self._model = ActionPredictor(self._model_cfg, self._data_cfg).to(self._device)
|
||||
self._model.load_state_dict(checkpoint["model_state_dict"])
|
||||
self._model.eval()
|
||||
|
||||
self._transform = transforms.Compose([
|
||||
transforms.Resize((self._data_cfg.image_size, self._data_cfg.image_size)),
|
||||
transforms.ToTensor(),
|
||||
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
|
||||
])
|
||||
|
||||
self._action_mean = None
|
||||
self._action_std = None
|
||||
stats_path = checkpoint_path.rsplit("/", 1)[0] + "/action_stats.npy"
|
||||
try:
|
||||
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:
|
||||
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)
|
||||
if self._action_mean is not None:
|
||||
history = (history - self._action_mean) / self._action_std
|
||||
history = history.unsqueeze(0).to(self._device)
|
||||
pred = self._model(img_tensor, history).squeeze(0).cpu().numpy()
|
||||
if self._action_mean is not None:
|
||||
pred = pred * self._action_std + self._action_mean
|
||||
return pred
|
||||
|
||||
|
||||
def parse_action_history(s: str, action_dim: int) -> np.ndarray:
|
||||
steps = s.split(":")
|
||||
history = []
|
||||
for step in steps:
|
||||
values = [float(v) for v in step.split(",")]
|
||||
assert len(values) == action_dim, \
|
||||
f"Expected {action_dim} values per step, got {len(values)}"
|
||||
history.append(values)
|
||||
return np.array(history, dtype=np.float32)
|
||||
|
||||
|
||||
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("--action-history", required=True,
|
||||
help="colon-separated action vectors, e.g. '45,0,45,0:45,10,45,10'")
|
||||
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")
|
||||
parser.add_argument("--device", type=str, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
predictor = ActionPredictorInference(args.model, args.device)
|
||||
action_dim = args.action_dim or predictor._data_cfg.action_dim
|
||||
|
||||
image = Image.open(args.image).convert("RGB")
|
||||
history = parse_action_history(args.action_history, action_dim)
|
||||
predicted = predictor.predict(np.array(image), history)
|
||||
|
||||
if args.output_format == "json":
|
||||
print(json.dumps({"predicted_action": predicted.tolist()}, indent=2))
|
||||
else:
|
||||
print(" ".join(f"{v:.4f}" for v in predicted))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
# Core
|
||||
torch>=2.0
|
||||
torchvision>=0.15
|
||||
numpy>=1.24
|
||||
Pillow>=9.0
|
||||
|
||||
# Training utilities
|
||||
tqdm>=4.60
|
||||
@@ -0,0 +1,143 @@
|
||||
import json
|
||||
import os
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch.utils.data import DataLoader, Subset
|
||||
from tqdm import tqdm
|
||||
|
||||
from config import Config, build_argparser, apply_overrides
|
||||
from model import ActionPredictor
|
||||
from dataset import ActionDataset
|
||||
|
||||
|
||||
def split_episodes(data_dir: str, val_ratio: float, seed: int = 42):
|
||||
n = ActionDataset.get_episode_count(data_dir)
|
||||
indices = list(range(n))
|
||||
rng = np.random.RandomState(seed)
|
||||
rng.shuffle(indices)
|
||||
val_count = max(1, int(n * val_ratio))
|
||||
return indices[val_count:], indices[:val_count]
|
||||
|
||||
|
||||
def train_one_epoch(model, loader, optimizer, device):
|
||||
model.train()
|
||||
total_loss = 0.0
|
||||
count = 0
|
||||
for batch in tqdm(loader, desc=" train", leave=False):
|
||||
image = batch["image"].to(device)
|
||||
history = batch["action_history"].to(device)
|
||||
target = batch["target"].to(device)
|
||||
optimizer.zero_grad()
|
||||
pred = model(image, history)
|
||||
loss = torch.nn.functional.mse_loss(pred, target)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
total_loss += loss.item() * image.size(0)
|
||||
count += image.size(0)
|
||||
return total_loss / max(count, 1)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def validate(model, loader, device):
|
||||
model.eval()
|
||||
total_loss = 0.0
|
||||
count = 0
|
||||
for batch in tqdm(loader, desc=" val ", leave=False):
|
||||
image = batch["image"].to(device)
|
||||
history = batch["action_history"].to(device)
|
||||
target = batch["target"].to(device)
|
||||
pred = model(image, history)
|
||||
loss = torch.nn.functional.mse_loss(pred, target)
|
||||
total_loss += loss.item() * image.size(0)
|
||||
count += image.size(0)
|
||||
return total_loss / max(count, 1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_argparser("actpred training")
|
||||
args = parser.parse_args()
|
||||
cfg = apply_overrides(Config(), args)
|
||||
|
||||
os.makedirs(cfg.train.checkpoint_dir, exist_ok=True)
|
||||
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
print(f"Device: {device}")
|
||||
|
||||
train_indices, val_indices = split_episodes(
|
||||
cfg.data.data_dir, cfg.train.val_split)
|
||||
print(f"Episodes: {len(train_indices)} train, {len(val_indices)} val")
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
print(f"Samples: {len(train_ds)} train, {len(val_ds)} val")
|
||||
|
||||
train_loader = DataLoader(train_ds, batch_size=cfg.train.batch_size,
|
||||
shuffle=True, num_workers=cfg.train.num_workers)
|
||||
val_loader = DataLoader(val_ds, batch_size=cfg.train.batch_size,
|
||||
shuffle=False, num_workers=cfg.train.num_workers)
|
||||
|
||||
model = ActionPredictor(cfg.model, cfg.data).to(device)
|
||||
n_params = sum(p.numel() for p in model.parameters())
|
||||
print(f"Model params: {n_params:,}")
|
||||
|
||||
optimizer = torch.optim.AdamW(model.parameters(), lr=cfg.train.lr,
|
||||
weight_decay=cfg.train.weight_decay)
|
||||
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=cfg.train.epochs)
|
||||
|
||||
best_val_loss = float("inf")
|
||||
patience_counter = 0
|
||||
best_path = os.path.join(cfg.train.checkpoint_dir, "best.pt")
|
||||
|
||||
for epoch in range(1, cfg.train.epochs + 1):
|
||||
print(f"\nEpoch {epoch}/{cfg.train.epochs} lr={optimizer.param_groups[0]['lr']:.6f}")
|
||||
train_loss = train_one_epoch(model, train_loader, optimizer, device)
|
||||
val_loss = validate(model, val_loader, device)
|
||||
scheduler.step()
|
||||
print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}")
|
||||
|
||||
if val_loss < best_val_loss:
|
||||
best_val_loss = val_loss
|
||||
patience_counter = 0
|
||||
torch.save({
|
||||
"epoch": epoch,
|
||||
"model_state_dict": model.state_dict(),
|
||||
"optimizer_state_dict": optimizer.state_dict(),
|
||||
"val_loss": val_loss,
|
||||
"config": cfg.to_dict(),
|
||||
}, best_path)
|
||||
inference_path = os.path.join(cfg.train.checkpoint_dir, "best_inference.pt")
|
||||
torch.save({
|
||||
"model_state_dict": model.state_dict(),
|
||||
"config": cfg.to_dict(),
|
||||
}, inference_path)
|
||||
print(f" -> saved best (val_loss={val_loss:.6f})")
|
||||
else:
|
||||
patience_counter += 1
|
||||
if patience_counter >= cfg.train.patience:
|
||||
print(f"\nEarly stopping at epoch {epoch} (patience={cfg.train.patience})")
|
||||
break
|
||||
|
||||
print(f"\nTraining done. Best val_loss={best_val_loss:.6f}")
|
||||
print(f"Best model saved to: {best_path}")
|
||||
|
||||
action_stats = train_ds.get_action_stats()
|
||||
if action_stats is not None:
|
||||
stats_path = os.path.join(cfg.train.checkpoint_dir, "action_stats.npy")
|
||||
np.save(stats_path, action_stats)
|
||||
print(f"Action stats saved to: {stats_path}")
|
||||
|
||||
if args.save_config:
|
||||
with open(args.save_config, "w") as f:
|
||||
json.dump(cfg.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
print(f"Config saved to: {args.save_config}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user