结论:calibrationd 的「已标定」不是一次测量结果,而是要凑满 5 个有效分块,即至少 500 次被接受的样本。样本门只认严格的高速直行与低不确定度:车速与视觉前向速度都要高于 15 mph 对应值、偏航率低于 2°/s;前 5 块会放宽不确定度门,但不放宽速度门。持久快照能让进程重启快速回到名义状态,但那不是新行程重新测得 500 个独立样本。
主循环与输入有效性
main() 读取 CarParams,以 cameraOdometry 作为轮询主题,接收 carState,并创建 Calibrator(param_put=True)。每当收到更新的相机里程计,就先把当前 carState.vEgo 交给 handle_v_ego(),再把视觉平移、旋转、广角外参、标准差和路面高度输入 handle_cam_odom()。服务表给 cameraOdometry 配置名义 20 Hz,extrinsicsCalibration 为 4 Hz。
循环每 5 次 SubMaster.update() 调用一次 send_data(),消息顶层 valid 取 sm.all_checks()。但样本更新段仅判断 sm.updated['cameraOdometry'],没有在进入 handle_cam_odom() 前显式判断该消息的 valid;输出有效位和内部估计更新是两条不同的门。主循环相位探针还确认:仅 carState 更新不触发估计;相机更新时若没有新的车况,就复用上次 carState.vEgo。因此样本门使用的车速没有独立的新鲜度检查。
还有一个节拍细节:SubMaster.update_msgs() 每次调用都会推进 frame,包括 poll 超时后无新消息;因此源码注释中的「4 Hz driven by cameraOdometry」是正常输入下的名义速率,不是无消息时严格不发布。隔离探针确认:首次更新后 frame=0 即发布,此后 frame=5 再发布,且无相机更新仍可发布 valid=False 的输出。
样本接受门
handle_cam_odom() 先将旧姿态平滑权重按每次调用 1/SMOOTH_CYCLES 递减,再执行下列判定。任一条不满足即丢弃本次样本。
| 条件 | 源码阈值 | 结果 |
|---|---|---|
| 直行高速 | carState.vEgo > MIN_SPEED_FILTER 且 trans[0] > MIN_SPEED_FILTER、abs(rot[2]) < MAX_YAW_RATE_FILTER |
车速与视觉前向速度都严格高于 15 mph 对应的 m/s,偏航率严格低于 2°/s;任一不满足即丢本次样本。 |
| 方向估计 | atan2(trans_std[1], trans[0]) < MAX_VEL_ANGLE_STD |
横向平移不确定度角严格低于 0.25°;源码用 trans_std[1],不是三轴方差的综合门。 |
| 高度估计 | 若 roadTransformTransStd 长度为 3,则第 3 项 < exp(-3.5) |
约 0.030 的长度单位;若列表长度不是 3,代码把此项视为通过,而非拒绝。 |
| 初始放宽 | (rpy_certain && height_certain) || valid_blocks < INPUTS_NEEDED |
前 5 个有效分块可绕过后两项不确定度门,但仍需满足直行高速。 |
sanity_clip() 将 NaN 姿态回退初值,并把 pitch/yaw 限在有效范围外扩 0.005 弧度的区间;它是数值夹取,不是「校准有效」的判定。PITCH_LIMITS 在 mici 上与其他设备不同,YAW_LIMITS 共用。
统计窗口、状态转换与平滑
moving_avg_with_linear_decay() 在当前 100 样本块内更新姿态、广角角度与高度;只在接受样本后前进索引,满块时递增 valid_blocks 并让 block_idx 在 50 块窗口内环绕。因此「完成 5 块」表示至少 500 次被接受的样本,并非固定 25 秒或任意 500 帧。get_valid_idxs() 会从统计中排除当前正在写的块。
| 状态 | 判定 | 边界 |
|---|---|---|
uncalibrated |
已完成块不足 5 块 | 原先处于 recalibrating 则保留该状态。 |
calibrated |
达到 5 块且平均 pitch/yaw 在严格限制内 | 否则为 invalid。 |
recalibrating |
当前为 calibrated 且 pitch 跨度 > 4° 或 yaw 跨度 > 2° |
以最近块重置为 1 块、保存旧姿态,按 SMOOTH_CYCLES=10 次调用递减权重平滑;高度跨度目前只留 TODO。 |
平滑权重在样本拒绝之前递减。从 1.0 开始以二进制浮点每次减 0.1,第 10 次后仍有约 1.39e-16 的正残值,第 11 次才由 max(0, ...) 精确归零。所以「10 次平滑」是名义次数,不代表 10 个被接受的样本或严格固定时间。
持久状态与消费者
Calibrator.__init__() 读取持久的 CalibrationParams,但仅在 param_put=True 时解析旧 rpyCalib、validBlocks、广角角度和高度;解析失败则保留默认值。update_status() 在 idx==0 且 block_idx % 10 == 5 时写入 get_msg(True).to_bytes()——这是分块节拍上的 Params 写入,使用 True 构造持久消息,并非当次 sm.all_checks() 的快照。此外,CP.notCar 时输出被覆盖为已标定、100%、零姿态,不能以仿真/非汽车输出证明真实传感器已标定。
下游有三个消费者,且有效性门并不一致:modeld 在外参主题更新时更新模型 warp;AugmentedRoadView 还要求消息有效及状态已标定才更新叠加矩阵;selfdrived 根据标定状态产生禁止进入/软退出事件。接入新相机时,不能用其中一个界面的「显示正常」替代端到端验证。
测试范围与硬件插件要求
test_calibrationd.py 覆盖持久状态读取、低速/大偏航拒样、高不确定度在前 5 块后拒样、pitch/yaw 分散度触发重标定;原版测试本次因缺少 libparams_c.dylib 在收集阶段未执行用例,而三组隔离探针(快照边界五项、筛样五项、主循环相位四项)通过。自制相机/IMU 插件要明确输出坐标、量纲、时间戳、标准差与安装几何;插件不得直接伪造 calStatus=calibrated 或持久 CalibrationParams 来绕过测量链。
常见问题
calibrationd 的「完成 5 块」到底代表什么?
代表至少 500 次被接受的样本(每块 100 个合格样本)。它不是固定 25 秒,也不是任意 500 帧;只有通过样本门、被真正接受的样本才会让块内索引前进。
为什么说车速没有独立的新鲜度检查?
因为样本更新段只判断 sm.updated['cameraOdometry'];相机更新到达时若没有新的车况,就复用上次的 carState.vEgo。能否接受仍由 handle_cam_odom() 的速度与不确定度门决定,而不是车速本身是否新鲜。
高度标准差为空的列表为什么不会拒样?
源码只在 roadTransformTransStd 长度等于 3 时检查第 3 项 < exp(-3.5);长度不为 3 时该分支直接把高度判为「确定」,空列表亦可接受。这是实现细节,不代表数据缺失被主动识别。
重启后立即「已标定」是重新测出来的吗?
不是。持久快照(CalibrationParams)能让进程重启时迅速恢复名义标定状态,但那不是新行程重新测得 500 个独立样本;对持久快照的异常形状也应单独核查。
延伸阅读
- 几何变换与硬件:相机/IMU 坐标到底怎么对上 —— 承接本文确定外参的坐标与量纲前提。
- 车载信号为什么会说谎:TTL、源时间与 500 ms 新鲜度阈值 —— 解释为何「样本门所用车速无新鲜度检查」值得警惕。
- 传感器融合的健康位:置信度不足时系统该做什么 —— 承接本文的不确定度门与状态机。
- 同一个「转向灯」在三家车厂是三种实现:车辆状态归一化的坑 —— 承接
carState作为标定输入的归一化问题。 - openpilot 的 100 Hz 控制回路:开源 ADAS 能给车载设备什么 —— 理解 calibrationd 在整条感知/控制链中的位置。
参考资料
- 项目 02 ——
openpilot-research/CALIBRATIOND_STATE_PERSISTENCE_DEEP_DIVE.md(取用了样本接受门、统计窗口与状态转换、持久状态与三个消费者,以及测试范围)。 - 项目 02 ——
openpilot-research/GEOMETRY_TRANSFORMS_HARDWARE_DEEP_DIVE.md(仅用于确认相机/IMU 坐标定义与外参几何前提)。
说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。
更新记录
首次发布 2026-10-11 最近核对:2026-10-11
发表回复