标签: 相机视觉

  • locationd 的四种健康位:20 Hz 输出为什么不能压缩成一个布尔值

    结论:locationd 的健康状态是四个独立维度——deviceMotion.valid、inputsOK、sensorsOK、posenetOK,它们不能压缩成一个「正常」布尔值。顶层 valid 只等于 filter_initialized && isfinite(kf.t),不直接包含另外三项;而 filter_initialized 一旦置真,不会在消息异常时自动变回假。名义频率下,约第 2 个相机坏输入或第 10 个 IMU 坏输入才会让 critical_service_inputs_valid 变假。

    调度与启动门:先看名义频率

    main() 以 cameraOdometry 为 SubMaster 轮询主题,另开独立 socket 批量读取 IMU 消息。服务表配置 加速度计/陀螺仪名义 104 Hz,相机里程计和 deviceMotion 20 Hz。

    主题 名义频率 作用
    加速度计 / 陀螺仪 104 Hz IMU 观测源,经独立 socket 批量读取
    相机里程计 20 Hz 轮询主题,同时触发 deviceMotion 发布
    deviceMotion 20 Hz 仅在 sm.updated['cameraOdometry'] 时发布

    启动门的细节很关键:初始化阶段只在 sm.all_checks() 与 sensor_all_checks() 同时满足时把 filter_initialized 置真;之后此布尔值不会在消息异常时自动变回假。输出侧,deviceMotion 仅在 sm.updated['cameraOdometry'] 时发布——单有 IMU 更新不触发输出。

    循环把 IMU 与 SubMaster 更新的消息按 logMonoTime 排序,仅把顶层 valid=True 的消息交给 handle_log()。这是当前批次内排序,不能保证跨批次全局严格有序;历史观测由滤波器最大回绕窗口约束。进程还可读取持久的 LocationFilterInitialState 作为初始状态/协方差来源,但本快照常规运行代码未定位到写入该键的对应路径——不能假定每次启动都有历史姿态。

    十八维状态与四类观测

    States 把状态分为 NED 姿态、设备速度、角速度、陀螺偏置、设备加速度、加计偏置,共 18 维。generate_code() 定义姿态旋转、速度积分以及 IMU/相机四类观测方程,运行时通过 rednose 生成的 EKF 执行——不是简单对四种速度取平均。四类观测的门各不相同:

    观测 生产条件与变换 拒绝 / 降权边界
    加速度计 msg.which() == acceleration,硬件数组重排取负为 [-v[2], -v[1], -v[0]],用传感器纳秒时间戳 时间戳不能为零,与 logMonoTime 差严格不超过 0.1 秒,不能早于滤波状态 0.8 秒以上;bmx055 作为第二 IMU 源被忽略;范数须 <100 m/s²
    陀螺仪 同样重排取负,先减滤波器陀螺偏置,再与相机偏航率分布交叉比较 除时间/来源门外,角速度范数须 <10 rad/s,偏航差须低于相机偏航标准差的 30 倍;只有通过才作为 PHONE_GYRO 观测
    相机旋转 以 timestampEof - 0.1 秒作为视觉姿态时刻,用 device_from_calib 旋到设备系 受 0.8 秒回绕门;旋转范数 ≤10 rad/s;标准差各分量须 >1e-5,范数不可超对应上限的 10 倍;入滤波前标准差乘 10
    相机平移 与旋转同一延迟时间和标定旋转;视觉平移写入 CAMERA_ODO_TRANSLATION 平移范数 ≤200 m/s;标准差须 >1e-5 且范数不超过 10*200,入滤波前标准差乘 4;方差旋到设备系后给 EKF

    两处时间语义差异必须记住:carState 在此只更新 abs(vEgo) 供 posenetOK 门使用,不是这只姿态滤波器的直接速度观测;extrinsicsCalibration 在 rpyCalib 非空且分量位于 ±0.5 rad 内时更新 device_from_calib。更关键的是:相机观测没有像 IMU 那样检查自身时间戳与 logMonoTime 的 0.1 秒差,而是按 timestampEof-0.1 与滤波器时刻比较——新硬件不能把两种时间语义混为一谈。

    坏输入计数:第 2 个相机、第 10 个 IMU

    HandleLogResult 区分成功、时间非法、值非法和传感器来源非法。主循环只对关键主题的时间/值非法增加坏输入计数,成功时乘以按频率计算的 calculate_invalid_input_decay();SENSOR_SOURCE_INVALID 不进入这两个分支。

    名义频率下,相机 input_invalid_limit=2、IMU 各为 round(2*104/20)=10,输出门使用比它们各低 0.5 的阈值——所以约第 2 个相机坏输入或第 10 个 IMU 坏输入,才会让 critical_service_inputs_valid=False。源文档特别澄清:实际恢复取决于后续成功样本数量和消息速率,不是固定墙上 10 秒。

    四种健康含义各不相同

    输出字段 源码计算 不代表什么
    deviceMotion.valid filter_initialized && isfinite(kf.t);同样设置四组 XYZMeasurement.valid 不直接包含当前 inputsOK/sensorsOK/posenetOK;某传感器掉线时顶层 valid 不一定同步变假
    inputsOK sm.all_valid() 与关键观测坏输入计数门同时成立 all_valid() 只检查消息有效位,不等于全部主题当前 alive/频率正常
    sensorsOK 对两路 IMU 各保存最近消息的 valid 位;非仿真要求距接收单调时间 <0.1 秒 不等于加速度/陀螺数值一定通过 handle_log() 的物理合理性与时间检查
    posenetOK 对相机平移标准差的前/后各 20 项均值作比,后均值是前均值 4 倍以上且 >7、车速 >5 m/s 时置假(低速放行) 它是模型运动估计的波动门,不是整条视觉链的真实性证明

    复位逻辑同样要分清:_finite_check() 在滤波返回的状态或协方差出现非有限值时调用 reset(t);但它不自动重置 filter_initialized 或所有输入计数。上游 selfdrived 对 posenetOK/inputsOK 另产生事件,controlsd 消费设备姿态与车辆参数——健康位与事件门是分层契约。

    测试与硬件插件边界

    test_locationd_scenarios.py 使用路段回放构造 IMU 缺失、数值峰值和 150 ms 时间偏移场景;其测试依赖路段数据下载/缓存,本轮未执行,不据测试文件存在而宣称当前设备已验证。

    自制 IMU/相机插件至少要保持轴序、符号、单位、硬件时间戳、logMonoTime 对齐、噪声标准差及顶层 valid 语义;对异步/乱序观测要验证 0.1 秒传感器门、0.8 秒回绕窗口和视觉 0.1 秒模型延迟。算法层允许回退或过滤观测,独立执行器安全限制仍在 panda 固件。

    常见问题

    顶层 valid 为真,能否说明所有传感器都正常?

    不能。deviceMotion.valid 只等于 filter_initialized && isfinite(kf.t),不直接包含 inputsOK/sensorsOK/posenetOK。由于 filter_initialized 一旦置真不会在消息异常时自动变回假,首次初始化后某传感器掉线,顶层 valid 不一定同步变假。

    为什么相机和 IMU 的坏输入阈值差这么多?

    因为阈值按名义频率换算:相机 20 Hz 对应 input_invalid_limit=2,IMU 104 Hz 对应 round(2*104/20)=10,输出门再各低 0.5。所以约第 2 个相机坏输入或第 10 个 IMU 坏输入才触发,恢复则取决于后续成功样本数量和消息速率,不是固定时长。

    posenetOK 变假是否等于视觉定位失效?

    不等于。它是对相机平移标准差前后各 20 项均值作比得到的波动门(后半均值是前半 4 倍以上且 >7、车速 >5 m/s 时置假,低速放行),只反映模型运动估计的波动,不是整条视觉链的真实性证明。

    相机观测和 IMU 观测的时间门一样吗?

    不一样。IMU 检查自身时间戳不能为零、与 logMonoTime 差严格不超过 0.1 秒、且不早于滤波状态 0.8 秒以上;相机观测没有检查自身时间戳与该 0.1 秒差,而是按 timestampEof-0.1 与滤波器时刻比较。两种时间语义不能混用。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LOCATIOND_SENSOR_FUSION_HEALTH_DEEP_DIVE.md(取用了 18 维状态、四类观测门、坏输入计数与四种健康含义)。
    • 项目 02 —— openpilot-research/CAR_PROTOCOL_IMPLEMENTATIONS.md(仅用于确认 selfdrived/controlsd 与健康位事件的分层关系)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • road/device/view 三套轴向:相机与 IMU 接入的几何契约

    结论:坐标变换链里的矩阵和常数是当前设备配置,不是新摄像头或 IMU 的通用标定结果。三套轴向必须分清:road 为前/左/上,device 为前/右/下,view 为右/下/前;euler2rot_single() 用 Rz(yaw) @ Ry(pitch) @ Rx(roll),角度是弧度。更隐蔽的是「什么时候接受标定值」这件事,模型与 UI 两道门并不一样;此外接入未登记的新设备/传感器键会在静态路径上直接抛 KeyError。以下基于源码快照 c8fb906 的静态阅读与独立探针,未取得实机镜头、安装位姿或模型实体做数值验证。

    坐标系与单位

    坐标系 源码定义 接口要求
    地理坐标 geodetic2ecef_single() 接收纬度、经度、海拔 输入纬经度是度,函数内转弧度;海拔按 ECEF 公式使用长度单位;输出 x/y/z 为同一长度单位(通常米)。
    ECEF / NED LocalCoord 以一处地理或 ECEF 坐标定原点 NED 的 z 朝下;ned2ecef 加回 ECEF 原点,ecef2ned 先减原点。不能把 GNSS 纬经度直接当车身坐标。
    路面/设备/视图 camera.py 轴向定义 road 前/左/上,device 前/右/下,view 右/下/前;view_frame_from_device_frame 是固定轴重排矩阵。
    姿态 euler2rot_single() 使用 Rz(yaw) @ Ry(pitch) @ Rx(roll) 角度用弧度,欧拉角排列为 roll/pitch/yaw;rpyCalib 与 IMU 输出不可擅自交换轴或度/弧度。

    五个文件与关键入口

    几何能力集中在 common/transformations 的五个文件里,各自有明确职责与边界:

    文件与关键入口 实际功能及边界
    camera.py: CameraConfig、DeviceCameraConfig 每路相机用宽、高、单个焦距构造 K(fx=fy、主点在画面中心);此对象不含真实镜头畸变系数或在线外参优化。
    DEVICE_CAMERAS 以 (deviceType, sensor) 选三路相机配置,含部分 unknown 兼容键;AR/OX 的 fisheye 焦距在源码注释里被标为可能不准。
    get_view_frame_from_road_frame()、get_view_frame_from_calib_frame() 按旋转角和高度构造 3x4 外参;模型预处理使用的高度为 0,其他调用不应隐去平移项。
    normalize()、denormalize() 用 K^-1 与 K 在像素坐标与归一化针孔坐标间变换;都不是带畸变的鱼眼矫正。
    device_from_ecef()、img_from_device() 前者依位姿四元数把 ECEF 点转设备系;后者转 view 后除以深度,负深度置 NaN,零深度无显式安全分支。
    model.py: get_warp_matrix() 计算 intrinsics @ view_frame_from_device_frame @ rot_from_euler(...) @ calib_from_model;bigmodel_frame=True 选 sbig 逆矩阵。

    运行链:模型与 UI 的标定门不一样

    modeld 在 extrinsicsCalibration 更新且已见设备/窄路相机状态时,用 (deviceType, sensor) 从 DEVICE_CAMERAS 取配置,按主/辅相机选内参并计算两套 warp。该访问没有默认配置分支——若接入新设备/传感器组合而未登记键,静态代码路径会抛 KeyError。而且这个触发条件没有显式要求 extrinsicsCalibration.valid 或 calStatus=calibrated,所以不能把 modeld 的矩阵更新等同于标定已通过全部质量门。

    行车叠加的 AugmentedRoadView._update_calibration() 同样选择 DEVICE_CAMERAS,但更新外参前显式检查消息更新、valid、三元素 rpyCalib 和 calStatus=calibrated;宽相机还有 wideFromDeviceEuler 路径。也就是说,模型与 UI 的「何时接受标定值」门不同;单凭叠加图像看起来对齐,不能证明模型输入几何或相机身份正确。Calibrator 用姿态估计累积筛选标定块、依 pitch/yaw 范围更新状态,并在文件头明确假设 roll 为零,模型输入不校正 roll。

    退化输入与边界

    独立探针给出了几处具体边界:零角度且输入 med 模型内参时 get_warp_matrix() 为单位矩阵;normalize([2000, 600]) 在宽 1928 的内参下仍返回有限值,因为 normalize() 只把带负值的点置 NaN、不限制图像上界;denormalize() 未给边界时也返回有限值,传入 width=1928, height=1208 后返回 NaN,但它只拒绝 x>width,x==width 仍被接受,所以该边界不是有效像素索引判据。img_from_device() 的批量 (1,3) 背向点返回 NaN,但 (3,) 单点输入会因末尾二维切片触发 IndexError——注释称支持单点,实际不支持。未知传感器键则直接抛 KeyError。

    测试现状方面:test_coordinates.py 含 ECEF/NED 数值样本、单点/批量和错误输入检查,test_orientation.py 含姿态转换一致性;但当前 transformations 测试目录未见独立的镜头畸变、DEVICE_CAMERAS 新键、img_from_device() 单点或 get_warp_matrix() 标定回归测试。用隔离运行器加载两份仓库原测试文件、仅把 OpenpilotTestCase 临时替换为标准 unittest.TestCase 后,12 个原测试方法全部通过——它执行的是原测试方法与原变换函数,但不运行项目测试夹具,不能替代镜头/IMU 实机标定。

    新硬件接入至少需要什么

    1. 为真实相机/传感器身份登记尺寸和内参,避免行车路径上的 KeyError;
    2. 以标定板或实景观测测量焦距、主点、鱼眼畸变、主宽相机相对姿态与安装高度;
    3. 确认 device/road/view 轴向与 rpyCalib 弧度定义,不要让度/弧度或轴序在链路中被悄悄替换;
    4. 对相机 SOF、IMU、GNSS 使用可比较时间基;
    5. 在回放中同时检查模型输入 warp、UI 叠加、定位残差与异常输入。

    应由受控平台层发布标定结果及质量状态,普通插件不得在行车中任意替换几何矩阵或伪造校准完成标志。实机误差、长期漂移与车辆级安全影响仍待台架和封闭场地验证。

    常见问题

    模型输入几何能用 UI 叠加看起来对不对来验证吗?

    不能。模型与 UI 的「何时接受标定值」门不同:UI 显式检查 valid、三元素 rpyCalib 与 calStatus=calibrated,而 modeld 的触发条件没有显式要求这些。叠加图像对齐不等于模型输入几何或相机身份正确。

    接入新的相机或传感器组合为什么会直接抛 KeyError?

    因为 DEVICE_CAMERAS 按 (deviceType, sensor) 取配置,代码没有默认配置分支。未登记键的设备/传感器组合会在静态路径上直接抛 KeyError,必须先登记尺寸与内参。

    img_from_device() 支持单点输入吗?

    注释称支持,但当前实现在单点 (3,) 输入时会因末尾二维切片抛 IndexError,批量 (N,3) 才可用。零深度也没有显式安全分支,调用者须自行处理非有限投影。

    几何常量可以直接搬到新摄像头上吗?

    不能。本文所述矩阵与常数是当前设备配置,不是新摄像头或 IMU 的通用标定结果;AR/OX 的 fisheye 焦距在源码注释里也被标为可能不准。新硬件应重新测量并规定轴向与弧度约定。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GEOMETRY_TRANSFORMS_HARDWARE_DEEP_DIVE.md(坐标变换与相机几何接口契约,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/run_transformations_source_tests.py(加载原测试文件的隔离运行器,12 个原测试方法通过)
    • 项目 02 —— openpilot-research/probes/test_camera_geometry_plugin_contract.py(相机几何四项隔离探针)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 两款车载相机传感器:三帧延迟与 g-1..g+1 的曝光闭环

    结论:原生相机链同时描述了 OS04C10 与 OX03C10 两套传感器,每路先探测 OS04C10、失败再回退 OX03C10。曝光闭环按 20 Hz、固定 dt=0.05 平滑目标灰度,检索范围只有当前模拟增益索引的 g-1..g+1,并且用三帧前的 EV 做反馈——所以状态消息里的曝光字段与随后提交的下一组寄存器值,不能不加帧延迟地视为同一时刻的物理测量。

    两套传感器描述:不只是「读曝光的接口」

    SensorInfo 同时承载探测地址、启动/I2C 表、RAW 格式、曝光范围/增益表、读出时间以及 ISP LUT;它不是仅有 getExposureRegisters() 的驱动接口。openSensor() 在每一路先尝试 OS04C10,再尝试 OX03C10,两个 sensors_init() 都失败才禁用该路。两种实现的 getSlaveAddress() 都把端口 0/1/2 映射为 0x6c/0x20/0x6c——新板的 I2C mux/线序不能只靠改传感器 ID 解决。

    契约 OS04C10 OX03C10
    分辨率/格式 2688×1520,RAW12,BGBG Bayer,out_scale=2 1928×1208,RAW12,GRGR Bayer,stride 另加 4 字节,顶部/底部额外行及 frame_offset=2
    MCLK / 探测 ID 24 MHz,ID 0x5304 24 MHz,ID 0x5803
    曝光范围 / EOF 偏移 2..2352 行,11 ms 2..2016 行,14.697 ms
    模拟增益索引 0..40 0..54
    ISP 黑电平、色彩矩阵、gamma、线性化、暗角 对应 OX 配置

    探测都读寄存器 0x300a。两个必须注意的点:EOF 是 SOF + readout_time_ns 的推算值,不是独立硬件捕获;OS04C10 的 11 ms 在源码中注明来自日志估计。模拟增益方面,两者的物理增益表虽相同,寄存器编码不同,索引必须受各自 analog_gain_max_idx 限制。ISP 侧,prepare_gamma_lut() 将 65 个采样点打包为 64 项;这些校正表与具体镜头/传感器组合绑定。

    探测供电、CSI 与 ISP 的真实耦合

    sensors_init() 组装一次 Qualcomm CAM_SENSOR_PROBE_CMD:I2C 采用 fast mode(源码注释为 400 kHz),16 位寄存器地址和 16 位 ID 数据。探测包内嵌固定上电/下电序列:先设置电源/复位项,等待 1 ms;给 MCLK 写入 24 MHz,再等待 1 ms;释放复位后等待 34 ms,才执行探测。源文档提醒,源码对某些 power_seq_type 仍保留「clock??」等不确定注释,不能把整数直接当作可投板的稳压器拓扑,必须用目标内核驱动定义、原理图和示波器确认引脚极性与上电斜率。

    ISP 输入口固定 D-PHY、4 条数据 lane、映射 0x3210、VC0;路面相机经 IFE 输出 NV12,座舱相机走 RAW RDI 再交 BPS。configCSIPHY() 同样固定 lane_mask=0x1f、4 lane、lane_assign=0x3210、MIPI_SETTLE_CNT=33 和 data_rate=48000000——这些值没有来自 SensorInfo 的逐传感器能力协商,更换 lane 数、走线、时钟或 PHY 时,只增加一个传感器类是不够的。

    座舱 BPS 初始化直接以 sensor->num() 索引 bps_cfg、bps_settings 和 bps_striping_output 三组固定二进制表。表声明仅有 4 个槽,前两个为占位,实际表与 ImageSensor 枚举值 2/3 绑定。新增枚举或热插拔传感器若不同步提供 BPS 表及索引验证,会在座舱路径出现无效配置或越界风险。

    自动曝光的实际计算

    CameraState::init() 从板级焦距、像素尺寸与输出缩放计算像素焦距,设置各路固定参考测光矩形,并初始化最近三帧的 EV。calculate_exposure_value() 在 NV12 Y 平面按 x=2, y=2(路面)或 y=4(座舱)抽样;从 255 向下累计直方图,取让高亮端累计至少达到样本数一半的亮度,再除以 256。它是抽样亮度的中位数近似,不是平均照度,也不是传感器原生统计直方图。

    set_camera_exposure() 按 20 Hz 固定 dt=0.05 平滑目标灰度;根据三帧前的 EV 与当前测得灰度计算 desired_ev,约束到 min_ev..max_ev。随后只搜索当前模拟增益索引的 g-1..g+1,为每个候选求受限积分时间,并调用传感器的 getExposureScore() 评估 EV 误差、推荐增益代价和增益切换代价——这不是全增益表的全局最优搜索。转换增益在目标灰度跨越阈值时切换,并通过 dc_gain_weight 平滑增益因子。

    曝光寄存器编码差异

    两个传感器的寄存器写入方式不同,且有一处共同的静态边界:

    实现 曝光寄存器编码 静态边界
    OS04C10 积分时间写 0x3501/02,同一模拟增益分别写 0x3508/09 与 0x350c/0d dc_gain_enabled 参数未参与此函数的寄存器生成
    OX03C10 HCG/LCG、SPD 与 VS 时间分别写入;SPD 有下限/上限,VS 为积分时间约 1/40 并限定 1..34 行,模拟增益写 0x3508/09 dc_gain_enabled 同样未参与此函数的寄存器生成

    两行的「静态边界」含义一致:软件的转换增益模型不能被理解为这里每帧必有对应寄存器切换;多曝光段的图像效果仍取决于初始化表和 ISP 配置。

    输入风险与插件重构

    源文档列出三类输入风险。第一,调试覆盖开关 CTRL_EXP_FROM_PARAMS 允许从 CameraDebugExpGain / CameraDebugExpTime 读取字符串并调用 std::stoi;在覆盖分支中未见范围检查,随后 sensor_analog_gains[new_exp_g] 与寄存器表直接按索引访问,负数、过大值和非数字分别可能造成越界或解析异常。第二,desired_ev 计算中直接除以测得 grey_frac,而亮度函数可返回零,标准浮点环境下暗帧会产生非有限中间值——该路径的最终值及 std::clamp 行为需测试确认。第三,测光 ROI 与 NV12 stride 的寻址差异:缓冲 Y 行步长是 128 字节对齐值,测光代码却按可见宽度换行,而固定的两种模组宽度都未对齐——后续算法描述是代码意图,不能据此认为它采样了光学 ROI 中对应的每个像素。

    因此新「相机插件」至少要声明:探测/供电、I2C 与 MCLK、CSI 格式/步长、Bayer/ISP LUT、曝光实际生效延迟、SOF/EOF 时间域、镜头内外参和失效状态。源文档的定位很明确:插件可以提供传感器参数与采集能力,但时间戳真实性、帧有效性、模型输入契约和车辆执行许可应由受控平台层校验。对自制板,调试接口应与行车模式隔离并做显式限幅。

    常见问题

    为什么说曝光状态和寄存器值不是同一时刻的?

    因为 set_camera_exposure() 用三帧前的 EV 计算 desired_ev,并按 20 Hz 固定 dt=0.05 平滑;最终参数通过 sensors_i2c() 排队写入下一帧配置。状态消息的曝光字段与随后提交的寄存器值之间存在帧延迟,不能不加延迟地视为同一时刻的物理测量。

    传感器探测为什么不能只改 I2C 地址?

    因为两种实现的 getSlaveAddress() 都把端口 0/1/2 映射为 0x6c/0x20/0x6c,而且探测还依赖固定上电/复位序列(1 ms、1 ms、34 ms)和 MCLK 24 MHz。新板的 I2C mux 与线序不能只靠改传感器 ID 解决,需用内核驱动定义、原理图和示波器核对。

    为什么换一种 lane 数或走线不能只加一个传感器类?

    因为 ISP 输入口与 configCSIPHY() 的参数是固定值(4 lane、lane_assign=0x3210、MIPI_SETTLE_CNT=33、data_rate=48000000),没有来自 SensorInfo 的逐传感器能力协商。更换 lane 数、时钟或 PHY 时,需要同步修改这些固定配置。

    自动化曝光是「全增益表最优搜索」吗?

    不是。它只搜索当前模拟增益索引的 g-1..g+1,为每个候选求受限积分时间,并由 getExposureScore() 评估 EV 误差、推荐增益代价和增益切换代价。因此它接近局部搜索,而非全局最优。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CAMERA_SENSOR_EXPOSURE_ISP_DEEP_DIVE.md(取用了两套传感器契约、探测供电与 CSI/ISP 固定配置、曝光闭环与寄存器编码差异)。
    • 项目 02 —— openpilot-research/CAR_PROTOCOL_IMPLEMENTATIONS.md(仅用于确认车型配置与相机索引在整体链路中的位置)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • UIState.update() 在绘制之后:告警时延与 5 Hz 刷新

    结论:车载 UI 是呈现与交互层,不裁决自动驾驶使能,权威状态来自 hardwared、selfdrived、card 与模型进程。决定告警时延的是一条容易被忽略的顺序:GuiApplication.render() 的生成器在绘制顶层 Widget 之后才结束本帧绘制,本轮 UIState.update() 因此发生在绘制之后,新消息通常在下一帧才用于 Widget——所以 uiDebug.frameTimeMillis 不能当成「消息发布到屏幕显示」的端到端时延。本文为对本地固定源码快照的静态阅读,逐个像素、全部语言与 GPU 合成均未验证。

    入口、数据与帧顺序

    ui.main() 初始化窗口后,按屏幕尺寸创建 MainLayout 或 MiciMainLayout,每次从 GuiApplication.render() 的生成器取得帧状态再调用 UIState.update()。屏幕关闭时生成器仍周期性 yield False,让 UIState 有机会更新并重新唤醒屏幕。触摸事件经 MouseState 由顶层 Widget 分发到子 Widget 或导航栈。

    组件/关键函数 实现作用
    UIState._initialize() 建立单例与大量 cereal 主题订阅,初始化 started、status、车型、实验模式和 Chestnut 状态
    UIState.update() 非阻塞 SubMaster.update(0),刷新状态/告警枚举,再调用屏幕设备管理;约 5 Hz 后台线程读取较慢的 Params
    UIState._update_state() / _update_status() 从 pandaStates、deviceState、selfdriveState 派生点火/行车/使能状态并通知边沿回调;started 要求 deviceState.started 且自身缓存点火为真
    UIState._update_chestnut_state() 根据设备发现、模型编译产物、新鲜度与 Params 显示断开/加载/激活/失败,与固件刷写状态不是同一状态机
    Device._update_brightness() 行车时按相机曝光估算环境亮度并平滑,后台线程设置背光;触摸、点火、交互超时控制屏幕电源
    Widget.render() / _process_mouse_events() 先更新状态再绘制,然后处理触摸;只接受从自身命中区域开始的触摸,长按达 0.45 秒后清除点击跟踪

    需要单独记住的是:UIState.update() 里的非阻塞订阅只刷新枚举与屏幕设备管理,较慢的参数项由约 5 Hz 的后台线程承担,这两者的节奏并不相同。

    页面与关键交互

    MainLayout._set_mode_for_state() 管理首页、设置、行车三态;行车切换会自动进入摄像头画面,侧边栏可由行车画面触摸切换。SettingsLayout 分为 Device、Network、Toggles、Software、Firehose、Developer 六面板,其中 TogglesLayout._update_toggles() 会根据车型纵向能力、分支和 engaged 状态限制开关。

    页面/组件 关键入口 行为
    大屏主导航 MainLayout._set_mode_for_state() 首页、设置、行车三态;行车切换自动进入摄像头画面,body/notCar 特殊处理
    大屏首页/侧边栏 HomeLayout._update_state() / Sidebar._update_state() 显示更新/驻车告警、网络/温度/连接/设备状态,可进入设置或发布书签
    大屏设置 SettingsLayout Device、Network、Toggles、Software、Firehose、Developer 六面板,开关受车型与状态限制
    首次引导 OnboardingWindow 及其 mici 版本 条款版本与培训版本写 Params;mici 另延长交互超时并将亮度设为 100,退出后恢复
    mici 主导航 MiciMainLayout._handle_transitions() 横向滚动驻车告警、首页、行车/Body;点火后约 2.5 秒切换行车,静止时返回行车

    行车画面与告警

    AugmentedRoadView._render() 先绘制摄像头,再按顺序叠加模型路径、HUD、告警和驾驶员状态,且仅在 ui_state.started 时执行。CameraView._render() 非阻塞读取 VisionIPC:没有新帧时保留上一帧,连接丢失时清空并绘制占位;comma 硬件走 EGL 直接缓冲,PC 走 Y/UV 纹理拷贝。ModelRenderer._render() 要求模型和标定至少在本次行车之后收到过。

    相机画面的连接管理也不简单:_ensure_connection() 对重连做了节流,_handle_switch() 要等目标流有帧才完成宽/窄切换,而宽/窄流的选择取决于实验模式与速度门限;_update_calibration() 只消费有效标定矩阵。屏幕上的路径只是视觉呈现,不是控制输出的独立证明。

    告警侧的两个时间门值得单列:AlertRenderer.get_alert() 在 10 秒未收到首次行车 selfdriveState 时显示等待;在 comma 硬件上,已收过状态但断流超过 5 秒时先显示接管/系统失联,再显示重启提示;大屏实现还会拒绝前一行车周期的旧告警。mici 的 AlertRenderer.get_alert() 有相同超时分支,但正常告警路径没有大屏的 recv_frame < started_frame 过滤——这是一个需要回放验证的旧告警可见性风险,不是已实测问题。语音失联告警由 soundd 负责,不能由普通 UI 插件替代。

    硬件与插件化边界

    显示平台适配需提供可靠触摸、屏幕电源、背光、GPU/EGL 或纹理路径和相机图像零拷贝能力,并监测触摸到反馈、消息到危险告警、屏幕唤醒与画面丢帧的真实时延。装饰性页面与指标可以插件化,但接管告警、失联占位、条款/培训、驾驶员监测提示及设置权限应由受信任核心约束;插件不能覆盖 selfdriveState 权威主题,也不能在未完成车型和安全校验时直接开启控制选项。

    现有 test_raylib_ui 只检查 UI 进程初始化;截图差异回放覆盖若干页面/告警视觉状态,但本次未运行。两种屏幕、触摸竞争、前后帧时延、长期断流与各语言文本溢出均尚未实测。

    因此插件化要区分「装饰」与「权威」:装饰性页面与指标可自由替换,而告警呈现、失联占位、条款培训与设置权限必须留在受信任核心;插件也不得在未完成车型与安全校验时开启任何控制选项。

    常见问题

    uiDebug.frameTimeMillis 是端到端显示时延吗?

    不是。生成器在绘制顶层 Widget 之后才结束本帧,本轮 UIState.update() 发生在绘制之后,新消息通常在下一帧才用于 Widget;因此该指标只是帧数据,不等于从消息发布到屏幕显示的端到端时延。

    UIState 的状态刷新是实时的吗?

    不是。状态订阅用非阻塞 SubMaster.update(0),而较慢的 Params 项由约 5 Hz 的后台线程刷新;屏幕关闭时生成器仍周期性 yield False 让 UIState 有机会更新并重新唤醒屏幕。

    UI 的 started 状态是怎么定的?

    UIState._update_state() 要求 deviceState.started 为真且自身缓存的点火为真,同时从 pandaStates/deviceState/selfdriveState 派生状态并通知边沿回调。

    行车告警的等待与失联阈值是多少?

    AlertRenderer.get_alert() 在 10 秒未收到首次行车 selfdriveState 时显示等待;comma 硬件上已收过状态但断流超过 5 秒时先显示接管/系统失联,再显示重启提示。mici 缺少大屏那层旧告警过滤,尚需回放验证。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/UI_RUNTIME_DEEP_DIVE.md(UI 入口、UIState 聚合、页面导航与行车告警时延)
    • 项目 02 —— openpilot-research/CHESTNUT_USB_FIRMWARE_DEEP_DIVE.md(源文档指向的 Chestnut 固件刷写状态专题)
    • 项目 02 —— openpilot-research/UI_SIDEBAR_STATUS_FRESHNESS_ACTION_CONTRACT_DEEP_DIVE.md(源文档指向的侧栏状态新鲜度与动作门专题)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 设置页写了参数不等于生效:ADB、alpha 纵向与总开关的下游链差在哪

    结论:车载设置页的开关只做一件事——往 Params 写值并(在少数情况下)请求一次行车周期;参数写进去不等于硬件行为已经验证,UI 显示门也不是安全授权。软件更新页的「检查/下载」是发给更新器的信号请求,安装按钮只写 DoReboot;开发者页的 ADB/SSH、动作模式与 alpha 纵向涉及不同下游消费者;驾驶开关则通过 card、selfdrived 影响使能。大屏与 mici 两套界面在时间门、异步调度与锁键语义上并不完全对齐。以下为固定源码快照 c8fb906 的静态分析。

    软件更新页:请求不是直接安装

    大屏 SoftwareLayout 从 Params 显示当前描述/发行说明、UpdaterState、失败次数、可拉取/可安装状态与目标分支。_update_state() 只在 offroad 显示「检查/下载」和「安装」,且安装可见还要求 UpdateAvailable。按钮点击后的约 10 秒等待窗只处理「更新器未响应」的 UI 状态,不保证下载成功。

    _on_download_update() 依据按钮文案选择 SIGUSR1 检查或 SIGHUP 拉取;_on_select_branch() 写 UpdaterTargetBranch 后发送检查信号。WaitTimeHelper 把 SIGUSR1 转成 CHECK、SIGHUP 转成 FETCH,由更新器负责分支解析与 staging/finalizing,再写 UpdaterFetchAvailable、UpdateAvailable 等 Params。两屏卸载都经确认后写 DoUninstall;manager_thread() 检出 DoUninstall/DoReboot/DoShutdown 后退出循环,由 main() 调用平台卸载或重启。实际固件切槽/卸载动作仍需平台级验证。

    开发者页:参数到运行链

    开发者页的控件覆盖 ADB/SSH 开关、SSH 公钥拉取、joystick 与纵横向动作模式、alpha 纵向控制确认以及 UI FPS/触摸调试。它们大多只写一个 Params 键,是否会真正改变硬件行为取决于设备系统层或进程表:例如 ADB 控件 offroad 禁用,而 SSH 开关未使用相同的 offroad 门;动作模式三者在 UI 回调中互斥写 Params,但参数可由其他进程直接修改,UI 互斥不是进程表的全局不变量。

    SSH 公钥走 SshKeyFetcher:请求 GitHub 的 <username>.keys,把用户名与返回文本写 GithubUsername/GithubSshKeys,出错时清理;这是双键非原子写,系统级 authorized_keys 生效链仍需平台审计。

    控件 下游或边界
    ADB / SSH 写 AdbEnabled、SshEnabled;服务实际启停取决于设备系统层。ADB 控件 offroad 禁用,SSH 未使用相同门。
    SSH 公钥 请求 GitHub <username>.keys,写 GithubUsername/GithubSshKeys;双键非原子写,错误时清理。
    joystick / 纵向 / 横向动作 三者在 UI 回调中互斥写 Params;process_config.py 据参数选调试进程,参数可被其他进程改。
    Alpha 纵向控制 非 release、车型 alphaLongitudinalAvailable、确认页之后写 AlphaLongitudinalEnabled 并请求行车周期;可能改变原车 ACC/AEB 组合。
    UI 调试 写 ShowDebugInfo 并在本 UI 进程切换触摸点/FPS 叠加,不影响 CAN 安全许可。

    行车周期与控制模式

    restart_needed_callback() 写 OnroadCycleRequested。hardwared 消费后清零,并在一段时间内使 not_onroad_cycle 条件为假,迫使 started 周期重建——这不是操作系统硬重启。新设备需要测试「参数写入 → deviceState.started 边沿 → manager 进程重选 → card 车型参数重建 → panda 安全模式重新配置」的顺序。

    这里有一个两屏不一致点值得注意:大屏动作模式回调写互斥参数,但并未像 mici 那样对纵/横向动作模式调用 restart_needed_callback(),两屏的即时生效路径可能不同。因此不能从「UI 控件已改变」推断当前控制进程已安全切换,需要通过回放或实机验证 manager 对参数变化的读取节奏。

    驾驶开关与消费关系

    TogglesLayout 定义 OpenpilotEnabledToggle、ExperimentalMode、加速踏板退出、LDW、Always-On DM、座舱摄像头/麦克风记录及单位切换。它按车型 CarParams 是否有 openpilot 纵向控制动态限制实验模式与驾驶风格;实验模式首次启用要求确认,涉及重启的开关在 engaged 时禁用。

    消费侧,card 在初始化时结合 OpenpilotEnabledToggle、控制器是否存在与车型 dashcamOnly 决定 CP.passive;selfdrived 与 card 的运行状态读取实验模式。LongitudinalPersonality 还可通过车辆距离按钮变化并反映到 UI,因此 UI 选项不是唯一写入者。两套页面的限制也不等价:大屏遍历各开关的 *Lock 参数,mici 在当前页面只显式处理 RecordFrontLock,移植配置策略时须逐键核对。

    对比项 大屏 SoftwareLayout / TogglesLayout mici 对应界面
    更新请求发送 直接发 SIGUSR1/SIGHUP,约 10 秒等待窗 后台线程发送,系统时间无效时阻止按钮请求
    动作模式重启 写互斥参数,未调用 restart_needed_callback() 对纵/横向动作模式调用 restart_needed_callback()
    锁键处理 遍历各开关的 *Lock 参数 当前页面只显式处理 RecordFrontLock

    自制硬件与插件化建议

    源文档给出一组建议:把设置描述为「能力 + 策略 + 执行者」,UI 插件只呈现能力,更新器/进程管理器/平台服务执行请求;给更新与危险操作定义结构化请求及结果,而非让多个 UI 复制 pkill、布尔 Params、10 秒本地计时;保留 offroad/engaged 二次校验,确保从外部 API 调用也无法绕过。

    对 alpha 纵向、动作模式、openpilot 总开关,应采用车型能力声明和独立安全审核;插件不得替换 panda 的 TX/RX 安全策略,硬件安全默认态仍为失效安全。所有这些端到端行为(ADB/SSH 真实启停、公钥更新原子性、下载中断与升级回滚、断电恢复)都需在目标板上验证,本文只证明代码路径存在。

    常见问题

    点了「安装」是不是就完成更新了?

    不是。两屏的安装按钮最终都写 DoReboot,并非在 UI 里直接把 overlay 写入当前分区;真正的前台切换由启动脚本完成。UI 的检查与下载也只是请求语义。

    改了 ADB 或 SSH 开关就一定能连上设备吗?

    不一定。源码快照只能确认参数被写入 AdbEnabled/SshEnabled,服务实际启停取决于设备系统层;而且 ADB 控件 offroad 禁用,SSH 开关未使用相同的 offroad 门。

    动作模式切换为什么有时要重启?

    因为 restart_needed_callback() 会写 OnroadCycleRequested,由 hardwared 触发一次行车周期重建(不是操作系统硬重启)。大屏动作模式未调用该回调,mici 调用了,两屏即时生效路径可能不同。

    UI 里的实验模式和驾驶风格为什么有时不显示?

    因为 TogglesLayout 按车型 CarParams 是否有 openpilot 纵向控制动态隐藏实验模式与驾驶风格;没有纵向能力的车型看不到这两项,实验模式首次启用还要求确认。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/SOFTWARE_DEVELOPER_SETTINGS_DEEP_DIVE.md(软件/开发者/驾驶开关三页的 UI 函数、Params 与下游消费者)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:更新请求信号、行车周期重建与两屏锁键差异的静态核对结论。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 座舱 DMS 推理链:1440×960 warp 与 6 维不确定度解码

    结论:座舱 DMS 的链路是 camerad 座舱帧经固定 1440×960 luma warp 进入 tinygrad 编译模型,输出一维向量再按元数据切片解码为左右驾驶员的脸姿态、位置与八类概率。发布消息的顶层 valid 恒为真,它只表示组包正常,不是摄像头视角、帧年龄、标定有效性与模型置信度都合格。本文为固定源码快照 c8fb906 的静态分析,未运行模型或图像回放。

    进程、构建与消息链

    manager 进程表在驾驶员视角条件下启动模型进程,非 webcam 的 PC 默认禁用。main() 等待 camerad 的座舱 VisionIPC,连接后按流报告的宽高加载模型和 warp 产物;每次 recv() 返回缓冲才运行并发布 driverStateV2。模型输出服务名义 20 Hz,真正频率由相机帧与推理耗时决定。进程只订阅 extrinsicsCalibration,没有在循环中对该主题做 SubMaster.all_checks() 门。

    构建侧 SConscript 从 dmonitoring_model.onnx 编译 dmonitoring_model_tinygrad.pkl,并为源码列出的两组座舱相机尺寸生成独立 dm_warp_<宽>x<高>_tinygrad.pkl。warp 目标为 DM_INPUT_SIZE=(1440,960),只使用亮度平面、边界填充值 16、变换矩阵放在 NPY 设备;NV12 stride/高度/缓冲大小由 get_nv12_info() 计算。缺少匹配尺寸的 warp 产物时,模型初始化会在打开文件处失败,而不是自动支持任意 USB 摄像头。

    组件/函数 实现功能 边界
    ModelState.__init__() 读取 OOB 模型,解析输入形状/输出切片元数据,预分配模型输出及相机尺寸对应 warp。 load_oob() 依平台选 QCOM/METAL/CPU:LLVM,构建产物与后端必须兼容。
    ModelState.run() 写入外参 calib,用 VisionBuf 内存指针建立 tinygrad 张量,执行 warp 与模型,拷贝一维 float32 输出。 按缓冲指针缓存零拷贝张量;前提是上游环形缓冲布局和生命周期稳定。
    slice_outputs() 按 OOB 元数据中的切片把一维输出分为各预测头。 分割布局不硬编码在 Python 中;模型升级必须同步元数据。
    parse_model_output() sigmoid 转概率,脸部描述向量末六维经 safe_exp 变为标准差。 safe_exp() 裁上界 11 抑制指数溢出;这里不做完整 isfinite 校验。
    fill_driver_data() 将左右侧各自的姿态、脸位置/标准差和八类概率写入消息。 姿态取描述向量前三维,位置取第 4-5 维。
    main() 接流、读取最新外参、逐帧推理、解码并发送。 只在有新外参消息时复制 rpyCalib,未显式检查其顶层 valid。

    图像几何与内参选择

    首次收到帧后,main() 按 buf.width 是否等于 _os_fisheye.width 选择 _os_fisheye 或 _ar_ox_fisheye 内参,并计算目标模型内参到原相机内参的逆变换;其后不随外参变化重算图像 warp。八项原 main() 假 VisionIPC/模型探针确认:首个非空帧按宽度是否等于 _os_fisheye.width 选择内参、不检查高度,生成的 model_transform 此后锁存,后续缓冲宽度变化也不重算。recv() 为空时立即继续,既不轮询外参也不发布。

    外参 rpyCalib 被单独送进模型 calib 输入,不是这个固定的图像 warp 矩阵。外参长度不匹配会在推理前抛错;推理抛错则本帧不发布。SEND_RAW_PRED 判断的是非空字符串,值为 '0' 同样会携带原始输出字节。两组相机的宽高/焦距来自硬编码配置,源码还提示其中一组鱼眼焦距近似值可能不精确。自制相机不能只通过改 VisionIPC 流名完成适配,需重做标定/warp 与质量验证。

    输出切片与不确定度解码

    slice_outputs() 仅按元数据切片并加 batch 轴,切片超出原向量时得到短数组而不报长度错误。parse_model_output() 把每侧描述向量的末六维经 safe_exp 转标准差,其余前段作为姿态/位置;fill_driver_data() 只取前三项姿态和第 4-5 项位置及对应五项标准差。极大标准差 logit 在 11 截顶,NaN 仍传播;sigmoid 借用这个截顶指数,因此极负 logit 的概率下限约为 1/(1+exp(11)),不是精确零。

    解码同时输出 LHD/RHD 两套人脸结果及 wheelOnRightProb。下游 DriverMonitoring._update_states() 按 RHD 学习结果选择一侧,然后根据脸概率/姿态标准差决定是否把分心类型纳入提醒。因此 driverStateV2 是预测,不是已完成的驾驶员状态判定。八项原切片/解析/组包探针确认:合成的九维脸描述可在位置切片为空时仍由 get_driverstate_packet() 组出顶层 valid=True 消息,这只证明缺少形状校验,不代表真实模型会输出九维描述。

    构造输入 原函数观察结果 验证范围
    切片超出原向量 得到短数组,不报长度错误 只证明切片函数不独立校验长度。
    极负 logit 概率 下限约 1/(1+exp(11)),非精确零 sigmoid 借用 11 截顶指数。
    位置切片为空的九维描述 仍组出顶层 valid=True 消息 缺少字段完整性校验。
    改变 location_ts 假消息各字段不受影响 发布结构不含相机 SOF 时刻。

    发布函数直接创建 valid=True 的顶层消息;它表示此函数正常组包,不是摄像头视角、帧年龄、标定有效性、模型置信度均合格的保证。main() 调用时传入 vipc_client.timestamp_sof,但 get_driverstate_packet() 不使用该参数;消费者只有消息总线自己的 logMonoTime、帧 ID 和模型耗时,不能从这个字段推断原始相机 SOF。策略端的脸姿态转换使用固定 1928×1208 参考尺寸和焦距 598,而相机配置中的 cabin 焦距为 567;自制镜头/warp 不能只替换上游像素流而保留这套转换假设。

    验证现状与插件边界

    本地 dmonitoring_model.onnx 只有 Git LFS 指针,目录未有运行所需的座舱 tinygrad .pkl/warp 文件,故不能在当前归档执行真实推理或验证数值精度。process_replay 定义座舱模型回放,使用 cabinCameraState/VisionIPC 帧作为输入并忽略运行耗时字段;这仅证明仓库有回放入口,不代表当前快照已通过。环境变量 SEND_RAW_PRED 非空时把原始浮点输出写进 rawPredictions;此数据可能含座舱行为信息,日志/插件应考虑隐私和存储边界。

    插件化时,可先把座舱采集和推理作为只读影子插件,契约至少包含像素格式、宽高/stride/缓冲生命周期、内参与 warp 版本、外参来源、模型/输出切片版本、SOF 时间戳、帧年龄、概率及标准差校验、隐私分级。权威 driverStateV2 只能有一个发布者;插件不能把缺帧或低质量预测宣称为「驾驶员注意」,也不能绕开 dmonitoringd 安全策略与独立执行网关。

    常见问题

    driverStateV2 的 valid=True 能说明什么?

    它只表示 get_driverstate_packet() 这个函数正常组包,不是摄像头视角、帧年龄、标定有效性、模型置信度均合格的保证。探针确认合成的九维脸描述在位置切片为空时仍可组出顶层 valid=True 消息。

    座舱相机换成别的分辨率还能直接用吗?

    不能。warp 目标是固定的 DM_INPUT_SIZE=(1440,960),构建脚本只为源码列出的两组座舱相机尺寸生成 dm_warp_<宽>x<高>_tinygrad.pkl。缺少匹配尺寸的 warp 产物时,模型初始化会在打开文件处失败。

    为什么极负的 logit 概率不是 0?

    因为 safe_exp() 把指数上界裁到 11,而 sigmoid 借用了这个截顶指数,所以极负 logit 的概率下限约为 1/(1+exp(11)),并非精确零。

    帧 ID 能用来还原相机的原始时刻吗?

    不能。main() 虽然传入 vipc_client.timestamp_sof,但 get_driverstate_packet() 不使用该参数,发布结构里只有帧 ID、CPU/模型耗时与总线时间戳,不能据此推断原始相机 SOF。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/DRIVER_MONITORING_MODEL_PIPELINE_DEEP_DIVE.md(座舱图像、模型推理与预测契约、输出解码与几何假设)。
    • 项目 02 —— 同目录探针记录:六项初始化产物、八项主循环帧、七项缓冲缓存、八项切片/解析/组包、七项脸姿态几何隔离探针结论。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • modelV2 的三个 valid 位不能合并:modeld 三路输出各差一道门

    结论:modeld 一轮里会依次发布 modelV2、drivingModelData、cameraOdometry 三路消息,名义节拍 20 Hz,但它们是三条独立消息,不是一次原子多主题提交。三者的顶层 valid 语义也各不相同:modelV2 只反映「外参标定曾到达」,drivingModelData 直接复制它,cameraOdometry 还额外要求本轮主相机零丢帧。把三个 valid 合并理解成「模型可靠」是错的。以下为固定源码快照 c8fb906 的静态代码契约分析,不代表模型预测精度。

    三路消息与发布节拍

    manager 进程表让 modeld 在 onroad 运行,服务表把 cameraOdometry、drivingModelData、modelV2 设为名义 20 Hz。真正发布发生在 main() 成功取得相机帧、且模型输出非 None 之后:同轮依次发布三个主题。若外置 Chestnut 大模型推理失败,本轮回退小模型且 model_output=None,这一轮就不发布三路输出,后续才由小模型继续。

    这意味着下游看到的「三路输出」在时间上并非同源同刻,而是同一轮内先后写入;消费进程各自按自己的频率与 valid 判断是否使用。

    组件与关键函数

    ModelState.__init__() 加载 OOB 模型、相机尺寸对应的 warp 与输出切片元数据,建立递归状态输入/输出;这些切片来自编译产物,不是 Python 文件里固定的完整模型 ABI。pack_inputs() 把两路 NV12 帧、变换矩阵、意图、驾驶位与动作时间拼成一次设备上传,连续缓冲布局与 NV12 stride 是模型与硬件的适配契约。

    ModelState.run() 复制两路帧、在意图上升沿产生脉冲、运行 warp 与模型并解码输出。一个易被忽略的不对称:只有 Chestnut 路径显式检查整块原始输出是否全为有限值,常规模型路径没有同等检查。解析侧 Parser.parse_outputs() 负责轨迹、车道、路沿、前车的 MDN 解码与概率的 sigmoid/softmax。

    函数/组件 功能 边界
    ModelState.__init__() 加载 OOB 模型、warp 与输出切片元数据 切片来自编译产物,不是 Python 文件中的完整模型 ABI。
    pack_inputs() 拼两路 NV12 帧、变换矩阵、意图、驾驶位与动作时间 连续缓冲布局与 NV12 stride 是硬件适配契约。
    ModelState.run() 复制帧、意图上升沿脉冲、warp 与模型、解码输出 仅 Chestnut 路径检查整块输出有限性,常规路径无同等检查。
    Parser.parse_outputs() 轨迹/车道/路沿/前车 MDN 解码,概率 sigmoid/softmax parse_mdn() 分出均值/标准差;前车头兼容单/多假设。
    get_action_from_model() 优先取 action 头,缺省从 plan 推导动作 加速度 0.3 s 平滑;车速低于 0.3 m/s 保持上次曲率。

    预测字段、时序与有效位

    ModelConstants 定义 33 个非线性时间采样点,T_IDXS 最远 10 秒,距离采样 X_IDXS 最远 192 米。道路模型对轨迹、四条车道线、两条路沿、三组前车预测与意图/驾驶元信息分别解码。这里有两个必须澄清的常见误读:车道线概率取原始概率头的奇数槽;三条 leadsV3 带 probTime 对应 0/2/4 秒,是三个时间假设,不能理解为三辆已确认的真实车辆。

    帧新鲜度方面,main() 从 narrowRoadCameraState.frameId 与 VisionIPC 主帧 ID 计算 frameAge,从相邻主帧 ID 估计丢帧率;若窄路相机状态尚未出现,frameAge 不足以证明帧新鲜度。模型输入的横纵向 action_t 包含车型/在线估计延迟、约 50 ms 帧时延与半帧预测间隔,这两项帧延迟是固定近似,不是逐帧由采集/执行时钟实测得到,更换自制相机或计算平台时必须重新验证。

    消息 顶层 valid 的真实条件 易误判点
    modelV2 只反映 extrinsics_calibration_seen,即收到外参更新且见过设备/窄路相机状态 更新该标志时没有显式检查外参已校准。
    drivingModelData 直接复制 modelV2.valid 是同源精简消息,不是独立第二次推理。
    cameraOdometry 标定曾到达,且本轮主相机没有跳帧 比 modelV2 多一个丢帧门。

    此外 modelV2.frameDropPerc 被 selfdrived 用于超过 1% 的掉帧事件,但该比例是 10 秒低通且前 10 次暖机后的运行指标,不是每帧硬性失效门。

    告警、控制与融合消费者

    get_action_from_model() 输出 desiredCurvature、desiredAcceleration 与 shouldStop。controlsd 在没有有效 lateralManeuverPlan 时取模型目标曲率,LongitudinalPlanner 消费目标加速度与停车建议;RadarD.update() 取前车预测与模型车速做雷达轨迹融合;LaneDepartureWarning 使用车道线概率;locationd/calibrationd 使用 cameraOdometry 观测而非 modelV2 的行驶动作。它们都仍受状态机、车辆能力、限幅与硬件网关约束,模型不是 CAN 发送者。

    告警侧 fill_model_msg() 在连续硬制动概率缓冲上计算 hardBrakePredicted,由 selfdrived 与驾驶员制动、原车纵向制动状态结合成 FCW 事件——它是告警信号,不是直接制动请求。modelV2.confidence 由预测脱离概率的滚动评分映射绿/黄/红,不是 modelV2.valid 的替代。可选 SEND_RAW_PRED 把数组字节写进消息,但该数组是在解析之后才复制的,softmax() 对其切片做过原地归一化,因此不能无条件把 rawPredictions 当作完全未经后处理的原始张量。

    验证缺口与插件边界

    process_replay 有 modeld 三路输出的回放配置,model_replay 对比参考结果时会忽略时间和部分数值差异。但当前归档缺少 LFS 模型实体及常规模型产物,不能据这些测试定义宣称预测、光学或实车时序已通过;本轮仅为静态核对,没有运行。

    插件化应把图像源、warp、模型、解码器与安全消费分层:影子模型只能发布独立候选主题,权威三路输出仍需单写者;契约至少固定相机内外参、NV12 布局、两路同步阈值、单调时间戳、输出切片 schema、MDN 分布含义、非有限值处理、帧丢失/年龄与三种有效位;控制与 FCW 不可由插件直接绕过 selfdrived 与 panda。

    常见问题

    三个有效位能合并成「模型可靠」吗?

    不能。modelV2.valid 只反映外参标定曾到达,drivingModelData.valid 直接复制它,cameraOdometry.valid 还要求本轮主相机零丢帧。三者语义不同,不能合并为一个可靠性结论。

    三组前车预测是不是三辆车?

    不是。三条 leadsV3 带 probTime 对应 0/2/4 秒的时间假设,不能理解为三辆已确认的真实车辆。车道线概率取的是原始概率头的奇数槽。

    为什么一轮里可能没有模型输出?

    当外置 Chestnut 大模型推理失败时,本轮回退小模型且 model_output=None,这一轮就不发布三路输出,后续才由小模型继续。常规模型路径失败则抛出异常。

    rawPredictions 是未经处理的原始张量吗?

    不完全是。sendraw 复制数组字节发生在解析之后,而 softmax() 已对其切片做过原地归一化,因此不能无条件把它当作完全未经后处理的模型原始张量;它还涉及日志体积与数据隐私。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ROAD_MODEL_OUTPUT_CONTRACTS_DEEP_DIVE.md(modeld 三路消息、MDN 解码、三种有效位与消费链)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:两项意图脉冲契约假推理隔离探针结论。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • selfdrived 告警仲裁:99 个 EventName 里只留 1 条上屏

    结论:同一帧里可以同时成立多个故障事件,但最终屏幕文字、声音与 HUD 图像只会呈现一条告警。selfdrived 用「事件容器(有序但不去重)→ 状态机(按类型优先级)→ AlertManager(按 alert_type 每帧留一条)」这三段流水线完成仲裁,任何一段的边界都会让结果和直觉不同。本文为固定源码快照 c8fb906 的静态分析,不等于独立硬件安全保证。

    从车辆状态到消息输出

    SelfdriveD.__init__() 等待或接收 CarParams,创建车辆事件适配、消息订阅、告警管理器和状态机。carState 用独立 socket 接收,以它作为步进输入;data_sample() 再非阻塞更新其余主题。初始化须满足 CS.canValid && sm.all_checks(),或约 6 秒超时进入初始化完成路径;这不是「超时后所有输入都变有效」,后续仍会创建故障事件。

    step() 每次先采样、聚合事件;只有 CP.passive=False 且完成初始化才调用状态机;随后更新告警并发布消息。params_thread() 约每 100 毫秒刷新单位、车道偏离、油门踏板退出、实验模式和驾驶风格。异步参数刷新与控制步进并行,不能假设 UI 写完 Params 后下一条 selfdriveState 已同步使用全部设置。

    publish_selfdriveState() 将 enabled、active、状态、engageable、告警文案/声音/HUD 图像写入 selfdriveState;engageable 的条件是当前没有 NO_ENTRY 事件,并不等于当前已使能。onroadEvents 每约 1 秒或事件名单变化时发布。controlsd 用 enabled/active 再结合车辆故障、静止门和纵向覆盖决定 latActive/longActive;pandad 还要求 selfdriveState 消息 alive/valid 才把 engaged 告知 panda。软件状态机、控制器活动门和 panda 固件安全门是三个不同层次。

    事件容器为什么不去重

    Events 保存当前帧有序事件、静态事件以及每个 EventName 的连续出现计数。clear() 先更新计数再仅保留静态事件,随后 SelfdriveD.update_events() 按来源追加动态事件。contains(ET) 查询事件的类型映射;create_alerts() 只为本状态允许的类型创建 Alert,并用 creation_delay 抑制短暂出现的告警。

    add() / add_from_msg() 用 bisect.insort 排序,但不去重;重复事件会产生重复的 create_alerts() 结果,计数器仍按「该事件名是否出现」每帧只加一次。static=True 会同时加入静态列表和当前列表,连续 clear() 会保留静态项并继续累计它的计数。创建延迟按 DT_CTRL * (之前连续帧数 + 1) 判断当前帧,等号也允许产生告警;回调在延迟判断前调用,异常直接向调用方传播。

    生产路径并非完全没有重复机会:bigModelFailed 可由失败边沿与活动模型故障段同帧添加,canError 可由雷达错误与车辆 CAN 无效同帧添加;这四项分支由隔离探针确认,但不证明实车同帧共现。同一 EventName 还可以有多个类型,例如 controlsMismatch 同时映射 IMMEDIATE_DISABLE 与 NO_ENTRY,cameraMalfunction 有 PERMANENT/SOFT_DISABLE/NO_ENTRY;哪个 Alert 真正参与显示取决于状态机当前的 current_alert_types。

    事件类型 状态意义 典型例子
    ENABLE / NO_ENTRY 启用请求 / 禁止进入 按键或 PCM 启用;系统初始化、车型能力、故障。
    PRE_ENABLE 进入预使能等待 需要条件继续满足才进入 enabled。
    OVERRIDE_LATERAL / OVERRIDE_LONGITUDINAL 驾驶员覆盖某执行维度 overriding 状态。
    SOFT_DISABLE 给驾驶员接管窗口 摄像头故障、进程失联、通信问题。
    IMMEDIATE_DISABLE / USER_DISABLE 立即退出 / 用户主动退出 安全模式不匹配、CAN 总线丢失、踏板/取消。
    WARNING / PERMANENT 活动状态展示 / 始终候选 转向临时不可用、FCW/LDW、启动与硬件提示。

    状态机的优先级

    StateMachine.update() 每步先递减软退出计时器,并始终加入 PERMANENT 告警类型。非 disabled 状态下,IMMEDIATE_DISABLE 优先于 USER_DISABLE,两者优先于软退出或覆盖。disabled 只有在出现 ENABLE 且没有 NO_ENTRY 时才能进入 preEnabled、overriding 或 enabled。ENABLED_STATES 包含 preEnabled、enabled、softDisabling、overriding;ACTIVE_STATES 不含 preEnabled,所以「enabled」与「执行器正在活动」并非同义词。

    当前状态 条件 结果
    enabled SOFT_DISABLE 进入 softDisabling,计时约 3 秒。
    enabled 横/纵覆盖 进入 overriding,继续 enabled=True, active=True。
    softDisabling 故障消失 回 enabled;若持续至计时结束则转 disabled。
    preEnabled PRE_ENABLE 消失 转 enabled;否则停留预使能。
    overriding 覆盖消失 回 enabled;若出现软退出则转 softDisabling。
    任意非 disabled IMMEDIATE_DISABLE 或 USER_DISABLE 直接变成 disabled。

    SOFT_DISABLE_TIME 是按 DT_CTRL 换算的步数,取决于循环是否按预期执行;最坏墙上时间和驾驶员实际听到提醒的延迟需要实测。源码还有两个易误判的边界:处于 disabled 且同帧有 ENABLE + IMMEDIATE_DISABLE、却没有 NO_ENTRY 时,代码只走 disabled 分支、结果进入 enabled;处于 preEnabled 时若 PRE_ENABLE 消失但 SOFT_DISABLE 仍在,会先进入 enabled 且返回 active=True,下一次调用才进入 softDisabling。

    告警仲裁:一帧只留一条

    AlertEntry.active() / just_added() 用包含端点的 frame <= end_frame 判定有效;只有当前帧紧挨上次加入帧且原条目仍有效才保留旧 start_frame。AlertManager.add_many() 以 alert_type 为键保留告警:每次覆盖该槽的负载和 added_frame,结束帧取 max(frame + 1, start_frame + duration),duration 单位是控制帧而非墙上秒。间隔一帧以上的重加即使旧告警还有效,也会重置 start_frame。

    process_alerts() 先把匹配 clear_event_types 的条目 end_frame 设为 -1,再从仍有效的条目按 (priority, start_frame) 最大值选择。完全同分时使用严格 >,字典中先插入的 alert_type 胜出;同一键的不同负载则由最后一次 add_many() 覆盖,并不产生第二个候选。SelfdriveD.update_alerts() 在不活动时清理 WARNING,已使能时清理 NO_ENTRY。因此同一帧可有多个事件,但只有一个当前显示/声音告警。set_offroad_alert() 另把驻车提示写入 Params,属于另一呈现通道,不参加本次 onroad AlertManager 排序。

    常见问题

    同一帧有多个故障,屏幕上为什么只显示一条告警?

    因为 process_alerts() 只从有效条目里按 (priority, start_frame) 最大值选一条;完全同分时用严格 >,字典中先插入的 alert_type 胜出。事件列表可以有多条,最终呈现的告警只有一条。

    enabled=True 是不是就代表执行器已经动了?

    不是。ENABLED_STATES 含 preEnabled,而 ACTIVE_STATES 不含它;enabled 只表示状态机处于使能族。controlsd 还要结合车辆故障、静止门和纵向覆盖决定 latActive/longActive,pandad 另要求 selfdriveState 消息 alive/valid。

    重复添加的事件会被自动去重吗?

    不会。add() / add_from_msg() 用 bisect.insort 排序但不去重,重复事件会产生重复的 create_alerts() 结果,计数仍按「该事件名是否出现」每帧只加一次。插件接入不应依赖容器自动去重。

    软退出(soft disable)给的 3 秒是按墙钟算的吗?

    不是。SOFT_DISABLE_TIME 是按 DT_CTRL 换算的步数,取决于循环是否按预期执行;最坏墙上时间和驾驶员实际听到提醒的延迟需要实测。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/SELFDRIVE_EVENTS_STATE_ALERTS_DEEP_DIVE.md(事件容器、状态机与 AlertManager 的三段仲裁机制与故障边界)。
    • 项目 02 —— 同目录探针记录:五项 Events 容器、五项状态机转移、五项 AlertManager 仲裁隔离探针结论。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 1024 位 RSA 加 SHA-1:panda 的签名、刷写与扇区边界

    结论:panda 的启动真实性由 bootstub 用「长度门 + SHA-1 + 版本门 + 1024 位 RSA 校验」完成,这是一道启动镜像真实性检查,而不是给运行期 USB/SPI 控制请求做逐请求鉴权。更关键的是:主机端「不许擦 sector 7」只是高层约束,设备端 flash_erase_sector() 实际上接受 sector 7;长度门允许接近 16 MiB,而该 H7 链接脚本声明的 flash 只有 1 MiB。以下基于源码快照 c8fb906 的静态阅读与宿主隔离探针,未运行刷写工具、未做实板测试。

    构建制品与签名字节契约

    SConscript 分别构建 bootstub 与 app,app 入口放在 0x08020000,随后以 SETLEN=1 调用 sign.py。签名脚本在原始 app 尾部追加 VERS 与版本 2,把首 4 字节重写为「签名覆盖区长度」,再以 SHA-1 对首 4 字节之后的内容计算摘要,最后附加 128 字节 RSA 签名。普通构建使用 debug 私钥并启用 ALLOW_DEBUG;RELEASE 构建需提供证书/密钥路径。生成的 cert.h 含 debug/release 公钥,但构建期未见核对 CERT 私钥是否与 release.pub 配对。

    环节 字节/常量 说明
    签名长度 RSANUMBYTES=128 公钥模数在构建时须为 1024 位
    填充前缀 00 01 + 105 字节 FF + 00 + 20 字节摘要 PKCS#1 v1.5 风格,但无标准 SHA-1 DigestInfo/ASN.1 编码
    十六进制补齐 rjust(0x100,'0') = 256 字符 写入镜像时转换为 128 字节
    摘要长度 20 字节 验签只接受 128 字节签名与 20 字节摘要

    验签函数在做完公钥运算后,把末尾摘要与预期值异或归零,并逐字节比较整个 128 字节固定模板。因此它只处理这一种签名格式,不应直接当作通用 RSA-SHA1 校验;源码注释里出现的「2048-bit」和 SHA-256 与当前启用的常量及检查不符。

    上电到跳转:bootstub 做哪几道检查

    early_initialization() 先初始化寄存器、浮置早期 GPIO、识别板型;遇到 ROM 模式标志或芯片 ID 不符会走 jump_to_bootloader()。此后 bootstub.main() 先处理 softloader 标志,再读 app 长度、计算 SHA-1、检查 VERS/最小版本,最后用发布公钥验签;仅 ALLOW_DEBUG 构建额外接受调试公钥。失败进入 soft_flasher_start(),成功则调用 app 向量。

    摘要实现是标准 SHA-1:SHA_hash() 依次调用 SHA_init()、SHA_update()、SHA_final(),64 字节分组经 SHA1_Transform() 处理,末尾补 0x80、零填充及 64 位比特长度,输出 20 字节大端摘要。bootstub 只哈希首字长度之后的 len-4 字节,签名本身与长度字都不在哈希区;长度字虽不参与摘要,却控制被读取的范围和签名位置。SHA-1 原实现通过空串、abc、分块 abc、56 字节填充边界与百万个 a 五类宿主标准向量。

    扇区与刷写边界:主机保护不等于设备保护

    存储布局上,sector0 放 bootstub,sector1 至 6 放 app,sector7 放设备序列与制造数据。主机端的 Panda.flash_static() 会用 last_sector < 7 阻止正常客户端擦除 sector 7,但设备端 flash_erase_sector() 只拒绝 sector 0,允许在解锁状态下擦除 sector 1 至 7。也就是说,直接发 EP0/EP2 的主机可以绕过这一层。EP0 控制请求 0xb1 解锁、0xb2 擦除、0xd1 进 ROM/softloader、0xd8 刷新并复位;comms_endpoint2_write() 从 app 起点顺序写 32 位字,源码未见对写指针、镜像长度或 provisioning 区域做边界检查。

    边界 源码事实 需要台架确认的点
    扇区保护 主机禁擦 sector 7;设备端仅拒绝 sector 0 经原始维护接口能否破坏制造数据
    长度门 bootstub 允许镜像长度接近 16 MiB 超出 1 MiB flash 后读/故障表现
    版本门 只要求 vers[1] >= 2 是否另设单调计数器防降级

    关键函数与地址契约

    下表整理了引导路径上与签名、刷写、身份相关的实现及其边界。它们是理解「哪一层保证什么」的最小清单:

    函数/定义 实际作用 边界
    bootstub.main() 用首字长确定签名位置,哈希 app 主体,校验版本与 RSA-SHA1 签名 长度上界接近 16 MiB,超出 1 MiB flash 声明
    RSA_verify() 检查公钥结构、128 字节签名、20 字节摘要及固定填充模板 只处理该格式,不宜泛化为新硬件的完整安全启动方案
    SHA_hash() 对镜像被签区做 SHA-1 分组计算并输出 20 字节摘要 只覆盖调用者传入的 len-4 字节;memcpy 宿主声明有 ABI 警告
    get_key_header() 从固定公钥文件生成固件公钥常量 公钥与 CERT 私钥的配对关系未见构建期检查
    flash_erase_sector() 拒绝 sector 0,已解锁时擦除 sector 1 至 7 sector 7 含制造数据,设备端并未排除

    五项隔离探针把原版 rsa.c、sha.c 编译为临时共享库,按 debug 私钥构造无 DigestInfo 固定填充签名后经 ctypes 调用原版验签:有效签名返回 1,摘要/签名字节被改、标准 SHA-1 DigestInfo 填充、错误签名或摘要长度、错误公钥字数、不支持的指数均返回 0。合成镜像另核对首字长度、末尾 VERS/版本 2 与 128 字节签名,改动被签主体后验签失败。

    常见问题

    bootstub 验签通过,是不是就代表设备运行期安全?

    不是。它校验的是启动镜像的真实性与完整性,并没有给运行期的 USB/SPI 控制请求做逐请求鉴权。EP0 解锁、擦除、EP2 写入等维护命令仍会被接受,因此需要把签名密钥与刷写权隔离到受控维护进程。

    为什么主机端禁止擦 sector 7 还不够?

    因为那是主机库的约束。flash_erase_sector() 只拒绝 sector 0,允许已解锁时擦除 sector 1 至 7,而 sector 7 含设备序列与制造数据。直接发 EP0/EP2 的主机不受「last_sector < 7」限制。

    版本号能防止固件降级吗?

    不能作为单调防回滚证明。启动检查只要求 vers[1] >= 2,源码中未见与设备单调计数器或持久最小版本比较。硬件改版若需防降级,应另行设计并保留救援路径。

    这套 1024 位 RSA 加 SHA-1 能直接搬到新硬件吗?

    不宜照搬。该验签只处理当前这种非标准固定填充格式,源码注释里的「2048-bit」与 SHA-256 也与实际常量不符。新硬件应先定义清晰格式与测试向量,再决定算法与密钥长度。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PANDA_BOOTSTUB_SIGNATURE_FLASH_BOUNDARY_DEEP_DIVE.md(bootstub 签名、刷写与制造数据边界,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_panda_rsa_signature_contract.py(RSA 与签名脚本五项宿主隔离探针)
    • 项目 02 —— openpilot-research/probes/panda_sha1_vectors_contract.c(SHA-1 标准向量宿主探针)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • Athena 片段导出:createClip 入队不校验时间顺序、码率与倍速

    结论:离线视频片段导出由 Athena RPC 排队:createClip() 入队、getClipState()/getClipChunk() 读取、deleteClip() 取消,工作线程把起止时间映射成连续的 60 秒分段,先写到 clips/.<filename> 点号临时文件,编码成功后再用 os.replace() 原子改名。关键边界是:入队层只校验 route、摄像头文件名与输出 basename,不校验时间顺序、bitrate 或 speedup,而 PC 回退路径没有 comma 硬件那套校验;硬件工作器按 floor(start*20) 到 ceil((start+duration)*20) 选帧,输出按 20 FPS 重建。本文为对源码快照 c8fb906 的静态阅读与假依赖探针,未在 comma 设备上运行 Qualcomm V4L2 解码/编码,也未生成实际 MP4。

    从远程请求到可下载文件

    VideoClips.createClip() 在非 PC 设备上仅在 IsOffroad 时接受创建;它校验 route、摄像头文件名和输出 basename,并按输出文件名维护任务。_worker() 依据起止时间映射连续 60 秒分段,先输出到点号临时路径,编码后确认仍是同一任务对象才 os.replace() 为正式文件;异常时移除当前队列项并尝试清理临时文件。

    _encode() 负责选后端:PC 用 ffmpeg concat/libx264,comma 硬件调用 encoderd --clip,码率由 Mbps 转 bps,并传递倍速与 JSON 元数据;子进程非零退出视为失败。注意片段与 uploader 的自动 qlog 上传不同:生成并经分块读取或显式上传接口交付,不能把 clips/ 目录出现当作云端已持有视频。

    边界 入口 已核对行为
    入队 VideoClips.createClip() 非 PC 仅 IsOffroad 时接受;校验 route、摄像头文件名与输出 basename;不校验时间顺序、bitrate、speedup
    工作线程 VideoClips._worker() 按起止时间映射连续 60 秒分段,先写 clips/.<filename>,编码后仍是同一任务才原子改名
    后端选择 VideoClips._encode() PC 用 ffmpeg concat/libx264;硬件调 encoderd --clip,码率 Mbps→bps,传倍速与元数据
    状态/读取 getClipState() / getClipChunk() 成品需 ffprobe 读到元数据且 requested_at 为数值才列 ready;同名内存任务覆盖磁盘 ready
    取消/删除 deleteClip() 先从内存队列移除,再在锁内终止同名编码进程,最后删除正式输出文件

    _available_ranges() 依赖任何以 camera.hevc 结尾的文件是否存在,据此推算名义 60 秒范围,并不探测真实视频长度;这就是「名义时长」与「实际可解码时长」分离的来源。

    RPC 层契约:隐藏名、负偏移与倒置区间

    四组原函数隔离探针确认了入队层与读取层的多处不对称。PC 分支可把结束时间早于起点、负码率和零倍速原样加入内存任务;非 PC 分支则在入队前要求 IsOffroad。.hidden.mp4 会通过 createClip() 的 basename 检查,却被 getClipChunk() 的「不得以点号开头」检查拒绝,_on_disk() 也跳过隐藏名。这种「可排队却不可读取」是层间契约不一致,而非异常参数一定编码成功。

    读取侧还有两处细节:getClipChunk() 接受等于文件长度的偏移并返回空数据,同时拒绝负数与非整数偏移;状态查询用 re.search 允许带前后缀的 route 字符串提取合法路段,而创建请求用 re.fullmatch。工作器探针还确认,请求 10–130 秒会映射到三个名义 60 秒段,向 _encode() 传相对起点 10 秒、时长 120 秒;真正如何拒绝倒置区间(如 60→-1 秒)未在探针执行,PC/硬件编码器的实际拒绝行为需另行验证。

    encoderd --clip 与硬件工作器

    encoderd.main() 仅在 comma 硬件编译分支接受 --clip,解析 OUTPUT START DURATION、--bitrate、--speedup、--metadata 与输入段,转入 encode_clip()。该函数要求非空输入、有限且非负的起点、正的时长/码率/倍速,并要求请求落在「输入文件数 × 60 秒」的名义范围内;它会跳过起点之前的整段,检查首段视频编码为 HEVC 且宽高有效。

    环节 实现 边界
    参数校验 encode_clip() 要求非空输入、有限非负起点、正时长/码率/倍速 名义范围 = 输入文件数 × 60 秒,不等于每段实际可解码时长
    解码 FFmpeg 解复用 HEVC,input_frame+1 作 token 排入 V4LDecoder.queuePacket() 捕获面 token 经 handleCapture() 还原源帧序号
    抽帧 按 floor(start*20) 到 ceil((start+duration)*20) 选帧,speedup 用全局帧偏移模运算 跳过的解码面立即归还,送编码器的面由回调释放
    输出 frame_id 与 timestampSof/Eof 从零按 20 FPS 重建 倍速靠抽帧实现,输出仍标注为 20 FPS,不保留原始 SOF/EOF

    V4LEncoder::encode_frame() 提交 NV12_UBWC 面,避免解码面过早重用;工作器发 EOS、等待已排输入有对应解码结果,最多连续五次空轮询,随后关闭编码器并检查实际抽帧数。VideoWriter::write() 用微秒时间戳写 MP4,析构时写 trailer 并移除 .lock。

    长片并行与失败边界

    对不足两个剩余段或时长小于 120 秒的请求,encode_clip() 串行处理;其余请求按接近中点的段边界拆成两个工作器:第一段直接写目标,第二段先写到已打开后 unlink 的临时 spool,两个工作器完成后按第一段编码帧数换算微秒偏移,回放第二段包并拼接。并行分支的 frame_offsets 保证倍速抽帧跨分片连续,第二段 codec config 被跳过而沿用第一段输出头。

    源码显式拒绝短的中间段,以免把源视频缺口误拼成连续时间;但工作器的成功条件比较的是实际读到的 input_frame 所推导的帧数,没有强制 input_frame >= end_frame。因此最后一段提前结束而仍有部分选中帧时,静态看可能以短于请求时长的文件返回成功,_available_ranges() 也会把末段按满 60 秒展示。这需要截断末段的实板样本或硬件回放来验证,不能仅凭退出码保证时长完整。

    常见问题

    createClip 会拒绝不合理的时间区间吗?

    入队层不会。PC 分支可把结束时间早于起点、负码率和零倍速原样加入内存任务;非 PC 分支只额外要求 IsOffroad。真正校验落在硬件编码入口 encode_clip(),而 PC 回退路径没有同一套硬件校验。

    clips 目录出现文件就等于云端已持有视频吗?

    不等价。片段由 Athena RPC 排队、生成,再经分块读取或显式上传接口交付,与 uploader 的自动 qlog 上传是两条路径;目录里出现文件只说明本地已生成。

    为什么导出的片段可能比请求时长短?

    工作器的成功条件只比较实际读到的 input_frame 推导帧数,没有强制 input_frame >= end_frame;末段提前结束而仍有部分选中帧时,静态看可能以更短文件返回成功,_available_ranges() 还会把末段按满 60 秒展示。

    倍速导出会改变帧率吗?

    不会标注变化。倍速通过全局帧偏移的模运算抽帧实现,输出 frame_id 与 timestampSof/Eof 从零按 20 FPS 重建,文件仍标为 20 FPS。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CLIP_EXPORT_DEEP_DIVE.md(Athena 片段 RPC、worker 分段与 encoderd --clip 硬件工作器)
    • 项目 02 —— openpilot-research/V4L_DECODER_RECONFIG_SURFACE_LIFECYCLE_DEEP_DIVE.md(源文档指向的硬解端口重配与外借面归还专题)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11