标签: 相机视觉

  • 仿真相机与传感器:图像缓冲、采样节拍及有效位

    结论:仿真感知链适合状态机和流程回放,但不能替代传感器校准、时钟同步、panda 安全钩子或物理总线测试。发布时戳不是物理曝光时戳——仿真把同一个 logMonoTime 同时当作 VisionIPC 的 SOF 与 EOF;重复样本也不等于传感器频率——主循环每圈发 5 条加速度、5 条陀螺仪与 10 条 GPS。所有频率推导均以 SimulatorBridge 主循环达到标称 100 Hz 为前提,实际频率未测。固定快照基线为 c8fb906。

    图像从 MetaDrive 到 VisionIPC

    MetaDriveBridge.spawn_world() 为窄路相机及可选广角相机配置 RGB 图像源,分辨率取 W=1928, H=1208。MetaDriveWorld 用 multiprocessing.Array 暴露两块 RGB 内存;metadrive_process() 把渲染图像写入共享数组后对 image_lock.release() 计数;桥接侧另有目标 20 Hz 的相机线程,在 send_camera_images() 开头 acquire() 一次,再把当前数组转为 NV12 并发布。

    这里的 image_lock 实际是初值 0 的 multiprocessing.Semaphore,用于等待至少一帧,而非读写互斥:生产者写图前不获取它,消费者转换 RGB 时也不阻止下一次写入。因此静态上存在读取一幅图像过程中被生产者覆盖的窗口;若生产速度超过消费者,semaphore 计数积压,消费者可能对同一块「最新图像」连续取样。是否出现撕裂或重复帧需要像素哈希 / 帧号探针验证。

    环节 实现事实
    色彩转换 rgb_to_nv12() 用整数 BT.601 近似系数生成 Y,2×2 box filter 后交错 U / V;要求偶数宽高;调用方固定断言 1928×1208×3 且 uint8。
    缓冲 Camerad.__init__() 为每路建立 5 个 VisionIPC 缓冲;窄路总建,广角按 dual_camera 决定。
    发布 _send_yuv() 先构造外层 Event.valid=True 的 *CameraState,把同一个 dat.logMonoTime 同时当作 SOF / EOF,再发布独立递增的仿真帧号和单位变换矩阵。
    有效位 底层 VisionIpcServer.send() 另把 extra.valid 固定为 False;CameraState 外层有效位与 VisionIPC extra 有效位不同。

    三项原类探针确认单路只分配窄路五个缓冲、双路帧号分别从零递增、图像发送先于状态消息发布,且每帧 SOF = EOF。它是仿真 RGB 走 CPU 拷贝的路径,不证明真实 ISP 的色彩、曝光或 DMA 缓存行为。探针还确认:若假 VisionIPC 已接受图像而假 PubMaster.send() 抛错,cam_send_yuv_road() 尚未递增帧号;调用方若捕获异常并再次调用,会用同一帧号发送另一份图像、但产生新时间戳——这是函数边界的部分提交状态,不证明现有桥接线程会自动重试。

    IMU、GPS、驾驶员与外设占位

    SimulatorBridge._run() 以目标 100 Hz 的主循环调用 SimulatedSensors.update(),另起目标 20 Hz 相机线程。每次 update() 中,send_imu_message() 连续发布 5 条加速度和 5 条陀螺仪消息,数据都取同一份 SimulatorState.imu,各条的内层 timestamp 使用各自消息的 logMonoTime,并非模拟器采样时刻。

    消息 仿真节拍与标称值
    IMU(加速度 / 陀螺仪) 每圈各 5 条;100 Hz 主循环下约 500 条 / 秒 / 主题;服务表标称 104 Hz 不是实际硬件节拍。
    GPS 仅在 simulator_state.valid 时,每圈发布 10 条 gpsLocationExternal,约 1000 条 / 秒;服务表标称 10 Hz。
    驾驶员状态 update() 以 DT_DMON/2 检查驾驶员消息,以 0.25 秒检查外设状态,并非严格 20 / 4 Hz 定时器。

    send_gps_message() 把当前系统墙钟填成 unixTimestampMillis、固定 flags=1、精度常量和 source=ublox;这些重复包不能用于证明 GNSS 热启动、时钟校准、观测协方差或实际链路吞吐。send_fake_driver_monitoring() 把两侧脸概率置 1、分心置假、注意力置满;driverMonitoringState 外层显式有效,而 driverStateV2 使用 new_message() 默认外层 valid=False。不可把这些「健康」字段当作自研传感器或驾驶员监测的物理验证。

    假时钟探针还确认两个节拍门都使用严格 >:恰好到 0.25 秒时不发外设状态,到 0.251 秒才发;只有发送后才更新对应的上次发布时间。无效仿真状态并不阻止驾驶员 / 外设占位消息,且 driverStateV2.valid=False 与 driverMonitoringState.valid=True 会同时出现。

    退出、测试和插件化边界

    仿真启动脚本 launch_openpilot.sh 设置 NOBOARD=1、SIMULATION=1,屏蔽真实 camerad、encoderd、loggerd 等进程。主循环、相机线程和 MetaDrive 子进程的生命周期分散在桥接层;相机线程的 Semaphore.acquire() 没有超时,模拟器不再产帧时单靠线程 exit_event 不能立即唤醒它。现有 test_driving() 被 unittest.skip 标记,不能据此声称当前闭环已通过。

    自研硬件插件应分别验证图像采样帧号、读出时戳、消息外层 / 内层有效位、stride 与缓冲持有期,以及 IMU / GNSS 的独立采样时刻和真实频率。建议先在隔离命名空间记录每路源帧号、共享内存哈希、发布时戳和消费时戳,再注入掉帧 / 停发 / 速率偏差。

    常见问题

    仿真里 SOF 和 EOF 为什么不等于曝光时间?

    因为 _send_yuv() 把同一个 dat.logMonoTime 同时当作 VisionIPC 的 SOF 与 EOF,表示「在软件发布时打的同一时刻」,不是传感器曝光 / 读出测量。它是仿真 RGB 走 CPU 拷贝的路径,不证明真实 ISP 行为。

    「CameraState 有效」和「图像有效」是一回事吗?

    不是。仿真里 *CameraState 外层 Event.valid=True,而底层 VisionIpcServer.send() 把 extra.valid 固定为 False。两者是不同层级的有效位,必须分别检查。

    仿真里 IMU 和 GPS 的频率是多少?

    取决于主循环实际频率。每次 update() 发 5 条加速度、5 条陀螺仪与 10 条 GPS;若主循环确为 100 Hz,则约 500 条 / 秒 / IMU 主题、约 1000 条 / 秒 GPS,而服务表标称 IMU 104 Hz、GPS 10 Hz。重复样本不等于传感器频率。

    仿真通过能代替传感器标定吗?

    不能。仿真链适合状态机和流程回放,但不能替代传感器校准、时钟同步、panda 安全钩子或物理总线测试。若要验证自研硬件,应分别记录源帧号、共享内存哈希、发布 / 消费时戳,并注入掉帧、停发与速率偏差。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/SIM_CAMERA_SENSOR_TIMING_DEEP_DIVE.md(取用了图像缓冲与 semaphore 行为、IMU / GPS 仿真节拍、有效位与插件化边界分析)。

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

    更新记录

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

  • camerad 原生相机请求环:缓冲生命周期与 enabled 门

    结论:camerad 的原生相机链是一条带请求环的资源生命周期:分配缓冲 → 每帧取槽(request_id % ife_buf_depth)→ 建立 IFE/BPS 同步栅栏 → 调度 req_mgr → 先 poke 传感器再配置 ISP → 消费 SOF 事件、等待栅栏 → 销毁栅栏并补排同槽。它对 Qualcomm/AGNOS 特定 ABI 强耦合,不是通用 V4L2 接口。最需要记住的边界是:enabled 不是运行时发布闸门,且「配置关闭但仍探测」的失败路径不会在析构时执行显式释放。

    一、重要组件与函数

    组件 关键入口 静态行为
    平台主句柄 SpectraMaster::init() 打开 req_mgr、cam_sync、ISP、ICP,查询 ISP/ICP 非安全 IOMMU 句柄并订阅内核 SOF 事件;这是 Qualcomm/AGNOS 特定 ABI,非通用 V4L2。
    命令内存 MemoryManager::alloc_buf()、free() 按精确大小缓存 req_mgr 分配的命令缓冲,取出时 memset;free() 只返还缓存,析构时才 munmap、关闭关联 FD 并释放内核 handle。
    相机开路 camera_open() 探测/获取传感器,计算输出尺寸,配置 ISP/ICP/CSIPHY、链接设备,分配并映射 RAW/VisionIPC 缓冲,预排一环请求;ife_buf_depth 取 VIPC_BUFFER_COUNT 且断言小于 20。
    图像缓冲 CameraBuf::init()、camera_map_bufs() 座舱分配 RAW VisionBuf,三路按各自 stream 创建 NV12 VisionIPC 缓冲,再将帧 FD 映射到 ISP、必要时 ICP 的 IOMMU。
    每帧请求 enqueue_frame()、sensors_poke() 用 request_id % ife_buf_depth 选槽,创建 IFE/BPS sync 对象,向 req_mgr 调度,随后提交传感器 NOP 包,最后配置 IFE 与可选 BPS;源码明确要求 poke 在调度之后。
    事件消费 camerad_thread()、handle_camera_event() poll(POLLPRI) 获取事件,按 session 分给对应相机;通过请求号/帧号校验与栅栏等待后处理帧,销毁当前槽 sync 对象并补排 request_id + ife_buf_depth。
    失败重排 clear_req_queue()、clearAndRequeue() flush ICP 和 req_mgr,请求环所有槽销毁 sync 对象,从新 request ID 预排一环,并以 last_requeue_ts 跳过旧 SOF;重新入队不保证图像时间连续。
    关闭 camera_close()、析构函数 open 为真才调用关闭;关闭内仅在 enabled 为真时 flush、停 ISP/ICP/PHY、unlink 和释放帧映射。

    这些资源的所有权也要分清楚:命令内存的缓存复用只用于 req_mgr 分配的命令缓冲,不等于图像帧缓冲可以无限复用。座舱分配 RAW VisionBuf,三路按各自 stream 创建 NV12 VisionIPC 缓冲,再把帧 FD 映射到 ISP、必要时 ICP 的 IOMMU。而 camera_close() 仅在 open 为真时调用,关闭内又仅在 enabled 为真时 flush、停 ISP/ICP/PHY、unlink 并释放帧映射,因此两个布尔门并不等价于完整的资源所有权。

    二、请求时序与故障语义

    validateEvent() 对 requestId=0 累计次数,超过深度加 2 后重排;正常帧若帧号或请求号跳跃且 skip_expected 为假,也重排。waitForFrameReady() 对 IFE 最多等 100 ms,对存在的 BPS 栅栏再等 50 ms;失败时 handle_camera_event() 不发布该帧,而是清队列重排,以避免读取可能尚未写完的图像。即使 processFrame() 因初始跨相机同步返回 false,只要栅栏完成,当前槽仍会销毁并补排;同步等待失败则由全环重排负责销毁。这些路径的帧缺口和新鲜度应在消费者侧单独观测。

    触发条件 处理方式
    requestId=0 累计超过深度加 2 全环重排。
    正常帧帧号或请求号跳跃且 skip_expected 为假 重排。
    IFE 等待超过 100 ms 或 BPS 栅栏超过 50 ms 不发布该帧,清队列重排。
    processFrame() 初始跨相机同步返回 false 但栅栏完成 当前槽仍销毁并补排。

    三、传感器 NOP 失败后的状态

    sensors_poke() 为当前请求构造无命令缓冲的 CAM_SENSOR_PACKET_OPCODE_SENSOR_NOP 包,它不是新一组曝光寄存器写入。enqueue_frame() 先调度 req_mgr,再调用 poke,最后提交 IFE/BPS 配置。若 device_config() 失败,poke 只记录错误并把 enabled=false,void 返回使调用方无法据此停止后续配置;即便先前的 req_mgr 调度失败,代码也没有跳过 poke 或 ISP 提交。

    enabled 不是运行时发布闸门:handle_camera_event() 没有检查该标志,栅栏完成后仍会补排;camerad_thread() 只按 session 匹配并在返回真时调用 CameraState::sendState(),后者也未按 enabled 阻断图像/状态发布。曝光函数会因 enabled=false 直接返回,但若内核继续给出有效事件,主机代码仍可能发布帧;反过来,若请求不再完成,也可能等待/重排。静态源码不能判定实板最终行为。新硬件适配器应把 NOP/调度/IFE 提交失败作为独立错误状态上报,统一停止排队、发布和清理,而不只改变一个布尔值。

    四、显式释放与硬件移植风险

    1. 双状态门不等价于资源所有权:构造器以配置中的 enabled 初始化状态,但 camera_open() 总先调用 openSensor();后者成功时已经创建 session、获取传感器句柄并写初始化表,再遇 !enabled 才返回,且尚未设置 open=true。因此「配置关闭但仍探测」的路径没有在析构中执行显式 CAM_RELEASE_DEV/DESTROY_SESSION。源码中也有 I2C 失败将 enabled=false 的分支;若在 open=true 后触发,则 camera_close() 会跳过 ISP/ICP/PHY、link 和帧映射的显式释放。进程退出或 FD 关闭后内核是否自动回收,须按驱动和故障注入验证。
    2. 命令失败与后续使用:enqueue_frame() 记录 sync 创建或调度失败,却仍继续 sensor poke、IFE/BPS 配置;后续等待路径断言 IFE sync 对象非零。另 camera_map_bufs() 对 YUV FD 映射结果只记录日志,没有像 RAW 映射一样断言成功。需在台架注入分配、映射和调度失败,验证实际错误传播与重启策略。
    3. 插件边界:图像插件可以提供带 frameId、SOF/EOF、格式和缓冲生命周期契约的帧,但 req_mgr、IOMMU 句柄、sync 对象及内核 ioctl 应由受控平台适配器独占。支持热拔插前,应把句柄获取/释放写成与 enabled 无关的资源状态机,并验证消费者在重排或摄像头禁用时不会把旧帧当新帧。

    常见问题

    ife_buf_depth 有上限吗?

    有。camera_open() 中 ife_buf_depth 取 VIPC_BUFFER_COUNT 且断言小于 20;每帧用 request_id % ife_buf_depth 选槽,因此该值同时决定请求环的槽数与补排步长。

    栅栏等待失败后会发生什么?

    waitForFrameReady() 对 IFE 最多等 100 ms、对存在的 BPS 栅栏再等 50 ms;失败时 handle_camera_event() 不发布该帧,而是清队列重排,以避免读取可能尚未写完的图像。重新入队不保证图像时间连续。

    enabled=false 会阻止帧发布吗?

    不会自动阻止。enabled 不是运行时发布闸门:handle_camera_event() 不检查该标志,sendState() 也未按它阻断发布。曝光函数会因 enabled=false 直接返回,但若内核继续给出有效事件,主机代码仍可能发布帧。

    配置关闭但仍然探测的路径会释放资源吗?

    不会执行显式释放。camera_open() 总先调用 openSensor(),它成功时已创建 session、获取句柄并写初始化表,再遇 !enabled 才返回且尚未设置 open=true;因此析构中不会执行显式的设备释放。内核是否自动回收须按驱动和故障注入验证。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CAMERA_REQUEST_BUFFER_LIFECYCLE_DEEP_DIVE.md(取用了相机请求环组件清单、请求时序与故障语义、NOP 失败后的状态、显式释放与移植风险)。

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

    更新记录

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

  • 桌面片段渲染:50 ms 日志分桶、帧号映射与 Params 前缀

    结论:桌面片段渲染器 tools/clip/run.py 与车端 encoderd --clip 是两套不同实现。它把日志按 50 ms 分桶、按 20 FPS/60 秒映射帧号、再用原车载 UI 渲染并用 FFmpeg 录成 MP4。三个最容易踩的边界是:分桶起点用每段首条日志的单调时间而非视频首帧 SOF;patch_submaster() 会把 valid 等标志全置真、不保留原始 Event.valid;GuiApplication 录屏等待编码子进程却不检查非零退出码。

    一、入口和分层调用

    阶段 关键函数 数据契约
    参数与录制配置 parse_args()、setup_env() 解析路段、秒数、输出、目标 MB、倍速和镜头;设置 RECORD/RECORD_OUTPUT/RECORD_SPEED/RECORD_BITRATE,这些值在随后导入 GUI 模块时成为录制配置。
    文件来源 Route.__init__()、Route.log_paths() 本地目录或 API 远端索引按段号组装列表,缺段位置为 None;默认 rlog,不从 qlog 抽样复原。
    日志分桶 load_logs_parallel()、_parse_and_chunk_segment() 线程并行读取完整段字节,进程池解码并迁移,按首消息时间起的 50 ms 窗口保存每主题最近一条 Event。
    UI 数据注入 patch_submaster()、UIState.update() 把 UI 的 SubMaster.update 替换为逐帧写入消息快照,再运行普通 UI 状态更新和增强道路视图。
    相机取帧 iter_segment_frames()、FrameQueue 全局帧号按 20 FPS / 60 秒映射到段内帧;后台线程预取 NV12,主线程推送本地 VisionIPC。
    合成与文件 clip()、render_overlays()、GuiApplication.init_window() 用原车载 UI 渲染画面,叠加可选路段/时间/标题,GuiApplication 经 FFmpeg 录制输出 MP4。

    二、时间、图像与消息一致性

    _parse_and_chunk_segment() 以每段首条日志的单调时间为起点,而不是该段视频首帧 SOF。一个 50 ms 桶只保留同主题最后一条;时间戳跨越多个桶时只追加一次旧桶并把 next_time 跳到新消息附近,不会补齐中间空桶。clip() 随后用 frame_start 和 duration * 20 直接切这个桶列表;若日志起点偏离视频起点、消息缺口或某段不足 60 秒,桶序号和图像帧号可能错位或片段短于请求。程序只检查切片非空,不验证切片长度等于请求帧数。

    时间基准 来源 与真实相机时间的关系
    日志分桶起点 每段首条日志的单调时间 与视频首帧 SOF 可能不一致,是桶序号与帧号错位的隐患来源。
    帧号映射 20 FPS / 60 秒 按段内帧计数,不校验切片长度是否等于请求帧数。
    VisionIPC SOF/EOF frame_idx * 50 ms 起点为零且二者相同,并非原车相机时间。

    patch_submaster() 对存在于 UI 服务表中的消息把 seen、updated、alive、valid 全置真,并把原始 logMonoTime 与当前本机接收时间混合登记;它没有保留原 Event.valid,未出现的新主题也不会在此函数中被显式标成失活。因此导出画面可显示出原始日志里曾无效或已失联的数据,不宜作为告警新鲜度的权威回放。VisionIPC 发送则用 frame_idx * 50 ms 为 SOF/EOF,起点为零且二者相同,并非原车相机时间。

    --qcam 通过 FFmpeg 将整段 qcamera 解码为 NV12 并保留在内存数组;普通 fcamera 走 FrameReader.get() 随机访问。FrameQueue 只从首个非空相机路径探测宽高,后续段没有统一尺寸校验;缺目标段时主动抛错。宽路镜头只要所选区间中存在任意 ecamera 路径就整体启用,但其中某段缺文件时仍会按每一帧取宽路,导致该段失败。

    三、Params、资源和失败边界

    clip() 在进入 OpenpilotPrefix 之前导入全局 ui_state。UIState._initialize() 当场构造 Params(),而 Params::Params() 在构造时按当前 OPENPILOT_PREFIX 固定路径。随后 load_route_metadata() 在新 Prefix 中把路段 initData.params 写入另一个 Params 命名空间,但已存在的 ui_state.params 仍指向旧路径,因此 UI 的参数刷新不保证读到该路段快照。另外 load_route_metadata() 假定第一个可用日志含 initData,否则访问会抛错;缺头的路段即使视频/其他日志可用也可能提前失败。

    FrameQueue._worker() 异常后在 finally 中投递终止哨兵,队列满而消费停止时该 put() 也可能阻塞;stop() 最多等线程 2 秒。clip() 只在正常渲染循环后显式停队列、关 GUI,没有包住整个运行链的 finally;解码、VIPC 或 UI 异常时清理可能不完整。GUI 的 close_ffmpeg() 会等待编码子进程,但不检查非零退出码,因此 clip() 之后打印「saved」并不等于文件可解码。RECORD_OUTPUT 被强制改成 .mp4 后,成功日志却打印原始 -o 路径,非 .mp4 后缀输入会造成日志路径与实际文件名不一致。

    parse_args() 只要求结束时间大于起始时间,没有对负起点、零/负倍速做完整校验;默认正的目标体积下 --speed 0 会在码率计算中除零。若选中的 rlog 路径列表含 None,并发读取阶段就会失败;这与「部分路段可预览」的语义不同。

    四、面向硬件和插件化的约束

    重构建议是把路段来源、时间轴对齐、相机解码、消息状态复原、UI 渲染和文件写入划为独立插件接口,但输入必须带不可变路段 manifest(段号、内容哈希、首末单调时刻、帧 ID、相机 SOF/EOF、schema 版本)。回放宿主应独占隔离 Params/msgq/VisionIPC 命名空间,并在导入 UI 单例之前建立命名空间;不能让路段 Params 落到生产配置,也不能把过期/无效 Event 提升为有效。文件输出需校验实际路径、FFmpeg 退出码和可解码帧数,异常路径统一回收线程、IPC、GPU 和临时文件。此桌面渲染链不能替代车端硬编/硬解或原车时序的台架验收。

    常见问题

    导出画面里的数据一定是有效的吗?

    不一定。patch_submaster() 把 seen、updated、alive、valid 全置真且不保留原 Event.valid,未出现的新主题也不会被显式标成失活。因此导出画面可显示出原始日志里曾无效或已失联的数据,不宜作为告警新鲜度的权威回放。

    为什么导出的片段长度可能与请求不符?

    因为分桶以每段首条日志的单调时间为起点而非视频首帧 SOF,一个 50 ms 桶只保留同主题最后一条且不补齐中间空桶。clip() 用 frame_start 和 duration * 20 直接切桶列表,日志起点偏离、消息缺口或某段不足 60 秒都可能造成错位或片段过短;程序只检查切片非空。

    导出打印 saved 就说明文件可解码吗?

    不能说明。GuiApplication 的 close_ffmpeg() 等待编码子进程但不检查非零退出码,因此打印「saved」不等于文件可解码;而且 RECORD_OUTPUT 被强制改成 .mp4,成功日志仍打印原始 -o 路径,非 .mp4 后缀会造成路径不一致。

    –speed 0 会怎样?

    parse_args() 只要求结束时间大于起始时间,没有对负起点、零/负倍速做完整校验;在默认正的目标体积下,--speed 0 会在码率计算中除零。这是源码可见的输入校验缺口,不是可控的「静音播放」。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/DESKTOP_CLIP_RENDER_LOG_CAMERA_UI_CONTRACT_DEEP_DIVE.md(取用了分层调用、时间/图像/消息一致性、Params 与前缀顺序、资源与失败边界、插件化约束)。

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

    更新记录

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

  • 行车界面 GPU 渲染链:NV12 缓冲、投影裁剪与告警的受信任边界

    结论:行车画面是一条纯显示链:camerad → VisionIPC → CameraView → EGL 外部纹理或 PC Y/UV 纹理 → AugmentedRoadView → ModelRenderer/HUD/Alert → GuiApplication.render → 屏幕;模型叠加另走 modelV2 + extrinsicsCalibration + radarState → ModelRenderer,并非从当前显示的视频帧反推。这条链不是车辆控制或功能安全的执行链,主循环在顶层 Widget 绘制后才更新 UIState,因此订阅的新消息通常在下一帧绘制,不能仅凭帧率推断相机到显示或故障到告警的端到端时延。实机 GPU、摄像头时序、屏幕亮度和触摸延迟均未测试。

    一帧如何到达屏幕

    GuiApplication.init_window() 创建窗口和可选的离屏 render texture,render() 每帧收集触摸、绘制栈顶 Widget、结束绘制;关屏时仍 yield False,让外层更新状态。AugmentedRoadView._render() 做行车门控、选宽/窄相机、更新标定、scissor 限定内容区,并按相机、模型、HUD、告警、驾驶员状态的顺序绘制。CameraView._render() 用 recv(timeout_ms=0) 非阻塞取最新帧:无新帧沿用旧画面,连接丢失清帧并显示占位,座舱相机水平翻转。断连时 _ensure_connection() 按 0.2 秒间隔重试,_handle_switch() 在目标相机流有帧后才切换并重建纹理。

    组件 / 函数 静态职责
    GuiApplication.init_window() / render() 创建窗口与可选离屏纹理;每帧收集触摸、绘制栈顶 Widget、结束绘制;关屏仍 yield False
    AugmentedRoadView._render() 行车门控、选宽窄相机、更新标定、scissor 限定内容区,按固定顺序绘制
    CameraView._render() 非阻塞取最新帧;无新帧沿用旧画面,断连清帧显示占位;座舱相机水平翻转
    CameraView._ensure_connection() / _handle_switch() 断连按 0.2 秒重试;目标流有帧后切换并重建纹理
    AugmentedRoadView._update_calibration() / _calc_frame_matrix() 接受有效且 calibrated 的外参,按内参/宽窄镜头/消失点算视频变换,并设车体到屏幕矩阵
    ModelRenderer._render() modelV2 与外参需本次行车后至少到达一次;按脏位重算图形
    AlertRenderer.get_alert() 正常显示 selfdriveState 告警;首启等待与状态失联有单独降级提示

    图像缓冲与 GPU 资源

    comma 分支由 CameraView.__init__() 初始化 EGL 和占位纹理。每个 VisionIPC 缓冲的 idx 首次出现时,create_egl_image() 复制 fd,并用 NV12 两平面、stride、UV 偏移属性导入 DMA-BUF;_render_egl() 缓存 idx -> EGLImage,绑定 GL_TEXTURE_EXTERNAL_OES 后由 shader 绘制。切流或关闭时 _clear_textures() 销毁 EGL image,destroy_egl_image() 关闭复制的 fd。这里的「零拷贝」只表示 UI 不执行 CPU 端 YUV 上传,不保证摄像头至显示全链路没有拷贝。

    非 comma 分支由 _initialize_textures() 创建 Y 和 UV 纹理,_render_textures() 仅在帧变化时上传数据,fragment shader 把 YUV 转成 RGB。两个分支都依赖 producer 的 NV12 缓冲尺寸、stride 与生命周期;更换摄像头/ISP、显示驱动或 GPU 时应先验证缓冲复用、纹理方向、色彩和断流恢复。idx 缓存以 _initialize_textures() 为释放边界,非切流的异常重连是否会复用相同 idx 但更换底层 fd,需要实机注入断连测试,不能仅凭源码保证。

    投影、裁剪和告警

    ModelRenderer._update_raw_points() 提取路径、车道线、路缘和加速度,_update_model() 依据前车距离缩短路径,_map_line_to_polygon() 把车体坐标两侧采样点投影成屏幕上的带状多边形,并过滤投影奇点和区域外点。渲染层外侧还有 CLIP_MARGIN=500 的几何筛选与 AugmentedRoadView 的 scissor 裁剪,两者用途不同。shader_polygon.draw_polygon() 把两条边交织成 triangle strip 并懒加载 shader;Gradient 限制最多 20 个色标,但显式 stops 不校验与颜色等长。普通路径颜色跟 longitudinalPlan.allowThrottle 混合,实验模式路径颜色与加速度有关。

    需要明确的是:这些门控只保证某些消息在本次行车后到达过一次,并不构成「摄像头帧与模型预测严格同时间戳」的保证,也没有在 CameraView 每帧旧画面保留时自动隐藏模型。告警应作为独立于装饰性图层的受信任通道验证:摄像头占位、UI 消息断流、绘制遮挡、驾驶员接管提示和语音提示须分别测量。

    大屏与 mici 的实现差异

    mici 的 AugmentedRoadView._render() 为侧栏留宽、添加渐隐纹理、置信球与书签,告警先经 will_render() 决定 HUD 顶部图标和方向盘提示,再绘制告警/HUD;大屏则使用彩色行车边框,叠加顺序不同。mici 的 ModelRenderer._render() 在 disengaged 时隐藏车道/路径,大屏没有这一条件——两者不能视为同一 UI 的简单缩放。

    对比项 大屏 mici
    布局与边框 彩色行车边框 为侧栏留宽、渐隐纹理、置信球与书签
    告警协调 按固定叠加顺序绘制 先经 will_render() 决定 HUD 顶部图标与方向盘提示
    车道/路径显示 无 disengaged 隐藏条件 disengaged 时隐藏车道与路径
    离路切换 重新 onroad 且曾有帧时重建客户端 排空现有连接的队列并清帧
    着色器 基础 YUV 转换 另有 engaged 去饱和/对比度与座舱增强

    过渡逻辑也不同:mici 的 CameraView._offroad_transition() 排空现有连接的队列并清帧,大屏的 _offroad_transition() 在重新 onroad 且曾有帧时重建客户端;迁移时要为两种屏幕分别验证切换时不会闪出上一行程图像。mici 的着色器另有 engaged 去饱和/对比度和座舱增强。

    自制硬件与插件化结论

    硬件原型至少需要相机同步/时间戳、可稳定复用的 NV12 缓冲和 DMA-BUF/EGL 支持或经验证的纹理上传替代、可见且可测的显示路径、可靠触摸/背光/屏幕唤醒,以及独立于 UI 的车辆安全网关。台架验收项可列为:相机断连/重启/切宽窄流、GPU 上下文丢失、屏幕休眠唤醒、模型/状态消息失联、告警抢占、长时间热降频,并测量真实最坏时延。

    「万物即插件」适合导航、可视化指标和非关键叠加;摄像头底图生命周期、标定/投影契约、接管告警与失联降级、驾驶员状态显示应由受信任宿主掌控。插件只读受版本约束的消息快照,通过宿主分配的低优先级绘图区域输出,不能遮挡或替代核心告警,更不能发布 selfdriveState 或触发车辆控制。

    常见问题

    「零拷贝」是否意味着摄像头到屏幕全链路都没有拷贝?

    不是。源文档明确这里的「零拷贝」只表示 UI 不执行 CPU 端 YUV 上传,不保证摄像头至显示全链路没有拷贝。comma 分支用 DMA-BUF 导入 EGLImage,非 comma 分支则在帧变化时上传 Y/UV 纹理,两条路径的拷贝点不同。

    模型叠加会不会因为显示旧画面而与视频帧错位?

    存在这种可能。CameraView._render() 无新帧时沿用旧画面,而源码没有在每帧旧画面保留时自动隐藏模型;门控只保证 modelV2 与外参在本次行车后至少到达一次,并不构成摄像头帧与模型预测严格同时间戳的保证。

    大屏和 mici 两套界面能不能当作同一 UI 的缩放?

    不能。mici 为侧栏留宽、加渐隐纹理与置信球,告警先经 will_render() 协调 HUD,且 ModelRenderer 在 disengaged 时隐藏车道/路径;大屏使用彩色行车边框、叠加顺序不同,也没有 disengaged 隐藏条件。

    第三方插件能否自行绘制告警或相机底图?

    不建议,也不符合源文档的边界。摄像头底图生命周期、标定/投影契约、接管告警与失联降级、驾驶员状态显示应由受信任宿主掌控;插件只读受版本约束的消息快照,在宿主分配的低优先级区域输出,不能遮挡核心告警或发布 selfdriveState。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ONROAD_GPU_RENDER_DEEP_DIVE.md(取用了帧循环、图像缓冲与 GPU 资源、投影裁剪与告警、大屏与 mici 差异及硬件插件边界)。
    • 项目 02 —— openpilot-research/UI_SHADER_POLYGON_GRADIENT_CONTRACT_DEEP_DIVE.md(仅用于确认 shader_polygon 与 Gradient 的边界)。

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

    更新记录

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

  • WebRTC 直播码率与关键帧:0.5/1.5/5 Mbps 三档

    结论:直播码率自适应是三档电平控制:默认 0.5/1.5/5 Mbps,每 0.2 秒采样 RTCP 接收报告,丢包率 ≥10% 立即降一档、≥5% 累积五次降档,零丢包累积五次升档且降档后升档等待次数翻倍。关键帧请求与码率都是全局 Params 电平,不含相机 ID、请求 ID 或逐路确认,多路会话共享同一请求位。时间维度上,WebRTC PTS 取的是发送时的 monotonic_ns 换算为 90 kHz,并不等于相机采样的 SOF/EOF。以下为固定源码快照 c8fb906 及固定版本 teleoprtc 归档、并对原码率类与视频轨道做隔离探针的静态分析,未运行真实相机、编码器或网络丢包测试。

    关键函数与跨进程调用

    入口 源码行为 边界
    LivestreamBitrateController.__init__()、run() 默认三档 0.5/1.5/5 Mbps(最高档可由 STREAM_BITRATE 覆盖);初始化就把最高档写 LivestreamEncoderBitrate。每 0.2 秒按接收报告调档。 只采样 get_stats() 字典的第一份报告,并非多轨道最坏/加权网络状态;未观察或报告完全不变则保持当前控制状态。
    _sample()、set_quality() fraction_lost / 256 转换丢包比例;手动 high/med/low 直接写 Params 并停止自动控制,auto 恢复自动。 手动模式不更新内部 level、counter、up_samples;回到 auto 后首次调档仍以先前自动状态为基准。代码未核验编码器是否实际应用码率。
    StreamSession.run()、message_handler() 会话开始置 LivestreamRequestKeyframe=True;数据通道可切相机、调质量、暂停/恢复视频、启用计时 SEI。 livestreamCameraSwitch 直接更换唯一轨道的订阅源,没有重置轨道 _seen_keyframe 或显式新关键帧请求。
    LiveStreamVideoStreamTrack.recv()、switch_camera() 订阅主题采用 conflate=True;首次看到关键帧标记 _seen_keyframe=True 并清全局请求,但收到非空消息都会返回视频包。PTS 用发送时 monotonic_ns 换算为 90 kHz。 _seen_keyframe 是请求清除门,不是发帧门;入流可能先送非关键帧。多轨道共用一个关键帧 Params 位。
    _build_frame_data() 普通模式拼接 EncodeData.header + data;可选计时 SEI 额外放入 SOF→EOF、EOF→日志、日志→本机当前时间三段毫秒延迟及 wall-clock 毫秒。 SEI 里同时存在单调时间差与墙钟绝对时间;跨时钟域比较必须说明校时、漂移和跳变。

    码率状态机与探针结论

    run() 只有丢包率 ≥5% 或 ≤0% 两类调档分支;0% 到 5% 间的正丢包率不清零 counter。因此「连续五次」更准确地说是相关分支累计五次采样,不保证中间完全没有低丢包样本。_sample() 只把整个报告元组相同视为没有新样本,不能证明报告对应新的媒体时间窗口。

    五项原 LivestreamBitrateController 类隔离探针核对了:10% 报告立即降档并把升档窗口从 5 翻倍到 10;5% 报告中穿插 1% 样本仍累计到五次降档;降档后需十个零丢包样本才升档;手动 low 切回 auto 时内部仍在 high,后续零丢包不回写 high,Params 继续留在手动 low;完全相同的报告元组不会重复采样。这些探针用人工 RTCP 报告和 Params 记录器执行原类方法,不验证真实 WebRTC 统计刷新、编码器应用或网络体验。

    关键帧请求与轨道行为

    RTCP PLI 调轨道 request_keyframe(),写全局 Params;每个直播编码器每帧读取码率/关键帧参数再编码。Params 是全局电平触发控制,不含相机 ID、请求 ID、过期时刻或逐路 ACK;软件编码后端只记录不支持设置码率/请求关键帧。多路会话共享 LivestreamRequestKeyframe 和 LivestreamEncoderBitrate,而各轨道各自处理媒体,一个轨道清除全局关键帧请求可能使另一个仍未见关键帧的轨道失去持续请求。

    LiveStreamVideoStreamTrack.enable(False) 重置 _seen_keyframe,但 switch_camera() 不重置它。即使 _seen_keyframe=False,recv() 也不会阻挡非关键帧,只等待有消息;消费者能否解码取决于编码器/封装及新关键帧到达,不能由该标志保证。五项轨道与时间探针确认:订阅采用 conflate=True、切相机仅替换 socket、首次关键帧前的非关键帧仍返回、PTS 由本机取帧时单调时钟换算为 90 kHz、首次关键帧清全局请求。探针使用假基类、假帧与假时钟,不证明真实编码包可解码、相机切换连续或跨设备时钟已对齐。

    帧时间与 SEI 字段

    PTS 使用取帧时间计算,不使用 EncodeData.idx.timestampSof/Eof;网络等待、conflate 丢旧包或暂停/恢复都可能让媒体时间轴与相机采样时间轴不同。计时 SEI 中三个毫秒差依次为 SOF→EOF、EOF→日志、日志→读取时刻,第四项为墙钟毫秒。插件层需同时保留采样、编码、传输与显示时间。现有 WebRTC 会话测试检查单轨道 recv() 的 PTS 单调趋势与空包字节,并测试消息代理/非 JSON 请求;未见码率状态机、多轨道关键帧确认、切相机或 SEI 时间字段的原套件专门回归。

    时间字段 来源 不能代替什么
    WebRTC PTS 发送时 monotonic_ns 换算为 90 kHz 相机采样 SOF/EOF;反映的是取帧/发送节奏。
    SEI SOF→EOF 编码内部两段单调时间差 完整采样时间消息契约。
    SEI 日志→本机当前时间 单调时间差 跨设备已校时;仍需说明漂移与跳变。
    SEI 第四项 wall-clock 绝对毫秒 可直接与其他单调时钟字段比较。

    新硬件与插件接口验收

    编码插件至少应声明 codec/profile、每路输入时钟、最大分辨率/码率、关键帧间隔、可变码率是否受支持及实际应用后的回执;关键帧请求用 camera_id + request_id + deadline 逐路确认已输出可解码关键帧。码率控制器应以每条流的实际 RTCP 统计及编码器应用回执为依据,手动/自动切换时同步内部档位并明确计数器重置规则,无报告、报告停滞与网络失联要分开。回放/台架应覆盖多路同时启动、一条流无关键帧、相机切换、暂停恢复、PLI 连发、无接收报告、0/中/高丢包、软件编码后端不支持动态码率,以及 wall clock 跳变时 SEI 的解释。

    常见问题

    WebRTC 直播码率有哪几档,怎么切换?

    默认三档 0.5/1.5/5 Mbps,最高档可由 STREAM_BITRATE 覆盖。控制器每 0.2 秒按 RTCP 接收报告调档:丢包率 ≥10% 立即降一档、≥5% 累积五次降档,零丢包累积五次升档且降档后升档等待次数翻倍。

    关键帧请求是逐路确认的吗?

    不是。关键帧请求与码率都是全局 Params 电平,不含相机 ID、请求 ID 或逐路 ACK;多路会话共享同一个请求位,一个轨道清除全局请求可能使另一个仍未见关键帧的轨道失去持续请求。

    切相机会自动请求新关键帧吗?

    不会。switch_camera() 只更换唯一轨道的订阅源,不重置 _seen_keyframe,也没有显式新关键帧请求;而且即使 _seen_keyframe=False,recv() 也不会阻挡非关键帧。成功切换后的解码连续性需实测。

    WebRTC 的 PTS 就是相机采样时间吗?

    不是。PTS 用发送时的 monotonic_ns 换算为 90 kHz,不使用 EncodeData.idx.timestampSof/Eof;网络等待、conflate 丢旧包或暂停恢复都可能让媒体时间轴与采样时间轴不同。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/WEBRTC_BITRATE_KEYFRAME_TIMING_DEEP_DIVE.md(码率状态机、关键帧请求、PTS/SEI 时间字段与探针结论)。
    • 项目 02 —— openpilot-research/ 同目录远程 RPC 与视频编码专题(会话边界与编码路径作为相邻边界参考)。

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

    更新记录

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

  • onroad 验收测试:25 秒日志、相机 SOF/EOF 与模型耗时各断言了什么

    结论:test_onroad.py 是需要 comma 硬件的设备测试,不是实车驾驶测试:它启动 manager、跑 25 秒、读首段 rlog.zst,再对服务频率、日志大小、相机时序、模型耗时和资源占用做断言。它的边界很明确——只分析短时日志、不测驾驶动态与安全限幅,且部分断言因算式问题无法触发、部分后备分支不可达。本文基于固定源码快照的静态分析,没有运行 manager、相机、panda、模型或车辆 I/O。

    启动链与副作用

    TestOnroad 设置 COMMA_HARDWARE_TEST=True;继承的 OpenpilotTestCase.setUpClass() 在非 comma 硬件跳过,然后调用小写的 setup_class()。普通路径先删 CurrentRoute、开启 RecordFront、写启用/条款/标定参数并设置 REPLAY、MSGQ_PREALLOC、TESTING_CLOSET,随后 Paths.log_root() 指向的目录若存在就被递归删除。在 AGNOS 设备上该路径位于设备本地介质目录,不随 per-test OPENPILOT_PREFIX 隔离。

    类级准备通过 subprocess.Popen() 启动 manager,最多等 30 秒收到 carState,要求 CurrentRoute 存在且日志目录中恰有一个匹配路段,然后睡眠 25 秒;终止 manager 后读取首段 rlog.zst。TestOnroad.setUp() 直接调用 unittest.TestCase.setUp(),没有调用每测试硬件初始化的那层,但继承的 run() 仍对各测试包裹环境快照、Prefix 和 manager 清理——类级准备发生在 per-test Prefix 之前,不能把它的 Params 和日志删除视为测试命名空间内的动作。

    并行运行时有隐患:通用 test_runner.make_batches() 只按类自身的大写 fixture 合批,而该类只定义小写 setup_class。若用多 worker 运行该文件,类级 manager 启动与日志目录删除可能在多个批次重复甚至并发;发布脚本和 Jenkins 直接调用本文件的 unittest.main(),不走这条并行分组路径。另外 finally 中判断 proc.wait(60) is None,而 Popen.wait() 正常返回退出码、超时抛 TimeoutExpired、不会返回 None,故这条 proc.kill() 后备分支不可达。

    主要验收断言与实际覆盖

    测试函数 有效检查 覆盖边界
    test_service_frequencies() 对已在日志中出现的非 GPS/init/sentinel 主题要求至少约 frequency * 20 * 0.8 条 未出现的主题不进循环;不是全服务存在性证明
    test_manager_starting_time() manager 启动到首条 managerState 小于 15 秒 只约束首消息,不证明所有进程就绪
    test_cloudlog_size() logMessage 总字节数小于 350 kB spam 判据 n / sum > 30 左侧至多 1,额外断言无法触发
    test_log_sizes() 文件落在 60 秒量按 25 秒缩放后的 0.5~1.5 倍 未知文件名访问会报错;不验证内容可解码
    test_ui_timings() 跳过约前 8 秒;最大绘制 CPU <250 ms、均值 <20 ms、>40 ms 帧少于 5 用 uiDebug.cpuTimeMillis,不是屏幕端到端延迟
    test_cpu_usage() 每进程上限 max(expected*1.8, expected+5) 总预算只求静态表之和;缺样本时上限可能未赋值
    test_memory_usage() 跳过前 8 秒,检查 deviceState 内存均值与增量 25 秒窗口不是长期泄漏证明

    相机与模型相关断言如下,它们共同的特点是只检验录入日志的元数据,不解码视频像素、不判断预测准确率:

    测试函数 有效检查 覆盖边界
    test_camera_frame_timings() 三路相机相邻 SOF 与 50 ms 的最大偏差 <5 ms 只检验日志帧时间戳
    test_camera_sync() 帧号连续、EOF 在 SOF 后 50 ms 内;道路/宽角同帧差 <2 ms、座舱错开 20~30 ms、编码器起始帧一致 相机状态只要求最早帧号 <100;仅对三路共同帧号比较
    test_camera_encoder_matches() 编码索引帧号对应相机状态的 SOF/EOF 完全相等 只核对元数据,不解码视频像素
    test_model_execution_timings() 8 秒后道路模型最大/均值 <60/40 ms,驾驶员模型 <300/50 ms 比较执行时间,非模型输出数值或最坏闭环时延
    test_timings() 预设服务消息间隔均值在目标频率 3% 内 独立于实际执行器安全约束
    test_startup()/test_engagable() RELEASE 存在时比较启动告警;8 秒后 engageable 全为真 release_only 只检查变量存在;可接管不等于真正 engage

    DEBUG 分支仅初始化 lr/ts,未设置 manager_st、msgs、log_sizes,只能支持使用到这些字段的定向用例,不能当作完整 suite 的等价运行模式。

    资源类断言也各有算式边界。test_cpu_usage() 用 procLog 首末 CPU 时间差与日志时长计算各进程使用率,每进程上限为 max(expected*1.8, expected+5),而总预算断言只求静态 PROCS 表的和,不约束实测使用率总和;缺样本时 max_allowed 可能未赋值或沿用上一进程值。test_memory_usage() 跳过前 8 秒,检查 deviceState 内存占用均值与相邻增量并打印 procLog 报告,但 25 秒窗口与百分比变化不是长期泄漏证明,共享内存区用量也只打印。test_log_sizes() 要求文件落在预设 60 秒量按 25 秒缩放后的 0.5~1.5 倍,未知文件名访问 LOGS_SIZE[f.name] 会报错,且它不验证文件内容可解码或跨段完整性。

    Chestnut 独立测试

    TestChestnutOnroad 还要求设备类型为 mici,并检查 Chestnut 硬件/编译产物。它写入演示 CarParams,用 mock deviceMotion 和合成 deviceState 驱动 camerad、calibrationd、modeld、dmonitoringmodeld,等到模型有效再收集 25 秒消息。断言包括:五种服务数量在目标频率约 5% 内、Event valid、帧号单调上升、至少 90% 道路模型帧与窄路相机交集、全部 modelV2.big 为真、以及 position.x 有限。

    这能证明短时大模型路径没有回落到小模型的可见标志,但不证明预测准确率、整帧像素对齐、车辆控制或长期热稳定性。发布脚本在强推前直接运行此文件的指定 unittest 入口,Jenkins 另把 Chestnut 测试与模型回放放在设备阶段;两者属于不同调用场景,且有分支/环境/硬件门,不能用任意一次绿色结果宣称全部平台通过。

    验证范围与插件化含义

    已静态核对完整测试文件、相邻 OpenpilotTestCase/Paths/helper 和 CI 入口,但未执行任何测试,未验证真实日志格式、采样分布、模型权重或目标设备。文中不可达/无效断言是直接源码控制流与算式结论,非已在设备上复现的故障。硬件插件验收应补固定日志的数值基准、CAN safety 测试、故障注入与独立台架时序,再进入实车驾驶员监督验证。

    常见问题

    test_onroad.py 是用来验证实车驾驶表现的吗?

    不是。它是需要 comma 硬件的设备测试,只启动 manager、跑 25 秒、读首段 rlog.zst,对服务频率、日志大小、相机时序、模型耗时和资源占用做断言。源文档明确它既不测驾驶动态与安全限幅,也不验证真实驾驶表现;本文也未运行 manager、相机、panda、模型或车辆 I/O。

    为什么说 proc.kill() 后备分支不可达?

    因为源码判断的是 proc.wait(60) is None:Popen.wait() 正常返回退出码,超时抛 TimeoutExpired,不会返回 None,所以基于该条件的后备分支逻辑上不可达。这是直接源码控制流结论,不是设备上复现的故障。

    相机同步测试通过,是否说明视频像素也对齐?

    不代表。test_camera_sync() 只比较录入日志的帧号与 SOF/EOF 时间戳,test_camera_encoder_matches() 只核对编码索引帧号与相机状态元数据,都不解码视频像素;且仅对三路共同帧号比较,不证明未记录帧的同步。

    Chestnut 测试通过能说明什么?

    它能证明短时大模型路径没有回落到小模型的可见标志(如 modelV2.big 全为真、帧号单调上升、服务数量符合目标频率)。但它不证明预测准确率、整帧像素对齐、车辆控制或长期热稳定性,也不能用绿测结果宣称全部平台通过。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ONROAD_TEST_ACCEPTANCE_LOG_TIMING_CAMERA_MODEL_CONTRACT_DEEP_DIVE.md(取用了启动链与副作用、验收断言覆盖边界、Chestnut 专项与验证范围)。
    • 项目 02 —— openpilot-research/TEST_RUNNER_PARALLEL_FIXTURE_CONTRACT_DEEP_DIVE.md(仅用于确认测试合批与 fixture 命名的边界)。

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

    更新记录

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

  • 摄像头采集与 IMU 采样:SOF/EOF、18 个缓冲与轴系契约

    结论:车载视觉与惯性输入必须把图像时间、帧同步、曝光、IMU 时间和故障降级当作独立契约,不能只满足「能输出 NV12 / XYZ」。原生 camerad 与 sensord 是两个独立时钟源的生产者:logMonoTime 是发布封装时刻,不等于 IMU 中断时间或图像 SOF;源码中的 EOF 是由内核 SOF 时间加传感器读出时间推算的,不是直接测得的硬件 EOF。固定快照基线为 c8fb906,本文为静态代码阅读,未连接相机模组、I2C / GPIO 实板或路测数据。

    原生摄像头:启动到发布

    进程选择由 USE_WEBCAM 决定:置位时原生 camerad 不启用;原生程序设置 CPU 亲和性后进入 camerad_thread()。该 C++ 目标在非 Darwin 平台构建,PC / macOS 不能仅因目录存在便声称原生采集可运行。

    板级配置里广角、窄角、座舱固定 camera_num 0 / 1 / 2,各有 VisionIPC stream、焦距、发布主题和 PHY;窄 / 广角同步 SOF,座舱期望半周期错相。即使禁用某路图像,源码仍会尝试探测 / 配置传感器,以避免 strobe GPIO 电气副作用。设备初始化打开 Qualcomm req_mgr / cam_sync / ISP / ICP,订阅 SOF 事件;逐路先探测 OS04C10、再探测 OX03C10,探测失败会禁用该路。

    阶段 实现事实
    缓冲 为处理模式分配原始缓冲,并为各流创建 18 个 YUV VisionIPC 缓冲;发布时附带 frameId、SOF、EOF。
    逐帧 poll 读取 req_mgr SOF,按 session_handle 路由,验证请求 / 帧序号,等 IFE / BPS sync,处理帧后送 VisionIPC 与对应 *CameraState,再按亮度更新曝光寄存器。
    状态消息 记录帧号、requestId、SOF / EOF、曝光 / 增益及处理耗时;图像主数据走 VisionIPC,FrameData 是另一条状态消息。

    图像主数据走 VisionIPC,而 FrameData 是另一条状态消息;FD 握手、缓冲复用与通知一致性需与图像数据契约分开验证。

    同步与故障边界

    validateEvent() 对 requestId=0、帧号 / 请求号跳变重排队;waitForFrameReady() 等 IFE 最多 100 ms、BPS 最多 50 ms,失败清队列并重排,避免使用可能撕裂的帧——该恢复动作不等于图像连续性已保住。

    syncFirstFrame() 要求非错相路面相机约 0.2 ms 内、错相座舱相机在 25 ms 偏移的约 0.2 ms 内对齐。若 IFE 帧号超过 40 仍未同步,源码记录超时并将 first_frame_synced 设真,后续可继续发布;因此「有图像」不证明同步质量合格。processFrame() 用内核 SOF 时间加传感器 readout_time_ns 推算 EOF;set_camera_exposure() 根据抽样 Y 平面亮度、目标灰度和前 3 帧曝光状态调整积分时间、模拟增益及高转换增益,测光区域依赖焦距 / 输出尺寸。替换镜头 / 传感器必须重新标定焦距、ROI、曝光延迟和增益表。

    webcam:开发替代,不等价于车载模组

    Camerad.__init__() 从环境变量选择窄路面、可选广路面和驾驶员摄像头,创建 VisionIPC 缓冲;Camera.read_frames() 经 OpenCV 读取、旋转 180 度并用 PyAV 转 NV12;camera_runner() 以 20 Hz 发布。_send_yuv() 的 SOF / EOF 值用 frame_id * 0.05 s 合成,FrameData 只填帧号与单位变换;没有原生传感器 SOF / EOF、跨相机硬件同步、曝光 / 增益状态。它是开发输入替代,不足以验证车载相机时间契约。

    sensord:采样、坐标与时间

    I2C bus 1 的 LSM6DS3 加速度、陀螺仪和温度共享设备;先 reset / init,再启动 IRQ 和温度轮询线程。GPIO 边沿时间戳来自内核,用 time.time_ns - time.monotonic_ns 折算到单调时钟;时钟偏移突变超过 10 ms 时跳过当次事件并更新偏移。

    层 实现事实
    加速度 读取 data-ready 与三轴寄存器,按 ±2g / 16-bit 换算 m/s²,输出轴顺序为 [y, -x, z];没有 data-ready 时不发布。
    角速度 读取 data-ready 与三轴寄存器,按量程换算 rad/s,同样输出 [y, -x, z];与加速度共享 IRQ,但逐传感器检查可读状态。
    温度 按 2 Hz 轮询,使用单调时间并按芯片变体选温度比例。
    启动有效性 从首次调用有效性检查起,前 0.5 s 不发布有效数据;自测仅在对应环境变量触发。

    传感器插件如果改轴定义而不更新定位观测模型,会产生系统性估计误差。定位 / 模型消费端应依据观测时间戳和有效性判断新鲜度,而不是依赖发布时刻的 logMonoTime。

    验证状态与硬件任务

    test_camerad.py 标记 COMMA_HARDWARE_TEST,覆盖曝光收敛、帧连续、路面同步 / 座舱错相及故障注入;test_sensord.py 同样要求硬件,检查频率、时间戳、数值范围和中断停机。本次仅确认测试存在,未运行。自制硬件的最小验收应记录:各路图像 SOF / EOF / 帧号、读出时间、丢帧及重排次数、测光 / 曝光响应;IMU IRQ 时间、轴向 / 量程、启动稳定、掉线和时钟跳变。再以回放、台架和实板测试逐项比较,不以 webcam 或仿真通过替代。

    常见问题

    「有图像」为什么不能说明相机同步合格?

    因为 syncFirstFrame() 在 IFE 帧号超过 40 仍未同步时,源码记录超时并直接把 first_frame_synced 设真,后续可继续发布。允许发布不等于对齐质量达标,需记录实际对齐误差与重排次数。

    图像 EOF 是硬件测得的吗?

    不是。processFrame() 用内核 SOF 时间加传感器 readout_time_ns 推算 EOF,文中 EOF 并非直接测得的硬件 EOF。新相机插件必须提供时间域、读出时间、请求序列及同步质量,不能把采集完成时刻冒充 SOF。

    IMU 的 logMonoTime 就是采样时间吗?

    不是。logMonoTime 是发布封装时刻,不等于 IMU 中断时间;GPIO 边沿时间戳来自内核再折算到单调时钟。定位 / 模型消费端应依据观测时间戳和有效性判断新鲜度。

    IMU 轴顺序为什么重要?

    因为 LSM6DS3_Accel 与 LSM6DS3_Gyro 都输出 [y, -x, z]。传感器插件若改轴定义而不更新定位观测模型,会产生系统性估计误差;新硬件必须显式定义启动稳定时间、量程、轴系和异常状态。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CAMERA_IMU_DEEP_DIVE.md(取用了摄像头启动到发布、同步 / 故障边界、webcam 替代路径与 sensord 采样轴系分析)。

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

    更新记录

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

  • selfdrived 动态告警:49 个槽位与软退出的真实边界

    结论:在 EVENTS 主映射里共有 49 个回调槽位、22 种回调形式:两个工厂覆盖 27 槽位,20 个直接函数覆盖 22 槽位。回调在 creation_delay 延迟过滤之前执行,即使告警最终未显示也会读取输入、构造文本,且这里没有逐回调异常隔离。更关键的是,回调返回的 ImmediateDisableAlert 只改变文案、声音、视觉标记、优先级与持续时间,是否立刻退出仍由事件类型决定,而不是由告警子类名里有 Immediate 决定。本文为固定源码快照 c8fb906 的静态阅读,未实测墙上时间与声音输出延迟。

    调用链:从输入到仲裁

    SelfdriveD.update_alerts() 提供六个实参:CP、CS、sm、is_metric、soft_disable_timer 和驾驶风格枚举字符串。Events.create_alerts() 只处理当前状态允许的事件类型:值若是 Alert 实例则直接使用,否则调用回调创建新实例。随后按连续帧计数判断 creation_delay,写入 EventName/类型 键和事件类型。

    环节 行为 边界
    update_alerts() 传入 CP、CS、sm、公英制、软退出计时与驾驶风格 输入字段来自当前 SubMaster 消息,跨主题时序需回放确认
    create_alerts() 只处理当前状态允许的事件类型;静态 Alert 直接用,否则调用回调 回调在 creation_delay 过滤前执行,无逐回调异常隔离
    AlertManager.add_many() 以 EventName/类型 键保存告警及结束帧 「事件已出现」与「UI 当前展示」可能不同步
    process_alerts() 按优先级、起始帧选当前项 最终仲裁仍在受控核心,不由插件决定

    这里有一个容易被忽略的顺序细节:create_alerts() 在判断 creation_delay 之前就已经执行了回调,因此告警文案与动态数值是在过滤之前构造的。若某回调依赖尚未更新的输入,它可能读到上一帧的字段;反过来,一个最终因延迟未显示的告警,其回调副作用——例如读取 sm、构造字符串——仍已发生。由于没有逐回调异常隔离,单个回调抛错会影响同一容器内的处理顺序,这一点在插件化时必须由受控核心兜底。

    两类软退出工厂:27 槽位

    软退出相关的槽位由两个工厂覆盖,共 27 个。soft_disable_alert() 覆盖 24 个事件,包括 bigModelFailed、cameraMalfunction、cameraFrameRate、locationdTemporaryError、paramsdTemporaryError、steerTempUnavailable、steerTimeLimit、sensorDataInvalid、excessiveActuation、overheat、calibrationInvalid、calibrationIncomplete、calibrationRecalibrating、espDisabled、commIssue、commIssueAvgFreq、selfdrivedLagging、processNotRunning、radarFault、radarTempUnavailable、modeldLagging、posenetInvalid、lowMemory、espActive;user_soft_disable_alert() 覆盖 3 个:wrongGear、doorOpen、seatbeltNotLatched。

    回调形式 槽位数与事件 运行结果
    soft_disable_alert() 24 个(含 overheat、radarFault、modeldLagging、lowMemory 等) 软退出计数低于 int(0.5/DT_CTRL) 时构造 ImmediateDisableAlert 外观,否则构造 SoftDisableAlert
    user_soft_disable_alert() 3 个:wrongGear、doorOpen、seatbeltNotLatched 同样在剩余约 0.5 秒改成紧急外观,之前使用文案较缓和的 UserSoftDisableAlert

    关键区别在于:回调返回的 ImmediateDisableAlert 只改变告警的文案、声音、视觉标记、优先级与持续时间,Events.create_alerts() 仍将 event_type 标为映射键 SOFT_DISABLE。StateMachine.update() 是否立刻退出由事件类型决定,不会因为 Alert 子类名含 Immediate 就改走 IMMEDIATE_DISABLE。计数按 DT_CTRL=0.01 的名义周期换算,墙上时间和声音输出延迟尚未实测。

    20 个直接回调:22 槽位

    回调与主映射事件 消费字段和动态内容
    joystick_alert():joystickDebug.WARNING 从 carControl.actuators.accel/torque 计算调试文案;不是实车执行反馈
    longitudinal_maneuver_alert():longitudinalManeuver.WARNING、lateralManeuver.WARNING 两个事件都读 alertDebug 的两行文案;横向事件由 lateralManeuverPlan 触发但回调仍读 alertDebug
    invalid_lkas_setting_alert():invalidLkasSetting.PERMANENT 根据 Tesla/Mazda/Nissan 品牌调整原车 LKAS 说明,其余用通用文案
    paramsd_invalid_alert():paramsdTemporaryError.NO_ENTRY 按 angleOffsetValid、steerRatioValid、stiffnessFactorValid 顺序挑第一个错误并附数值
    camera_malfunction_alert():cameraMalfunction.PERMANENT 对三路摄像头逐个调用 sm.all_checks([service]) 拼出失败名称
    overheat_alert():overheat.PERMANENT 取 CPU、GPU、内存温度的最大值显示;空列表用 0 兜底
    comm_issue_alert():commIssue.NO_ENTRY 对 sm.data 各主题重新执行 all_checks,只列前四个失败主题
    personality_changed_alert():personalityChanged.WARNING 显示当前驾驶风格字符串,持续时间 1.5 秒

    其余直接回调包括 startup_master_alert()、below_steer_speed_alert()、wrong_car_mode_alert()、out_of_space_alert()、below_engage_speed_alert()、too_distracted_alert()、calibration_invalid_alert()、calibration_incomplete_alert()、process_not_running_alert()、modeld_lagging_alert()、posenet_invalid_alert()、low_memory_alert()。需要强调「槽位」是 EVENTS 中事件名与事件类型的组合,不等于 22 个独立事件,也不等于 49 条屏幕上同时可见的告警。此外 NoEntryAlert 在 mici 上会调换两行文字,EVENTS.update() 还覆写 9 个条目中的告警定义,实际设备呈现必须按设备类型重核。

    从这些实现里还能看出一个共性:动态文案普遍消费当前订阅到的消息字段,例如 overheat_alert() 取 CPU/GPU/内存温度最大值、modeld_lagging_alert() 读 modelV2.frameDropPerc、posenet_invalid_alert() 计算 CS.vEgo - modelV2.velocity.x[0]。这些字段来自不同进程、不同采样时刻,回调本身不做新鲜度校验,因此「文案里显示的数值」不宜直接当作同一时刻的联合状态。

    仲裁、测试与插件边界

    AlertManager.add_many() 以 EventName/类型 键保存告警及结束帧,process_alerts() 按优先级、起始帧选当前项。update_alerts() 在不活动时清理 WARNING,已使能时清理 NO_ENTRY,这可能让「事件已出现」和「UI 当前展示」不同步。selfdriveState 再发布当前文案、声音与 HUD 标记。

    测试覆盖存在缺口:test_alerts.py 的静态告警属性检查明确跳过回调值,test_alertmanager.py 主要验证持续帧逻辑,不能证明 20 个直接回调的所有输入边界正确。隔离探针确认回调在 creation_delay 过滤前执行、异常不在容器中隔离。重构成插件时应把输入观测和告警策略分层:插件提供可信字段与新鲜度,受控核心保留事件类型、软退出计时、回调异常处理和最终仲裁;不得让插件仅靠改文案或改告警子类来更改安全状态。

    常见问题

    回调返回 ImmediateDisableAlert 会让车辆立刻退出吗?

    不会。create_alerts() 仍把 event_type 标为 SOFT_DISABLE,是否立刻退出由 StateMachine.update() 按事件类型决定,与告警子类名是否含 Immediate 无关。

    回调只在告警最终显示时才执行吗?

    不是。回调在 creation_delay 延迟过滤之前执行,因此即使最终未显示也会读取输入并构造文本,且这里没有逐回调异常隔离。

    49 个槽位等于 49 条同时可见的告警吗?

    不等于。槽位是 EVENTS 中事件名与事件类型的组合,既不是 22 个独立事件,也不是屏幕上同时可见的告警条数;mici 覆写还会改变实际呈现。

    「事件已出现」和「UI 当前展示」总是同步吗?

    不一定。update_alerts() 在不活动时清理 WARNING、已使能时清理 NO_ENTRY,因此事件状态与 selfdriveState 发布给 UI 的展示可能不同步。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/DYNAMIC_ALERT_CALLBACKS_DEEP_DIVE.md(取用了回调槽位统计、两类软退出工厂、20 个直接回调、仲裁与测试缺口、插件边界及隔离探针结论)。
    • 项目 02 —— openpilot-research/CAR_MESSAGES_FIELD_CONTRACT_DEEP_DIVE.md(仅用于确认回调消费字段的消息来源)。

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

    更新记录

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

  • 启动辅助界面:有限 Markdown 方言、进度管道与退出码协议

    结论:启动辅助界面的三块内容各有明确的契约边界:发布说明走的是一个「有限 Markdown 方言」,只把 === 后的行包成 <h1>、* 行转成列表,末尾只替换 & 与双引号,不转义 < 与 >;进度窗口通过子进程标准输入按行传文本或百分比,空行中断本帧读取而 EOF 不会主动退出;错误窗口的宿主 wait_for_exit() 在成功创建子进程后只接受退出码 1,而 PC 正常关闭路径的退出码预期是 0,可能造成无限等待。以下为固定源码快照 c8fb906 与无 GUI 探针的静态结构分析,未启动构建、注册、更新、raylib 窗口或车载设备。

    发布说明的有限 Markdown 方言

    parse_markdown() 按换行拆分:后一行以 === 开头时把当前行包为 <h1>,以 * 开头的行转为 <ul>/<li>,并根据前导空格和 tab_length 粗略处理嵌套;其他非空行原样输出。它不是通用 Markdown 解析器,不支持标题等级、代码块、链接和转义规则的完整语义。输入以 ===anything 开头也满足标题分隔条件,非规范缩进可能与内部 list_level 不一致。

    末尾统一把 & 变为 &amp;、双引号变为 &quot;,不转义 < 与 >。无设备探针中,输入 <b>note</b> & x 得到 <b>note</b> &amp; x。下游 HtmlRenderer.parse_html_content() 是 raylib 文本渲染器,只识别有限标签,不是浏览器执行环境;但原始 <b> 等受支持标签仍能改变显示结构,不能把这函数当作对任意来源文本的 HTML 净化器。

    updated.parse_release_notes() 从 RELEASES.md 取第一个双换行前的最新说明,UTF-8 解码后调用上述函数;解析异常则回退原始字节加换行,文件缺失则返回空字节。结果写入更新参数后,由行车外告警页 ReleaseNotesDialog.refresh() 或软件设置项显示。现有 test_markdown.py 只遍历发布说明段并检查转换结果非空,没有验证标签转义、嵌套列表或异常输入。

    构建/注册进度窗口

    宿主 common.Spinner 通过 Popen(["./spinner.py"], stdin=PIPE, cwd=system/ui) 启动独立 UI,启动 OSError 只留下 None。update() 向标准输入写入 UTF-8 文本加换行再 flush,它只在 flush 周围捕获 BrokenPipeError,写入本身若抛错会向调用方传播。update_progress(cur,total) 计算并四舍五入百分比,宿主端不检查 total=0 或范围。

    契约点 源码行为 探针确认
    进度写管道 update() 写 UTF-8 文本加换行,只在 flush 周围捕获 BrokenPipeError 1/4 写为 25n;零分母抛错;flush 断管被吞、write 断管传播。
    数字解析 子进程 set_text() 对 str.isdigit() 为真的文本尝试 int() 并裁到 0..100,其余换行为文字 -1 是文字;某些 Unicode 数字如 ² 通过 isdigit() 却在 int() 抛 ValueError,中断当前 UI 主循环。
    帧读取 _read_stdin() 用零等待 select 读取就绪行并 strip() 两端空白,读到空行或 EOF 就结束本轮 空行之后尚存输入留待下一帧;main() 每帧只取本次读到的最后一行,EOF 本身不会主动关闭窗口。
    子进程回收 Spinner.close() 发 kill(),再 communicate(timeout=2) 回收 与 TextWindow 的终止策略不同。

    manager.build() 用它显示 scons 构建进度,失败时关闭 spinner 并打开错误窗口;registration.register() 在需要在线注册时显示阶段文字。二者的进度窗口只是呈现,不是构建/注册完成的证明;真正结果仍由子进程返回码或注册 API 决定。

    错误窗口的等待与退出码

    场景 子进程退出码 宿主 wait_for_exit() 行为
    创建失败 — text_proc is None,直接返回,不会无限循环
    PC 正常关闭 预期 0(源码无显式 sys.exit(1)) 只接受 1,状态 0 每 0.1 秒继续轮询,无 deadline
    设备 Reboot 路径 源文档未给出显式 sys.exit(1) 同上,可能无限等待(静态推论,未 GUI 实测)

    宿主 common.TextWindow 以错误文本为进程参数启动 system/ui/text.py,失败时将 text_proc 设为 None。get_status() 调用 poll() 返回退出码或 None;wait_for_exit() 在成功创建子进程后只接受退出码 1,状态 0 或其他值每 0.1 秒继续轮询,没有 deadline。创建失败后 text_proc is None,则直接返回,不会无限循环;close() 只发 terminate()、置空引用,没有 wait() 确认回收。

    UI 子进程 system/ui/text.py 在 PC 上显示 Exit,在设备上显示 Reboot;按钮先调用 gui_app.request_close(),设备上再调用 HARDWARE.reboot()。GuiApplication.render() 在关闭请求后自然结束循环,text.py 的 __main__ 没有显式 sys.exit(1)。因此在普通 PC 正常关闭、脚本正常返回的源码路径上,子进程预期退出码是 0,而宿主只接受 1,可能无限等待;这是跨进程静态推论,尚未用 GUI 实测。

    system/ui/text.py 自有的 wrap_text() 不调用通用 lib.wrap_text():它按段落拆开、保留中间/尾部空段,但丢掉前导空段;若首个词本身超宽,会先插入空字符串行,随后仍保留完整超宽词,不执行词内断行。探针确认 abcdefgh 在模拟宽度 4 下输出 ["", "abcdefgh"],a b 在宽度 1 下还可能保留行尾空格。错误追踪中的长路径或不可断词因此可能越过文本视口。

    插件化与验证要求

    源文档给出的是建议而非已实现能力:更新或插件提供的说明文本须明确来源和信任级别,如需富文本应用结构化、白名单标签生成,不能依赖 parse_markdown() 的 &/引号替换完成净化;进度 UI 的输入应是受限状态对象(阶段、0..100 进度、错误码),而不是让任意插件直接写入引导进程标准输入;启动/故障窗口应受进程监督,退出码、存活检测、最大等待时长、终止后的 wait() 与无 UI 回退日志都需有契约;故障窗口不得阻断安全关机或设备恢复。

    已用无设备探针覆盖宿主子进程正常 0/异常 1/无法启动及进度管道 write/flush 断开,另覆盖进度 UI 输入/帧选择和错误 UI 换行/按钮回调;卡住进程的真实资源回收、UI 渲染与设备 Reboot 动作仍需台架验证。

    常见问题

    parse_markdown() 会转义 HTML 吗?

    只替换 & 与双引号,不转义尖括号。探针中一个 b 标签加上 & 与 x 后,b 标签仍原样保留,& 则被编码。它不能当作对任意来源文本的 HTML 净化器。

    进度条输入一个负数会怎样?

    子进程 set_text() 对 str.isdigit() 为真的文本才尝试 int(),-1 因不是 isdigit() 而被当作文本;而某些 Unicode 数字如 ² 通过 isdigit() 却在 int() 抛 ValueError,会中断当前 UI 主循环。

    为什么错误窗口可能一直不退出?

    宿主 wait_for_exit() 在成功创建子进程后只接受退出码 1,而 PC 正常关闭路径的 text.py 退出码预期是 0,状态 0 会被每 0.1 秒继续轮询且没有 deadline,因此可能无限等待;这是静态推论,尚未用 GUI 实测。

    进度窗口显示完成就代表任务完成了吗?

    不代表。进度窗口只是呈现,真正结果仍由子进程返回码或注册 API 决定;进度消息也不是可靠队列或确认协议。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/BOOTSTRAP_UI_MARKDOWN_PROCESS_CONTRACT_DEEP_DIVE.md(Markdown 方言、进度管道、退出码协议与探针结论)。
    • 项目 02 —— openpilot-research/ 同目录富文本与更新流程专题(作为相邻边界参考)。

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

    更新记录

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

  • 座舱预览的双屏入口、面框绘制与声音主题边界

    结论:座舱摄像头预览是一条展示 / 测试路径,源码里没有任何一处把「预览能画出面框、能发声」当成驾驶员监测(DMS)已就绪的证明。两个入口(大屏设备页与 mici 设备页)都只在 ui_state.is_offroad 为真时允许点击;预览把 driverStateV2 与 driverMonitoringState 拼在一起绘制,但这两条消息之间没有同帧对齐或新鲜度门;mici 预览发布的 selfdriveState 用的是 new_message() 默认的 valid=False,而声音服务只按「是否 updated」读取 alertSound,不检查有效位。固定快照基线为 c8fb906。

    双屏入口:点击门与构造时写入的参数

    预览按钮存在两个入口:大屏设备页(device.py 的 L60 附近)与 mici 设备页(device_layout.py 的 L183 附近)。两者都用 ui_state.is_offroad 在行车时禁用预览按钮——这是一道点击入口门,不是对话框运行期间的持续安全检查。

    更值得注意的是参数写入的时机。大屏 CabinCameraDialog.__init__() 在构造时就同步写 IsDriverViewEnabled=True,并追加一个交互超时 pop_widget 回调;而不是等到 show_event()。反复构造会累积回调。mici 的 show_event() 才写 demo 参数、把交互超时延长到 300 秒、清 DriverTooDistracted 并建立 selfdriveState 发布端;hide_event() 关 demo 参数、恢复超时。

    IsDriverViewEnabled 由 dmonitoringd_thread() 每 40 个 driverStateV2.frameId 读取一次。demo 下只要有有效驾驶员模型消息,就运行 DriverMonitoring.run_step(..., demo=True),使用固定的模拟速度、使能和标定输入。因此 UI 写参数不是立刻切换策略,预览中的警示等级也不是实车速度 / 档位条件下的策略结果。

    入口 关键行为 契约边界
    大屏设备页 / mici 设备页 以 is_offroad 禁用行车预览按钮 点击入口门,非运行期持续检查
    大屏 CabinCameraDialog.__init__() 构造时即写 IsDriverViewEnabled=True,追加超时回调 反复构造累积回调
    mici show_event() / hide_event() 写 / 清 demo 参数,超时 300 秒,建发布端 首次 show_event() 在发布端创建前发 _publish_alert_sound(None),不发送
    mici 培训 CabinCameraSetupDialog 复用基类,_render() 不调用 _render_dm_alerts() 培训绘制路径与设备页声音路径不可混同

    面框绘制:两条消息没有对齐门

    大屏 _render() / _draw_face_detection() 在无帧时显示启动文案,有帧时按 driverStateV2 的左右驾驶位概率、faceProb>0.7 与姿态标准差绘制面框。绘制侧没有检查这条模型消息的年龄或 valid,面框坐标是近似公式。

    mici 侧更复杂:driverMonitoringState 决定面部检测门,而面框与眼镜概率使用 driverStateV2 字段。两个消息没有同帧对齐,也没有新鲜度门。无帧时 mici 仍会发布一次无声消息。这意味着「画出了一个面框」既不能证明模型消息是新鲜的,也不能证明它与当前画面属于同一帧。对自研相机 / 计算板的接口而言,画面帧、driverStateV2 与 driverMonitoringState 应分别显示采样年龄与有效性;模型面框必须与具体相机标定 / 帧时间关联,不能只按静态坐标公式判定安装合格。

    声音总线:同主题发布与非阻塞有效位

    mici 设备页在画面可见时每次绘制都执行 _render_dm_alerts(),从而发布 selfdriveState。_publish_alert_sound() 把 alertLevel 映射为预警 / 提示 / 紧急声音,用 messaging.new_message('selfdriveState') 发布一条只填了 alertSound 的消息。

    问题在于有效位。new_message() 默认 valid=False,此处没有覆写;其他 selfdriveState 字段仍是默认值。而 Soundd.get_audible_alert() 只检查 sm.updated['selfdriveState'],读取 alertSound 而不检查 valid。因此预览可以驱动声音;声卡是否实际播放仍取决于进程运行条件与设备。

    相同主题也被控制、规划、UI 等模块读取,不能假定它只是私有的测试音轨。驻车入口虽然降低了同时行车的概率,但源码未提供预览发布者的独立主题,也没有持续的驻车发布门。建议把预览音效改为独立测试音命令 / 主题,或由声音服务显式接收带来源和驻车门的请求,正式 selfdriveState 保持单一权威发布者。

    风险点 源码事实 影响
    超时回调只增不减 add_interactive_timeout_callback() 只 append,注册的是无参数 gui_app.pop_widget() 多次打开预览后,下次超时可连续弹出多个当前导航页
    offroad 回调未注销 两套 CameraView 构造时注册绑实例回调;close() 释放纹理 / 客户端但不注销 已关闭实例仍可能被列表引用
    泄漏测试被跳过 test_widget_leaks.py 被 skip("segfaults") 不能视为通过的生命周期证据
    mici hide_event() 只关子视图 清参数与超时覆盖,仅 close() 关闭子视图 再显示时底层状态需按各路径分别验证

    生命周期:只追加的回调与未注销的订阅

    大屏和 mici 对话框在每次构造时都调用 Device.add_interactive_timeout_callback(),该函数只 append,而 Device._update_wakefulness() 在超时边沿遍历全部历史回调。它们注册的是无参数的 gui_app.pop_widget(),不是「仅关闭此预览实例」。先后打开多个预览后,下一次超时可连续弹出多个当前导航页——这在源码层面可推导,具体导航栈 / 事件调度需 GUI 故障注入。

    两套底层 CameraView 均在构造时向 UIState 注册绑定实例的 offroad 回调;close() 释放纹理 / 客户端,但未注销该回调。UIState.add_offroad_transition_callback() 也只追加,因此已关闭实例仍可能被列表引用,长期反复打开可能保留对象并在状态切换时调用已关闭视图。

    把预览的「请求驾驶员模型 demo」定义为有所有者、可撤销、带超时的租约,是避免裸布尔 Params 在多个 UI 页面之间互相覆盖的方向;回调订阅、VisionIPC 客户端、纹理与超时覆盖都需要可重入的 show / hide / close 生命周期测试,至少覆盖连续打开、页面切换、超时、摄像头断流、离线转行车与 GUI 重启。

    常见问题

    座舱预览能画出面框,是否说明驾驶员监测已就绪?

    不能。预览把 driverStateV2 与 driverMonitoringState 拼在一起绘制,但两者没有同帧对齐或新鲜度门,绘制侧也不检查模型消息的年龄或 valid;demo 模式还使用固定的模拟速度、使能和标定。预览是展示 / 测试路径,不构成 DMS 安全就绪的证明。

    预览为什么会影响声音?

    mici 预览用 new_message('selfdriveState') 只填 alertSound 发布消息,默认 valid=False;而 Soundd.get_audible_alert() 只检查是否 updated,读取 alertSound 而不看 valid。这就让预览能驱动同一条声音主题,该主题同时被控制、规划、UI 读取。

    反复打开预览会有什么副作用?

    两处回调都只追加不注销:交互超时回调追加无参数 pop_widget(),offroad 回调在 close() 后仍保留引用。源码层面可推导下次超时可能连续弹出多个导航页、已关闭视图仍可能被调用;具体调度与资源增长需故障注入验证。

    预览按钮的驻车限制够不够?

    不够。两个入口只用 is_offroad 在点击时禁用按钮,这是入口门而不是对话框运行期间的持续检查;源码未提供预览发布者的独立主题或持续的驻车发布门。降低同时行车概率不等于运行期安全约束。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CABIN_PREVIEW_DM_AUDIO_LIFECYCLE_DEEP_DIVE.md(取用了双屏入口、DM 面框绘制、声音主题发布与回调生命周期分析)。

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

    更新记录

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