138 lines
6.2 KiB
Python
138 lines
6.2 KiB
Python
import argparse
|
||
import json
|
||
import os
|
||
import numpy as np
|
||
import torch
|
||
from PIL import Image
|
||
from torchvision import transforms
|
||
|
||
from config import parse_checkpoint_config
|
||
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 = parse_checkpoint_config(checkpoint["config"])
|
||
self._data_cfg = cfg.data
|
||
self._model_cfg = cfg.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, pretrained=False).to(self._device)
|
||
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.frame_len = self._data_cfg.frame_len
|
||
|
||
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
|
||
if self._data_cfg.normalize_actions:
|
||
# 优先使用 checkpoint config 记录的 stats 路径(相对路径按 checkpoint 目录解析),
|
||
# 旧 checkpoint 无该字段时回退到 checkpoint 同目录的 action_stats.npy
|
||
stats_path = self._data_cfg.action_stats_path
|
||
if stats_path:
|
||
if not os.path.isabs(stats_path):
|
||
stats_path = os.path.join(
|
||
os.path.dirname(os.path.abspath(checkpoint_path)), stats_path)
|
||
else:
|
||
stats_path = os.path.join(
|
||
os.path.dirname(os.path.abspath(checkpoint_path)),
|
||
"action_stats.npy")
|
||
if not os.path.exists(stats_path):
|
||
raise FileNotFoundError(
|
||
f"模型训练时启用了动作归一化,但未找到统计文件: {stats_path}")
|
||
stats = np.load(stats_path)
|
||
if stats.shape != (2, self._data_cfg.action_dim):
|
||
raise ValueError(
|
||
f"action_stats.npy 形状应为 (2, {self._data_cfg.action_dim}),"
|
||
f"实际为 {stats.shape},请检查文件: {stats_path}")
|
||
self._action_mean = stats[0].astype(np.float32)
|
||
self._action_std = stats[1].astype(np.float32)
|
||
|
||
@torch.no_grad()
|
||
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}")
|
||
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
|
||
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(",")]
|
||
if len(values) != action_dim:
|
||
raise ValueError(
|
||
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("--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)")
|
||
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.action_dim
|
||
|
||
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(images, 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()
|