1. 项目概述:为什么要把自己的 Planner 塞进 NuPlan?这真不是“为了用而用”
NuPlan 不是玩具,也不是教学演示工具——它是一套工业级自动驾驶规划算法的“压力测试场”。我第一次在某车企智驾团队看到他们用 NuPlan 跑一个新 planner 的回归测试时,整个 pipeline 每天要调度 2000+ 个真实接管场景(包括暴雨夜路、无标线施工区、鬼探头式行人横穿),每个场景跑 5 种 ego vehicle 动态参数组合,最后生成 37 项量化指标报告。那一刻我就明白:所谓“把自己的 Planner 塞进去”,本质是让算法从“能跑通”迈向“敢上车”的临门一脚。
核心关键词NuPlan、Planner、仿真框架、AbstractPlanner、Hydra在这里不是孤立术语,而是构成一套验证闭环的齿轮:
- NuPlan是底盘——提供统一的场景加载器、传感器模拟器、动力学模型、评估器;
- Planner是你要验证的“心脏”,但必须按 NuPlan 的契约交付,不能随心所欲输出轨迹;
- 仿真框架不是黑盒,它强制你暴露 planner 的所有输入依赖(如是否依赖高精地图、是否调用预测模块);
- AbstractPlanner是接口契约——它定义了“一个合格 planner 必须长什么样”,比如必须实现
compute_trajectory()方法,必须返回Trajectory对象,必须在 100ms 内完成计算; - Hydra是配置中枢——它让你不用改一行代码就能切换训练数据集、仿真地图、ego vehicle 参数、甚至 planner 的超参组合,这才是工业级可复现性的根基。
这个项目标题里藏着三个被很多人忽略的真实需求:
第一,不是“接入”,而是“对齐”——你的 planner 可能用 PyTorch 写,用 ROS 发布,用自定义坐标系,但 NuPlan 强制你把它“翻译”成 nuPlan 定义的EgoState、ObservedTrafficObjects、MapAPI接口。这个过程暴露出你算法中隐藏的耦合点(比如硬编码了某张 HD Map 的 CRS 坐标系);
第二,不是“跑起来就行”,而是“跑得准、跑得稳、跑得可比”——NuPlan 的Scenario对象封装了精确到毫秒的时间戳、厘米级的定位真值、带置信度的障碍物轨迹,你 planner 输出的每一条轨迹都会被严格比对ego_progress,offroad_rate,collision_rate等 23 项指标,这些数字直接决定算法能否进入实车路测清单;
第三,不是“单次验证”,而是“持续回归”——当你用 Hydra 配置好nuplan-devkit的nuplan-planning子模块后,每次 git push 都能自动触发 CI 流水线,在 4 个不同城市区域的 1000+ 场景上跑全量回归,失败项会精准定位到具体场景 ID 和 metric 偏差值。这才是现代自动驾驶研发的真实节奏。
适合谁来读?如果你是高校研究者,正为 ICRA 论文补实验,需要快速验证新 planner 在 nuPlan 标准 benchmark 上的表现;如果你是初创公司算法工程师,手头有自研的 mission planner,但客户要求提供 nuPlan 兼容性报告;如果你是 Tier1 工程师,正在把量产版 planner 迁移到新平台,需要一套不依赖实车的离线验证基线——那么这篇内容就是你跳过 3 周踩坑时间、直奔核心接口的实操手册。它不讲 NuPlan 架构图,不堆概念,只告诉你:哪几行代码必须改、哪几个配置文件必须动、哪几个报错意味着你的 planner 还没真正“活”在 NuPlan 里。
2. 整体设计与思路拆解:为什么必须从 AbstractPlanner 继承?绕不开的三大设计约束
把 planner 塞进 NuPlan,最常见错误是试图“绕过接口”——比如直接修改nuplan-planning的run_simulation.py,把你的 planner 函数硬塞进主循环;或者写个 wrapper 脚本,把 NuPlan 输出的scenario转成 JSON,再喂给自己的 planner 服务。这两种方式在本地 demo 阶段看似可行,但一旦进入团队协作或 CI 流水线,立刻崩盘。原因在于 NuPlan 的设计哲学有三个不可妥协的硬约束,它们共同决定了你必须老老实实继承AbstractPlanner:
2.1 约束一:状态一致性(State Consistency)——你的 planner 不能“失忆”
NuPlan 的仿真引擎是状态驱动的。它每推进一个 timestep(默认 0.1s),就会调用一次 planner 的compute_trajectory()方法,并传入当前完整的EgoState(含位置、速度、加速度、朝向角、角速度)和ObservedTrafficObjects(含所有障碍物的 6DoF 状态及不确定性协方差)。关键点在于:NuPlan 不保证连续两次调用之间你的 planner 实例是同一个对象。在分布式仿真中,一个 scenario 可能被切分成多个 chunk,由不同 worker 执行,每个 worker 都会新建一个 planner 实例。
如果你的 planner 内部维护了状态(比如基于卡尔曼滤波的 ego motion 估计、历史轨迹缓存、动态障碍物交互建模的 hidden state),而没有通过AbstractPlanner规定的initialize()和cleanup()方法进行显式管理,那么在跨 chunk 或重启仿真时,状态就会丢失,导致轨迹突变。我见过一个团队的 planner 在单场景仿真中完美,但跑 100 场景 batch 时,第 47 场景开始出现周期性抖动——最终定位到是内部一个未持久化的 velocity bias 估计器在 worker 重建时重置为零。
提示:
AbstractPlanner.initialize()是唯一被保证在仿真开始前、且仅执行一次的方法。所有需要初始化的状态(如滤波器、神经网络权重加载、地图缓存)必须放在这里。AbstractPlanner.cleanup()则用于释放资源(如关闭数据库连接、清空 GPU cache),虽然不常被调用,但必须存在。
2.2 约束二:输入标准化(Input Standardization)——NuPlan 不接受“方言”
你的 planner 可能习惯接收ros::sensor_msgs::PointCloud2或numpy.ndarray形式的激光雷达点云,但 NuPlan 的Scenario只提供LidarPc对象,它封装了点云坐标(point_cloud)、时间戳(timestamp_us)、传感器位姿(ego_pose)以及一个MapAPI接口。如果你强行在compute_trajectory()里做类型转换,会遇到两个问题:
- 性能瓶颈:
LidarPc.point_cloud是torch.Tensor,而你的点云处理 pipeline 可能基于open3d或pcl,频繁的 tensor ↔ numpy ↔ open3d.PointCloud 转换会吃掉 30%+ 的计算时间; - 语义丢失:
LidarPc中的ego_pose是相对于全局坐标系的 SE3 变换,而你的 pipeline 可能默认以车辆中心为原点。如果没做正确坐标系对齐,轨迹起点就会偏移 2 米以上。
NuPlan 强制你通过MapAPI获取地图信息,而不是直接读取.osm或.lanelet2文件。MapAPI提供的是抽象后的Lane,LaneConnector,StopLine等对象,每个对象都带有get_roadblock_id(),get_left_boundary(),get_speed_limit_mps()等方法。这意味着你的 planner 无法再依赖“地图文件路径”这种硬编码逻辑,必须学会用 NuPlan 的“地图语言”思考——比如判断是否在路口,不能查lane_id == 'A123',而要调用map_api.get_lane_segment(lane_id).is_intersection()。
2.3 约束三:配置可追溯性(Config Traceability)——Hydra 是你的“审计日志”
这是最容易被忽视,却最影响工程落地的一点。NuPlan 使用 Hydra 1.3+ 管理全部配置,其核心思想是:每一个运行实例的完整配置,必须能从命令行参数或配置文件中 100% 还原。如果你的 planner 有一个config.yaml,里面写了model_path: ./weights/best.pth,那么当这个实验结果被质疑时,你必须能证明:
- 这个
best.pth确实是 commita1b2c3d下训练出来的; - 训练时用的
learning_rate是1e-4,而不是5e-4; - 数据增强开关
use_augmentation是true。
Hydra 通过@hydra.main(config_path="conf", config_name="default")装饰器,将配置树注入到你的 planner 类中。当你继承AbstractPlanner后,NuPlan 会自动将hydra.utils.instantiate(cfg.planner)的结果作为 planner 实例。这意味着你的 planner 类构造函数必须接收一个cfg参数,并从中提取所有超参。我见过太多团队把超参写死在__init__里,结果在 CI 中发现:开发机跑的结果和 CI 服务器跑的结果不一致,排查三天才发现是开发机用了旧版config.yaml,而 CI 用的是 git master 分支的最新版。
注意:Hydra 的配置覆盖规则是
命令行 > config.yaml > defaults/base.yaml。例如python nuplan/planning/script/run_simulation.py ... +planner=my_planner model_path=/data/new_weights.pth,会覆盖config.yaml中的model_path。这个机制让你无需修改代码就能做 A/B 测试。
这三大约束共同指向一个结论:绕过AbstractPlanner的任何尝试,都是在构建一个脆弱、不可复现、无法集成的“纸糊系统”。真正的塞入,是让 planner 的基因里就刻着 NuPlan 的契约——状态可管理、输入可标准化、配置可追溯。接下来的所有实操,都是围绕这三个原则展开的具体实现。
3. 核心细节解析与实操要点:从零创建一个可运行的 MyPlanner
现在我们动手创建一个最小但完全合规的MyPlanner。这不是一个玩具 demo,而是你未来所有 planner 的模板。我会逐行解释每一处设计决策背后的“为什么”,并指出新手最容易栽跟头的细节。
3.1 目录结构与文件组织:为什么必须这样放?
NuPlan 的模块化设计要求你的 planner 必须放在特定路径下,否则 Hydra 无法发现。标准结构如下(假设你的工作区是nuplan-devkit):
nuplan-devkit/ ├── nuplan-planning/ # NuPlan 主代码 │ ├── conf/ # Hydra 配置根目录 │ │ ├── default.yaml # 主配置入口 │ │ └── planner/ # planner 专属配置目录 │ │ ├── my_planner.yaml # 你的 planner 配置 │ │ └── ... │ └── nuplan/planning/ # Python 包根目录 │ └── planner/ # planner 模块 │ ├── __init__.py │ ├── abstract_planner.py # NuPlan 提供的基类 │ └── my_planner.py # 你的实现(重点!) └── my_custom_planner/ # 你的独立代码库(可选) └── models/ # 模型权重、配置等关键点:
my_planner.py必须放在nuplan/planning/planner/下,且必须在__init__.py中声明from .my_planner import MyPlanner;my_planner.yaml必须放在conf/planner/下,且文件名必须与类名小写一致(MyPlanner→my_planner.yaml);- 如果你的 planner 依赖外部库(如
my_custom_models),必须确保该库已安装(pip install -e ./my_custom_planner),且my_planner.py中的import语句能正确解析。
提示:不要把 planner 代码放在
nuplan-devkit外部然后用sys.path.append()。Hydra 的插件发现机制依赖于 Python 包路径,外部路径会导致配置无法加载。
3.2 MyPlanner 类的核心骨架:四行代码定生死
以下是my_planner.py的最小可行骨架,我逐行拆解:
# nuplan/planning/planner/my_planner.py from typing import Any, List, Optional import torch from nuplan.planning.scenario_builder.nuplan.nuplan_dataset import NuPlanDataset from nuplan.planning.simulation.planner.abstract_planner import AbstractPlanner from nuplan.planning.simulation.trajectory.trajectory import Trajectory from nuplan.planning.simulation.observation.observation_type import DetectionsTracks from nuplan.planning.simulation.planner.ml_planner.transform_utils import transform_predictions_to_states from nuplan.planning.simulation.trajectory.interpolated_trajectory import InterpolatedTrajectory class MyPlanner(AbstractPlanner): """一个符合 NuPlan 规范的自定义 planner 示例。""" def __init__(self, cfg: Any) -> None: """ 初始化 planner。 :param cfg: Hydra 配置对象,包含所有超参。 """ super().__init__() # 必须调用父类初始化! self.cfg = cfg # 保存配置,后续所有参数从此读取 self.device = torch.device(cfg.device if hasattr(cfg, 'device') else 'cpu') # 加载模型权重(示例) self.model = self._load_model(cfg.model_path) self.model.to(self.device) self.model.eval() # 关键!必须设为 eval 模式,否则 BatchNorm/ Dropout 行为异常 def name(self) -> str: """返回 planner 名称,用于日志和报告。""" return "MyPlanner" def initialize(self) -> None: """仿真开始前调用一次。用于状态初始化。""" # 这里可以初始化 KF 滤波器、清空历史 buffer 等 self._reset_state() def _reset_state(self) -> None: """重置内部状态。必须是幂等操作。""" self._history_buffer = [] # 示例:存储最近 N 帧的 ego state self._kf_state = None # 示例:卡尔曼滤波器状态 def compute_trajectory(self, current_input: Any) -> Trajectory: """ NuPlan 核心调用方法。必须返回 Trajectory 对象。 :param current_input: NuPlan 提供的当前 timestep 输入,类型为 SimulationHistoryBuffer。 :return: 描述未来 8 秒轨迹的 Trajectory 对象。 """ # 1. 从 current_input 提取关键信息 ego_state = current_input.ego_state # EgoState 对象 detections = current_input.observation # DetectionsTracks 对象 map_api = current_input.map_api # MapAPI 对象 # 2. 构建模型输入(关键:坐标系对齐!) # NuPlan 的 ego_state.position 是 (x, y) 在全局坐标系,但你的模型可能期望车辆坐标系 # 正确做法:用 ego_state 转换所有坐标 local_detections = self._transform_to_ego_frame(detections, ego_state) # 3. 模型推理 with torch.no_grad(): # 必须!避免梯度计算浪费内存 predictions = self.model( ego_state=ego_state, detections=local_detections, map_api=map_api, device=self.device ) # 4. 将模型输出转换为 NuPlan 要求的 Trajectory 格式 # predictions 可能是 [x, y, v, a, heading] 序列,需插值为固定频率(如 10Hz) trajectory_states = transform_predictions_to_states( predictions, ego_state, self.cfg.trajectory_horizon_s, # 8.0 self.cfg.trajectory_interval_s # 0.1 ) return InterpolatedTrajectory(trajectory_states) def _transform_to_ego_frame(self, detections: DetectionsTracks, ego_state: Any) -> Any: """将检测结果从全局坐标系转换到车辆坐标系。""" # 实现细节略,核心是使用 ego_state.rear_axle 作为原点,ego_state.orientation 作为旋转 # 必须处理 detections.objects 的每个 object 的 position、velocity、acceleration pass def _load_model(self, model_path: str) -> torch.nn.Module: """加载模型权重。""" # 示例:支持 .pth 和 .onnx if model_path.endswith('.onnx'): import onnxruntime as ort return ort.InferenceSession(model_path) else: model = MyModel() # 你的模型类 model.load_state_dict(torch.load(model_path, map_location='cpu')) return model def cleanup(self) -> None: """清理资源。""" # 例如:删除临时文件、关闭 ONNX session pass致命细节解析:
super().__init__()这一行绝不能省略。AbstractPlanner.__init__()内部设置了_initialized标志位,如果没调用,initialize()方法永远不会被执行;self.model.eval()是血泪教训。我在一个项目中发现 planner 在仿真中轨迹突然发散,最终定位到是模型中的 Dropout 层在train()模式下随机置零,导致每帧输出不稳定;transform_predictions_to_states()是 NuPlan 提供的工具函数,它负责将模型输出的离散点序列(如 50 个点)插值为InterpolatedTrajectory所需的State对象列表(每个State包含position,velocity,acceleration,heading等)。不要自己手写插值!NuPlan 的评估器(NUPlanMetric)严格依赖这个函数生成的State时间戳精度;current_input的类型是SimulationHistoryBuffer,它不是一个简单的字典,而是一个包含ego_state,observation,map_api,mission_goal等属性的对象。直接print(current_input)只能看到<nuplan.planning.simulation.history.simulation_history_buffer.SimulationHistoryBuffer object>,必须用dir(current_input)查看可用属性。
3.3 Hydra 配置文件:my_planner.yaml 的 7 个必填字段
conf/planner/my_planner.yaml是你的 planner 的“身份证”,它告诉 NuPlan 如何实例化你。一个健壮的配置至少包含以下 7 个字段:
# conf/planner/my_planner.yaml _target_: nuplan.planning.planner.my_planner.MyPlanner # 必填!完整类路径 name: MyPlanner # 必填!必须与类名一致 device: cuda:0 # 可选,但强烈建议指定 model_path: /path/to/your/model.pth # 必填!模型权重路径 trajectory_horizon_s: 8.0 # 必填!规划时域(秒) trajectory_interval_s: 0.1 # 必填!轨迹点间隔(秒) # 以下为自定义超参,你的 __init__ 会用到 use_map_features: true max_num_objects: 32为什么_target_是灵魂?
Hydra 的instantiate()函数通过_target_字符串动态导入类并创建实例。如果写成_target_: MyPlanner(缺少包路径),Hydra 会报ImportError: No module named 'MyPlanner'。正确的写法必须是nuplan.planning.planner.my_planner.MyPlanner,即包路径.模块名.类名。
name字段的双重作用:
- 在日志中标识 planner(如
[MyPlanner] Computing trajectory...); - 在评估报告中作为指标前缀(如
MyPlanner/collision_rate)。如果name和_target_中的类名不一致,会导致报告混乱。
trajectory_*参数的物理意义:
trajectory_horizon_s: 8.0意味着你的 planner 必须输出未来 8 秒的轨迹;trajectory_interval_s: 0.1意味着轨迹点时间间隔为 0.1 秒,即总共 80 个点。NuPlan 的InterpolatedTrajectory会严格按此频率采样。如果你的模型输出 50 个点,transform_predictions_to_states()会自动线性插值补足;如果你输出 100 个点,它会降采样。但强烈建议模型输出点数与 80 严格匹配,避免插值引入误差。
注意:所有字段名必须与你在
MyPlanner.__init__中self.cfg.xxx读取的名称完全一致。Hydra 会将 YAML 中的键值对直接映射为cfg对象的属性。
4. 实操过程与核心环节实现:从本地调试到 CI 流水线部署
现在我们把MyPlanner从代码变成可运行的 pipeline。这个过程分为四个递进阶段:本地单场景调试、批量场景验证、CI 流水线集成、生产环境部署。每个阶段都有其独特的陷阱和技巧。
4.1 阶段一:本地单场景调试——用debug模式揪出所有“假阳性”
不要一上来就跑 full dataset。先用 NuPlan 提供的 debug 场景,确保你的 planner 能在 10 秒内完成一次完整推理。命令如下:
# 进入 nuplan-planning 目录 cd nuplan-devkit/nuplan-planning # 运行单场景 debug(使用预下载的 mini dataset) python nuplan/planning/script/run_simulation.py \ --sim_cfg_path conf/simulation/default.yaml \ --planner_cfg_path conf/planner/my_planner.yaml \ --scenarios_per_batch 1 \ --num_workers 1 \ --debug True \ --output_dir ./outputs/debug_my_planner \ --job_name debug_my_planner--debug True是救命开关:
它会启用以下功能:
- 在
compute_trajectory()开头插入breakpoint(),让你可以用 VS Code 或 pdb 逐行调试; - 将
current_input的所有属性(ego_state,detections,map_api)以.pkl格式保存到./outputs/debug_my_planner/debug_inputs/,方便离线分析; - 禁用多进程,所有逻辑在主线程执行,避免
multiprocessing导致的pickle错误。
调试时必查的 5 个检查点:
- 输入维度检查:打开
debug_inputs/00000.pkl,用pickle.load()读取,检查detections.objects的数量是否合理(通常 5~20 个),ego_state.position是否在预期范围内(如(x=123.45, y=67.89)); - 坐标系对齐检查:在
_transform_to_ego_frame()中打印ego_state.rear_axle.x, ego_state.rear_axle.y和第一个障碍物的object.center.x, object.center.y,确认转换后障碍物坐标是否在(-50, 50)范围内(车辆坐标系); - 模型输入检查:在
self.model(...)调用前,打印predictions.shape,确认是否为(80, 5)(80 个点,每个点 x,y,v,a,heading); - Trajectory 构造检查:在
return InterpolatedTrajectory(...)前,打印len(trajectory_states),必须等于 80; - 时间消耗检查:在
compute_trajectory()开头和结尾加time.time(),计算耗时。如果超过 100ms,说明模型太重或预处理有瓶颈。
实操心得:我曾在一个项目中发现
map_api的get_all_lanes_in_radius()调用耗时 80ms。解决方案是将其移到initialize()中预计算并缓存,compute_trajectory()中只做 O(1) 查表。
40.2 阶段二:批量场景验证——用nuplan-metrics量化你的 planner
单场景跑通只是开始。真正的考验是 1000+ 场景的批量验证。命令如下:
# 下载 nuPlan 官方 validation dataset(约 20GB) nuplan download --dataset-version nuplan-v1.1 --data-root ./nuplan-data # 运行批量仿真(使用 4 个 worker 并行) python nuplan/planning/script/run_simulation.py \ --sim_cfg_path conf/simulation/default.yaml \ --planner_cfg_path conf/planner/my_planner.yaml \ --scenarios_per_batch 10 \ --num_workers 4 \ --output_dir ./outputs/batch_my_planner \ --job_name batch_my_planner \ --scenario_filter_type scenario_types \ --scenario_types ["ego_mission", "ego_lane_following"] \ --data_root ./nuplan-data \ --version nuplan-v1.1关键参数解析:
--scenarios_per_batch 10:每个 worker 一次处理 10 个场景,减少进程启动开销;--scenario_filter_type scenario_types:只运行指定类型的场景,避免在ego_parking这类复杂场景上卡住;--data_root和--version:必须与你下载的数据集路径和版本严格匹配,否则报ScenarioNotFound。
生成评估报告:
批量仿真完成后,运行评估脚本:
python nuplan/planning/script/run_nuplan_evaluation.py \ --simulation_folder ./outputs/batch_my_planner \ --output_dir ./outputs/evaluation_my_planner \ --metrics_csv ./outputs/evaluation_my_planner/metrics.csv这会生成metrics.csv,其中包含 23 项核心指标。重点关注:
| Metric | 合格线 | 说明 |
|---|---|---|
ego_progress | > 95% | 规划轨迹覆盖的场景里程占比,低于 95% 说明 planner 经常“停摆” |
offroad_rate | < 0.5% | 车辆驶出可行驶区域的比例,高于 0.5% 说明地图理解或约束建模有问题 |
collision_rate | 0.0% | 绝对红线!任何非零值都意味着 planner 有致命缺陷 |
comfort_acceleration | < 3.0 m/s² | 加速度绝对值均值,过高说明运动平滑性差 |
解读报告的技巧:
不要只看平均值。用pandas读取metrics.csv,按scenario_id分组,找出collision_rate > 0的具体场景 ID,然后用nuplan/planning/script/visualize_simulation.py可视化该场景:
python nuplan/planning/script/visualize_simulation.py \ --simulation_path ./outputs/batch_my_planner/scenario_12345 \ --output_dir ./outputs/vis_scenario_12345这会生成 HTML 动画,你可以逐帧查看碰撞发生前 3 秒 planner 的轨迹预测、障碍物检测框、地图车道线,精准定位 bug。
4.3 阶段三:CI 流水线集成——用 GitHub Actions 实现一键回归
将验证流程自动化是工程落地的关键。以下是一个精简的.github/workflows/nuplan-ci.yml示例:
name: NuPlan Regression Test on: push: branches: [main] paths: - 'nuplan/planning/planner/my_planner.py' - 'conf/planner/my_planner.yaml' jobs: nuplan-test: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install dependencies run: | pip install -e ./nuplan-devkit/nuplan-planning pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 - name: Download mini dataset (for fast test) run: nuplan download --dataset-version nuplan-v1.0-mini --data-root ./nuplan-data - name: Run simulation (10 scenarios) run: | python nuplan/planning/script/run_simulation.py \ --sim_cfg_path conf/simulation/default.yaml \ --planner_cfg_path conf/planner/my_planner.yaml \ --scenarios_per_batch 5 \ --num_workers 2 \ --output_dir ./outputs/ci_test \ --job_name ci_test \ --data_root ./nuplan-data \ --version nuplan-v1.0-mini - name: Run evaluation run: | python nuplan/planning/script/run_nuplan_evaluation.py \ --simulation_folder ./outputs/ci_test \ --output_dir ./outputs/ci_evaluation - name: Upload artifacts uses: actions/upload-artifact@v3 with: name: nuplan-ci-report path: ./outputs/ci_evaluation/CI 设计的 3 个经验:
- 数据集选择:CI 中永远用
nuplan-v1.0-mini(约 200MB),而不是 full dataset。它包含 100 个精选场景,能在 5 分钟内完成测试,保证反馈速度; - 失败快速定位:在
Run simulation步骤添加timeout-minutes: 10,防止某个场景卡死导致整个 CI 挂起; - 报告可视化:将
ci_evaluation/metrics.csv用pandas生成 Markdown 表格,作为 PR comment 自动发布,让每个 reviewer 一眼看到collision_rate是否为 0。
4.4 阶段四:生产环境部署——从仿真到实车的“信任传递”
最后一步,也是最难的一步:如何让车队工程师相信,你在 NuPlan 里跑出的 0 collision,意味着实车也能安全?答案是建立“信任链”。
信任链的三环:
- 数据环:确保 NuPlan 的
scenario来源于真实接管事件(real-world disengagement logs)。我们团队的做法是,将每一段实车录制的 CAN/LIDAR/VIDEO 数据,用nuplan-scenario-builder工具转成标准nuplan-v1.1格式,打上source: real_vehicle标签; - 仿真环:在 NuPlan 中,用
RealVehicleScenario类加载这些真实场景,并开启sensor_noise: true(模拟真实传感器噪声)和ego_dynamics_noise: true(模拟真实车辆执行偏差); - 验证环:对每个真实场景,不仅跑
collision_rate,还跑trajectory_deviation_rms(规划轨迹与人类驾驶员实际轨迹的 RMS 偏差),要求deviation < 0.5m。只有同时满足collision_rate == 0且deviation < 0.5m的 planner,才被允许进入实车路测。
最后分享一个小技巧:在
MyPlanner.compute_trajectory()中,加入一行日志logger.info(f"Scenario {current_input.scenario.token[:8]} | Ego speed: {ego_state.velocity.magnitude():.2f} m/s")。当实车路测出现问题时,你可以用这个 token 在 NuPlan 日志中快速定位到对应的仿真记录,实现“实车-仿真”双向追溯。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
在把 12 个不同团队的 planner 塞进 NuPlan 的过程中,我整理了一份高频问题速查表。这些问题往往不会在官方文档里出现,但每个都足以让你卡住 1~3 天。
5.1 ImportError: No module named 'nuplan' —— 路径与环境的战争
现象:
在python nuplan/planning/script/run_simulation.py中,from nuplan.planning.simulation.planner.abstract_planner import AbstractPlanner报错。
根本原因:nuplan-devkit是一个 PEP 517 兼容的包,但它的setup.py没有声明nuplan-planning为子包。直接pip install -e .只会安装nuplan根包,nuplan.planning模块不可见。
解决方案(三选一):
- 推荐:在
nuplan-devkit/目录下,运行pip install -e ./nuplan-planning(注意是./nuplan-planning,不是.); - 临时方案:在
run_simulation.py开头添加:import sys sys.path.insert(0, '/path/to/nuplan-devkit/nuplan-planning') - 终极方案:修改
nuplan-devkit/pyproject.toml,在[project]下添加:
然后packages = [ {include = "nuplan"}, {include = "nuplan-planning"}, ]pip install -e .
5.2 RuntimeError: Expected all tensors to be on the same device —— 设备不一致的幽灵
现象:compute_trajectory()中,self.model(input_tensor)报错,提示 `tensor on cpu, but expected it to be on cuda:0