标签: 相机视觉

  • JotPluggler 视频与缩略图:8 帧缓存与解码器共享

    结论:JotPluggler 的相机与缩略图路径是离线路段媒体查看——实时模式会排除四类编码索引/编码数据、直播编码索引及 thumbnail,因此不能把它描述成实时行车画面输入。build_camera_index() 按数组最短长度合成 (timestamp, segment, decode_index, frame_id) 并按时间排序,但不逐项核对多条字段曲线时间戳一致性、也不在建索引时验证 decode_index 在媒体帧数内,无效索引要到取帧时才失败。解码器按 (CameraType, width, height) 复用,锁只覆盖获取/建立过程,若两路映射到相同组合并发解码,存在共享可变解码状态竞争风险(跨文件静态推断,未复现)。以下为固定快照 c8fb906 的静态阅读,未下载视频、未解码损坏媒体、未运行 GUI、未测内存/帧时延。

    路段中的图像索引

    load_route_data() 在路段数据归一化后,调用 build_camera_index() 分别建立窄路、座舱、广角路和 qcamera 四类索引。它先收集有文件路径的片段,再从对应 *EncodeIdx 曲线读取 segmentNum、segmentId(缺失时 segmentIdEncode)及可选 frameId;按数组最短长度和同一索引组合成 (timestamp, segment, decode_index, frame_id),最后按时间排序。缺少索引曲线时虽然有文件路径,也不会形成可按时间选帧的条目。

    frame_id 会保留在结构中,但本工具取帧请求使用的是 decode_index。build_camera_index() 没有逐项核对多条字段曲线时间戳一致性,也没有在建索引时验证 decode_index 在媒体文件帧数内;实际无效索引要到 FrameReader::get() 才失败。这与 GPS 字段按索引拼接的风险类似:对丢字段、乱序、跨段切换需要带真实日志的对齐测试。

    extract_segment_thumbnails() 另从 THUMBNAIL 事件提取 JPEG,时间优先用 timestampEof,为零时退回事件 logMonoTime;build_route_data() 排序并与其他数据共用时间原点。

    相机请求、解码与纹理

    四个 CameraFeedView 各自有需求线程,围绕「选帧 → 解码 → 上传纹理」运转。下表列出实现行为与边界。

    函数 / 组件 实现行为 边界
    CameraFeedView::Impl::setCameraIndex() 替换相机索引、清空待处理请求和 8 帧结果缓存、递增 generation,按首个片段组织预加载 切换路段会重置纹理与请求;旧下载/解码通过 generation/serial 检查避免结果误入新路段
    request_for_time() / update() 根据 tracker 时间选最近索引帧;优先当前帧或缓存,允许先显示同片段附近帧,再排入最新请求 附近帧只是临时可视回退,不应作为精确时间证据;失败帧记入 failed_request,相同请求不立即重试
    demand_worker_loop() 加载片段的 FrameReader,按 decode_index 解码进 VisionBuf,NV12 转 RGBA;完成后向 UI 交付并向前预取两帧 RGBA 按 width * height * 4 分配;媒体尺寸异常时需端到端限制和压力测试
    preload_worker_loop() / ensure_reader_loaded() 两个预加载线程按与当前片段距离预开其他视频,需求线程可等待同片段的在途加载 readers 是按片段保存的 map,本路径未见固定数量/字节上限
    upload_pending_result() / drawSized() UI 线程上传 RGBA 到 GL 纹理;Fit 模式裁切填满 pane,另一模式保持原比例完整显示 8 帧 RGBA 缓存之外还有纹理、解码缓冲及预加载 reader
    FrameReader::load() / DecoderManager::acquire() HTTP(S) 视频先走本地缓存再用 FFmpeg 探测/建帧索引;按相机类型和尺寸复用解码器 锁只覆盖获取/建立过程;FFmpegVideoDecoder::decode() 使用实例上下文与帧缓冲,未见同实例解码互斥

    这套链路也有资源上界缺口:readers 是按片段保存的 map,本路径未见固定数量或字节上限,长路段可能积累解析器与索引;四个 CameraFeedView 各自有需求线程,若两路映射到相同 (CameraType, width, height) 并发解码,存在共享可变解码状态竞争风险(跨文件静态推断,需并发用例确认)。

    缩略图与实时限制

    ThumbnailView::Impl::update() 用二分查找选离 tracker 最近的缩略图;decode_jpeg() 使用 FFmpeg MJPEG 解码,仅接受 YUV420/422/444 类格式,手工转换成 RGBA,随后上传单个 GL 纹理。成功图像按比例居中并叠加时间、片段、序号;失败索引在当前缩略图集合内不反复解码。rgba.resize(width * height * 4) 前只检查解码尺寸大于零,未见更小的像素/内存预算,应以异常 JPEG 测试资源约束。

    环节 行为 边界
    缩略图选图 二分查找选离 tracker 最近的缩略图,时间优先 timestampEof、为零退回 logMonoTime 失败索引在当前集合内不反复解码
    JPEG 解码 FFmpeg MJPEG,仅接受 YUV420/422/444 类格式,手工转 RGBA 后上传单个 GL 纹理 rgba.resize(width * height * 4) 前只检查解码尺寸大于零,无更小像素/内存预算
    实时订阅排除 实时模式排除四类编码索引/编码数据、直播编码索引及 thumbnail 相机与缩略图路径是离线路段媒体查看,不是实时行车画面
    隐私 座舱视频与缩略图可能涉及人脸、乘员与道路位置 导出、远程下载、缓存清理及访问控制应分开设计

    关键限制来自 should_subscribe_stream_service():它在实时模式排除四类编码索引/编码数据、直播编码索引及 thumbnail,因此上述相机和缩略图路径是离线路段媒体查看,不能把它描述成实时行车画面输入。工具读到的座舱视频和缩略图可能涉及人脸、乘员与道路位置;导出、远程下载、缓存清理及访问控制应分开设计。

    对「万物即插件」的约束

    建议拆成 VideoIndexProvider、FrameDecoder、ThumbnailDecoder 和只读 MediaPane。索引契约必须声明时间基准、片段编号、解码帧序号及缺帧语义;解码器实例要么独占线程,要么明确同步。宿主统一限制像素数、缓存字节、打开的 reader 数及并发下载;出错时向用户标出「正在显示附近帧/上一帧」,避免将近似画面误当当前时刻。媒体插件不得获取 CAN TX、固件更新或安全配置权限。

    同目录测试仅检查帮助输出;本文未运行损坏 HEVC/JPEG、并发多路、远程下载、GPU 纹理或内存压力测试。

    常见问题

    这是实时行车画面吗?

    不是。实时模式会排除四类编码索引/编码数据、直播编码索引及 thumbnail,因此相机与缩略图路径是离线路段媒体查看,不能描述成实时行车画面输入。

    8 帧缓存就是内存上界吗?

    不是。8 帧 RGBA 缓存之外还有纹理、解码缓冲及预加载 reader,不能用「8 帧」估算总内存上界。

    索引里的 decode_index 一定有效吗?

    不一定。build_camera_index() 不在建索引时验证 decode_index 在媒体文件帧数内,实际无效要到 FrameReader::get() 才失败。

    两路相机同时解码安全吗?

    不能假定安全。解码器按 (CameraType, width, height) 复用,锁只覆盖获取/建立过程;若两路映射到相同组合并发解码,存在共享可变解码状态竞争风险。这是跨文件静态推断,需并发视频用例或 ThreadSanitizer 确认,不能视为已复现故障。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/JOTPLUGGLER_VIDEO_THUMBNAIL_DEEP_DIVE.md(相机索引、请求/解码/纹理链与缩略图边界)
    • 项目 02 —— openpilot-research/JOTPLUGGLER_DATA_PIPELINES_DEEP_DIVE.md(图像链所在的离线/实时数据管线,作为本文的补充阅读)

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

    更新记录

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

  • 视频编码:录制与直播分流,切段由帧号阈值决定

    结论:视频编码链里「录制」和「直播」是两条分流:loggerd 订阅非直播 EncodeData 并转为媒体文件与 EncodeIdx,直播主题由 WebRTC 消费、不按录制媒体流写盘。切段由帧号阈值决定——每路按 SEGMENT_LENGTH × MAIN_FPS 关闭/重开编码器,段长通常 60 秒、20 FPS,但输入缺帧时不能只按墙上时钟推断切段位置。

    本文基于固定源码快照的静态阅读,未取得完整 LFS/平台环境,也未在相机或 V4L 编码硬件上运行。采集端与落盘端属于相邻专题,这里聚焦编码进程、多相机同步、后端差异与切段/索引。

    进程、相机和消息边界

    encoderd 随 onroad 运行;stream_encoderd 用同一可执行文件加 --stream,在直播或非汽车模式运行;部分硬件另有 --clip 离线导出入口,不属于这两个实时进程。三者的输入都是 camerad 通过 VisionIPC 发布的帧。

    边界 已核对行为
    进程选择 encoderd 随 onroad 运行;stream_encoderd 同可执行文件加 --stream;--clip 是离线导出,不属于实时进程
    相机表 窄路面流发主录制流(硬件 HEVC、PC FFVHUFF)与低码率 qNarrowRoadEncodeData;广角、座舱各有主流
    座舱流 座舱主流的 record 取决于 RecordFront
    输入 启动时探测可用 VisionIPC 流,每个可用流起一个编码线程;连接后从帧缓冲及 VisionIpcBufExtra 读取帧号/时间戳
    输出 发布编码字节、尺寸、Unix 发布时刻、帧 ID、SOF/EOF、编码 ID、段号/段内 ID 与关键帧标记
    时间域 logMonoTime、采集 EOF 和 Unix 发布时间是不同时间域
    录制/直播分流 loggerd 订阅非直播 EncodeData 转媒体文件;直播主题由 WebRTC 消费,不写盘

    实时调度与切段

    sync_encoders() 意图在多编码器起始阶段汇总可用流帧号,把共同起点推到当前帧后两个帧号,每路达到阈值才编码。但父线程在增加 max_waiting 的同时逐个启动子线程,等待数尚未冻结,早启动的线程可能误走单路分支。即使门正常通过,也只同步起始帧 ID,不等于整段硬件时钟同步。

    encoder_thread() 检查 VisionIPC 缓冲自身帧 ID 与附加元数据的帧 ID 是否相等,失配则跳过,首次落后时记日志。每路按 SEGMENT_LENGTH × MAIN_FPS 的帧号阈值关闭/重开编码器。

    缩略图有独立的节拍:同一窄路面帧会分别送主编码器和 qcamera 编码器;缩略图由 JpegEncoder::pushThumbnail() 在 frame_id % 1200 == 100 时生成,使用 JPEG 消息发布,不是每帧都有缩略图。

    loggerd.handle_encoder_msg() 对齐编码器段号与当前日志段,新段创建 writer、等待关键帧后写入媒体,并把 EncodeData.idx 转为 EncodeIdx 写入事件日志。encoderd、loggerd 独立启停,段号对齐依赖首次收到的偏移,不能把编码器内部段号直接当路由段号。

    切段与索引的对应关系需要端到端核对:编码器按帧号阈值分段,loggerd 再按段号把媒体写入文件并把 EncodeData.idx 转为 EncodeIdx;两个进程独立启停,因此「编码器内部段号」与「路由段号」不能默认相同。跨段队列、音频等待与段边界处的关键帧对齐,都需要单独验证,不能只凭媒体文件存在就判定编码链完整。

    编码后端:硬件与 PC 不等价

    V4LEncoder 打开 Qualcomm V4L2 编码设备、提交 NV12 缓冲;后台 dequeue_handler() 接收配置头和编码包,以 EOF 时间戳断言输入/输出配对,关键帧带配置头发布,段结束时等待队列排空及 EOS。这些时序需实板确认。

    后端 行为与限制
    硬件(V4L) 打开 Qualcomm V4L2 编码设备、提交 NV12;以 EOF 时间戳断言输入/输出配对,关键帧带配置头发布,段结束等待队列排空及 EOS
    PC(Ffmpeg) NV12 转 I420、必要时缩放;qcamera 用 H.264,其他流用 FFVHUFF;header 为空,set_bitrate() 和 request_keyframe() 仅记「不支持」
    缩略图 NV12 下采样经 FFmpeg MJPEG 输出 thumbnail;尺寸与源流比例用断言约束

    软件后端不是硬件 HEVC 的等价替代。直播时 encoder_set_bitrate() 与 encoder_request_keyframe() 每帧读取参数,WebRTC 侧通过 LivestreamEncoderBitrate 和 LivestreamRequestKeyframe 交互;PC 后端只记录不支持。把直播插件换成独立编码器时,应明确参数所有权、关键帧请求的确认与超时。

    故障边界与验证

    源码可见的故障路径包括:VisionIPC 尚未可用时重试、帧号失配跳帧、编码失败记录、V4L poll 超时继续等待、V4L 输入/输出时间戳失配断言。一个容易被忽略的边界是:外层 VisionIPC 重连后并未清空已创建的编码器或重做多相机起始同步;若新流尺寸/帧号语义改变,恢复行为需要专门测试,不能从重连循环存在就推断安全恢复。

    故障路径 当前处理 未覆盖的部分
    VisionIPC 不可用 重试 重连后未清编码器、未重做起始同步
    帧号失配 跳过该帧,首次落后时记日志 恢复后帧号语义改变的行为待测
    V4L poll 超时 继续等待 无独立截止语义
    输入/输出时间戳失配 断言 需实板确认

    test_encoder.py 是硬件测试入口,会检查媒体文件、帧数、编码索引及多相机首帧一致性;本次未运行。面向新硬件,至少应验证缓冲所有权、帧号/时间戳、编码包与索引一一对应、关键帧切段、断流重连和录制/直播互不抢占。

    常见问题

    录制和直播走的是同一条链路吗?

    不是完全同一条。loggerd 订阅非直播 EncodeData 并转为媒体文件与 EncodeIdx;直播主题由 WebRTC 消费,不按录制媒体流写盘。两者共享编码器输出,但下游消费与落盘路径不同。

    切段是按墙上时钟还是按帧号?

    按帧号阈值。每路按 SEGMENT_LENGTH × MAIN_FPS 关闭/重开编码器,段长通常 60 秒、20 FPS;输入缺帧时不能只按墙上时钟推断切段位置。

    VisionIPC 重连后就等于安全恢复了吗?

    不能这样推断。源码里外层重连后并未清空已创建的编码器或重做多相机起始同步;若新流尺寸或帧号语义改变,恢复行为需要专门测试。重连循环存在,不等于恢复过程安全。

    PC 上的软件编码能替代硬件编码吗?

    不能当成等价替代。硬件走 Qualcomm V4L2、关键帧带配置头、段结束等待 EOS;PC 后端 NV12 转 I420、header 为空,set_bitrate() 和 request_keyframe() 仅记「不支持」。直播插件替换编码器时要明确参数所有权与关键帧确认。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/VIDEO_ENCODING_DEEP_DIVE.md(取用了编码进程与相机表、实时调度与切段、三类编码后端、故障边界与验证范围)。
    • 项目 02 —— openpilot-research/CAMERA_IMU_DEEP_DIVE.md(仅用于确认采集端 VisionIPC 帧边界,未取用其未公开内容)。

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

    更新记录

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

  • joystick 输入生产者:100 Hz 发布与 0.2 秒失联门的缺口

    结论:joystick_control 是一个输入生产者:它要么读终端键盘、要么读游戏手柄,然后由一个独立的守护线程以 100 Hz 目标节拍发布 testJoystick。三个事实决定它的边界:轴值是运行期估计(极值动态扩展,未标定);发布线程不检查输入年龄与 cancel,采集阻塞时旧轴值仍能被封装成新的 valid=True 消息;下游 joystickd 的约 0.2 秒帧差失联门只感知消息停发,识别不了「重复旧输入的新消息」。要注意这描述的是软件路径,不是实车会保持执行器动作的结论——selfdriveState、card 和 panda 安全策略仍独立约束输出。

    两个实现:终端键盘与游戏手柄

    入口带一个容易被误读的开关:直接执行脚本时会检查 IsOffroad,但设置 ZMQ 环境变量会跳过该入口检查,而 main() 本身直接构造游戏手柄。这是开发工具的启动约束,不是强制的进程级安全策略,ZMQ 的存在也不能视为网络身份认证。

    组件 / 函数 具体行为 边界与含义
    Keyboard.update() 阻塞读取一个字符;W/S 与 A/D 分别按 0.05 累积,R 归零,C 将本地 cancel 置真,下一次输入又先置假。 轴值不自动归中;输入停止不触发归零,长期保持的值由发布线程继续发送。
    KBHit.__init__()、set_kbhit_terminal()、set_normal_term() 从 sys.stdin 取 fd,保存 termios,清除 ICANON/ECHO 并以 TCSAFLUSH 应用;注册进程正常退出时的恢复回调。 非 TTY 的 tcgetattr() 会失败;异常/信号强杀不保证 atexit 执行,终端状态不是按对象生命周期恢复。
    KBHit.getch()、getarrow()、kbhit() 单字符阻塞读、三字符方向键读取、select(..., 0) 非阻塞就绪查询分别作为静态方法暴露。 Keyboard.update() 只调用 getch(),未先查询 kbhit();getarrow() 假设读满三字符,畸形序列可抛异常。
    Joystick.update() get_gamepad()[0] 处理首个事件,按 PC/车载不同映射转换纵横轴;采集到的极值动态扩展区间,再插值、死区与三次曲线。 极值是运行期估计,不是经标定的设备物理量;只按首个事件更新。已处理的拔出异常会归零,其他异常或采集线程停止不进入该分支。
    send_thread() 独立守护线程以 100 Hz 目标节拍发布 testJoystick,把外层 valid 置真,只复制 axes_order 对应的两轴值。 不检查采集线程是否阻塞、上次真实输入时间或 cancel。
    Joystick schema 结构有 axes 和 buttons;服务表频率为 0。 当前生产者只填 axes;cancel 不是该生产者消息的一部分,下游也只取轴值。

    发布线程不看输入:100 Hz 与约 0.2 秒失联门

    send_thread() 周期性复制 axes_values。采集线程没有为每次输入维护时戳,发布线程也不检查 joystick.cancel。因此在主采集循环阻塞、设备静默但不抛已处理异常时,生产端仍会产生新的 valid=True 两轴消息。joystick_control_thread() 先同步写入 JoystickDebugMode=True,启动发布线程,主线程无限调用 update(),没有显式 stop/join/模式撤销协议。

    下游 joystickd 的约 0.2 秒帧差失联门只感知消息停发,不会识别这类「重复旧输入的新消息」。探针也印证了这一点:send_thread() 在轴值不变且 cancel=True 时仍连续发送相同的两轴,每条 valid=True 且不填 buttons。这里要重复一次边界——这描述的是软件路径,selfdriveState、card 和 panda 安全策略仍独立约束输出,不能据此断言实车会保持执行器动作。

    生命周期缺口 源码事实 后果
    输入年龄门 采集线程无每次输入时戳,发布线程不查上次输入时间 采集阻塞仍发布旧轴为「新消息」
    取消传播 C 只改本地 cancel,且下次按键即清除;消息不含取消字段 取消不会作为消息传给下游
    发布时钟 发布按 100 Hz 目标节拍,与采样时钟无关 发布时钟不能代表采样新鲜度
    关闭握手 无显式 stop/join/模式撤销协议 进程退出才停;不能推断采集卡住时会停止发布

    模式参数与下游 joystickd:两种进程切换方式

    JoystickDebugMode 在 manager 启动/下线时切换清理:车载调试模式用它把控制权在 controlsd 与 joystickd 之间切换。这里要区分两件事——参数清理不等于 testJoystick 发布生命周期监测,模式选择与输入有效性需要分开验证。下游 joystickd 只取轴值并设置帧数超时门,而 card 与 panda 各自还有独立的安全门。

    游戏手柄一侧的映射也不是只有一套:Joystick.update() 按 PC 与车载两套映射转换纵横轴,采集到的极值动态扩展区间后,再经插值、3 百分比死区与三次曲线。探针确认 PC 扳机翻转后会再归一化,已处理的拔出异常会清零轴值。源文档还列出若干待验证项:不同游戏手柄 inputs 事件顺序与极值、真实拔出/静默行为、GIL 调度下两个线程的读写时序、消息停发后的实际时间,以及车辆安全钩子的响应;当前归档目录没有针对这些边界的专用原仓库单元测试,本篇的隔离探针不能替代真实设备验证。

    输入契约:采样时钟不能由发布时钟代替

    源文档对插件化的要求很明确:输入插件输出应包含设备身份、采样单调时戳、序号、校准版本、轴语义/单位以及明确的取消事件,发布时钟不能代替采样时钟;宿主按真实输入年龄与设备健康授予短期租约,过期、拔出、采集线程失活或异常时撤销租约,并且发布无效/零值不能伪装成新鲜的真实操纵;测试控制与生产控制分属隔离命名空间,即使测试输入有效,也只形成受监督的候选指令,必须继续通过唯一控制仲裁和独立硬件安全门。

    还要留意终端资源这块的实测边界:Keyboard() 构造时才创建 KBHit,但 joystick_control_thread() 没有 finally 或显式调用 set_normal_term();正常 Python 退出时 atexit 尝试恢复,被强制终止则不能保证。此外该类注释里「Windows no-op」的说法与模块顶层 import termios 不一致,不能把它当跨平台终端插件接口。

    常见问题

    为什么「消息还在发」不代表输入还新鲜?

    因为 send_thread() 按 100 Hz 目标节拍复制 axes_values,而采集线程没有为每次输入维护时戳,发布线程也不检查上次真实输入时间或 cancel。采集阻塞但进程仍在时,旧轴值仍会被封装成新的 valid=True 消息。

    终端里按 C「取消」会传给下游吗?

    不会。C 只是把本地 cancel 置真,且下一次输入又会先置假;cancel 并不是该生产者消息的一部分,下游 joystickd 也只取轴值。因此不能把终端说明中的取消键理解为已传播的取消命令。

    游戏手柄拔出后轴值会归零吗?

    对已处理的 OSError/UnpluggedError 会归零并返回假;但源文档指出其他异常或采集线程停止并不进入该归零分支,而且极值是运行期估计,不是经标定的设备物理量。

    设置 ZMQ 环境变量是否等于更安全?

    不是。设置 ZMQ 只是跳过 CLI 入口的 IsOffroad 检查,改变的是本地检查,不能被当作网络身份认证或进程级安全策略。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/JOYSTICK_INPUT_PRODUCER_DEEP_DIVE.md(取用了键盘与游戏手柄两种输入实现、100 Hz 发布与约 0.2 秒失联门缺口、六项隔离探针结果与插件化输入契约)。

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

    更新记录

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

  • VisionIPC 缓冲契约:FD 握手、帧通知与复用覆盖窗口

    结论:VisionIPC 用两条独立通道搬运图像:Unix socket 用 SCM_RIGHTS 传 FD 与缓冲描述,msgq 只发 {server_id, idx, extra} 小包。图像字节在共享缓冲里循环复用,因此「msgq 收到帧通知」不等于「当前像素通过完整性与时序验证」——包顺序并不保证旧包对应的像素仍是旧帧。

    双通道结构:FD 握手与帧通知

    相机采集器创建固定数量的 VisionBuf(NV12 共享内存或 ION),客户端通过 Unix socket 的 SCM_RIGHTS 拿到 FD 并导入内存;随后每次发布只发一个含 server_id、缓冲索引与 extra 的小包。通知通道与描述/FD 通道是不同的故障面:前者决定图像何时被认为可读,后者决定缓冲能否安全导入。

    VisionIpcServer.listener() 在 Unix socket 接受流类型请求,可返回流列表,或把该流的元数据与所有缓冲 FD 一起传给客户端,返回描述里附上服务端随机 server_id 并清掉私有 ION handle 字段。VisionIpcClient.connect() 先释放旧映射,再接收 FD、导入内存并初始化 NV12 指针;源码明确写明该操作非线程安全,调用期间不能使用旧缓冲。

    缓冲创建与 NV12 布局

    默认 create_buffers() 用 width*height*3/2、stride=width、uv_offset=width*height,不能直接套在有 ISP 行对齐的硬件上。相机侧使用带实际尺寸的接口创建 18 个 YUV 缓冲。num_buffers 只要求小于 VISIONIPC_MAX_FDS=128,没有在此处证明任意数量足以避免覆盖。

    缓冲分配还涉及平台差异:通用平台用共享文件 FD 加 mmap,图像区尾部再留 8 字节存 frame_id;通用实现的 sync() 是空操作,不代表所有 GPU/ISP 一致性已保证。ION 分支申请共享句柄并 mmap,sync() 通过 ION ioctl 做缓存操作;新硬件的 DMA-BUF 分配和 cache API 不能仅替换文件名或跳过同步。

    函数与数据契约

    组件/入口 行为 不能假定
    VisionBuf::allocate() 通用平台用共享文件 FD 加 mmap,图像区尾部再留 8 字节存 frame_id。 通用实现的 sync() 是空操作,不代表所有 GPU/ISP 一致性已保证。
    VisionBuf::allocate() ION 版 Qualcomm/ION 分支申请共享句柄并 mmap,sync() 通过 ION ioctl 做缓存操作。 新硬件的 DMA-BUF 分配和 cache API 不能仅替换文件名或跳过同步。
    VisionIpcServer::get_buffer() 默认按索引循环取缓冲,或由摄像头指定索引。 无消费者释放/引用计数等待;源码此处还有「是否检查已发送」的待办。
    VisionIpcServer::send() 可选执行缓存同步,然后发 {server_id, idx, extra} 小包。 msgq 包不是图像快照;发送后对应共享缓冲仍可被生产者重用。
    VisionIpcClient::recv() 按 msgq 包索引找已导入缓冲;越界或 server_id 不同会断开,成功时缓存同步并返回指针。 不比对共享缓冲尾部 frame_id 与 packet extra.frame_id,也不验证像素读取期间未被再次覆盖。
    VisionIpcBufExtra 通知含 frame_id、SOF/EOF 时间戳和 valid。 Python 发送包装固定写 valid=False;本快照原生相机的三字段聚合初始化也令其默认为 false,不能把该位作为普遍帧健康门。
    send() Python 包装 复制输入字节进下一个缓冲、写尾部帧号并发通知。 这是一次 CPU 拷贝路径,不等于通用零拷贝采集。

    复用覆盖窗口:包顺序不等于像素帧

    缓冲与通知异步推进,包顺序不保证旧包对应的像素仍是旧帧。例如只创建一个缓冲,连续发送帧 1、帧 2 后再让非 conflate 客户端读取:通知可能先返回 extra.frame_id=1,但同一缓冲已被帧 2 改写。现有非合并测试只检查两次通知的 frame_id,未在这种情况下比较两次读取的像素内容。这是代码结构推得的覆盖风险,源文档未声称已在本机实际复现。

    conflate=True 只跳过旧通知,不能独立证明最新缓冲在 GPU/CPU 读取完成前不被下一轮覆盖。因此需要在目标板上记录 packet.frame_id、共享尾部 frame_id、像素哈希、SOF/EOF 与推理/显示使用时刻,注入断连、重启、丢帧和消费者长尾耗时,才能验证这一窗口。

    重连与插件契约

    VisionIpcClient::recv() 的越界/server_id 检查能识别服务端重建、索引集合变化,但只在收到新通知时触发;没有通知时返回 nullptr,并不会自动重新握手。UI 的 CameraView 有连接重试/切流逻辑;道路 modeld 和 dmonitoringmodeld 在启动时循环连接,主循环遇到 recv() is None 时继续等待,源码未在循环里显式重新 connect()。不能从 UI 的恢复路径推断模型进程也能无重启恢复。

    消费者 连接与重连行为 conflate
    UI CameraView 有连接重试/切流逻辑 —
    modeld 主相机 启动时循环连接,主循环 recv() is None 只继续等待 True
    modeld 广角附加流 同上,并以 SOF 时间戳对齐 False
    dmonitoringmodeld 启动时循环连接,源码未见循环内显式重连 —

    modeld 主相机使用 conflate=True,广角附加流使用 False 并以 SOF 时间戳对齐;即使两路通知的时间相差超过 10 ms,代码记录错误后仍向后执行。它依赖同一时钟域和合理缓冲复用窗口。自制相机插件必须说明:SOF/EOF 来自何处、时钟域/单位、帧号如何跨 VisionBuf 尾部与通知保持一致、行步长/UV 偏移、缓冲数和最大持有时长、缓存同步方向、服务端重启后的 FD 重新导入。

    常见问题

    msgq 收到帧通知,是否代表像素已经就绪?

    不代表。msgq 只发 {server_id, idx, extra} 小包,图像字节在共享缓冲里;缓冲与通知异步推进,包顺序不保证旧包对应的像素仍是旧帧。recv() 也不比对共享缓冲尾部 frame_id 与 packet 的 extra.frame_id。

    为什么只创建一个缓冲特别危险?

    因为连续发送帧 1、帧 2 后,通知可能先返回 extra.frame_id=1,但同一缓冲已被帧 2 改写,客户端读到的像素已不是帧 1。现有非合并测试只检查两次通知的 frame_id,未比较像素内容。

    conflate=True 能保证最新缓冲不被覆盖吗?

    不能。conflate=True 只跳过旧通知,不能独立证明最新缓冲在 GPU/CPU 读取完成前不被下一轮覆盖;仍需在目标板上用像素哈希与时间戳验证覆盖窗口。

    模型进程会像 UI 一样自动重连吗?

    源文档未给出该结论。UI 的 CameraView 有连接重试/切流逻辑,但道路 modeld 与 dmonitoringmodeld 在启动时循环连接,主循环遇到 recv() is None 只继续等待,源码未在循环里显式重新 connect()。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/VISIONIPC_BUFFER_CONTRACT_DEEP_DIVE.md(取用了双通道结构、缓冲创建与 NV12 布局、函数与数据契约及复用覆盖窗口)。
    • 项目 02 —— openpilot-research/VISIONIPC_UNIX_FD_HANDSHAKE_ERROR_CONTRACT_DEEP_DIVE.md(仅用于确认 FD 握手与错误码传播的边界)。

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

    更新记录

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

  • 模型回放:瞬时 50 ms、平均 30/18 ms 与 PC 放宽门控

    结论:模型回放的时序门是样本统计而非端到端保证——modelV2 瞬时上限 50 ms、平均上限 30 ms(检测到大模型时 50 ms),driverStateV2 瞬时上限 50 ms、平均上限 18 ms,二者都丢弃第一条执行时间再取 max/mean。PC 模式会额外忽略位置、车道线、道路边缘、前车轨迹等字段并使用 0.3 的比较容差,CI 下比较结果 failed 被强制清零,因此 CI 的零退出码不能作为严格数值门的通过证据。以下为固定源码归档的静态阅读与纯消息替身探针,未下载路段、未加载模型、未执行模型。

    关键组件与函数

    该入口把道路模型和座舱模型分成两条独立回放链,各自裁取消息、插入标定与设备状态,再顺序调用回放。下表按组件列出职责与约束。

    函数 / 组件 实际职责 约束与风险
    get_log_fn() 由路段、CHESTNUT 和参考名构造 .zst 文件名 CHESTNUT 来自命令行 --chestnut;文件名不是模型权重/设备身份的完整证明
    plot() / generate_report() 把指标序列画成 PNG,并从 modelV2 取 8 项、driverStateV2 取 7 项,共生成 15 张曲线 proposed == master 是 Python 序列精确相等,仅决定报告分组,不参与主判定
    comment_replay_report() 有关联 PR 时上传图片及本次完整日志,并发布评论 其中上传/评论异常在主程序的比较 try 内会转为 failed=True,不能把报告服务失败直接解读为模型输出不等价
    trim_logs() 按相机状态计数选窗口,再把指定辅助主题的全部原始消息追加 默认 START_FRAME=0 时从首条消息开始纳入;追加可能重复和打乱时间顺序
    model_replay() 道路模型和座舱模型分别裁取消息、插入标定/设备状态,顺序调用回放;随后检查大模型状态及执行耗时 两条回放链分别启动进程,不是单个端到端闭环;缺首条标定或设备状态会 StopIteration
    get_frames() 从固定缓存反序列化,或取三路 .hevc,预热 [START_FRAME, END_FRAME) 的 NV12 帧后把 FrameReader pickle 落盘 缓存名只有路段/段号/窗口,未包含权重、软件提交、解码器和视频内容哈希;pickle.load() 要求缓存文件可信
    __main__ 读取路段、回放、按模式比较或更新参考,写 model_diff.txt,用 failed 退出 更新、CI、PC 三个模式改变判定语义,不能只看退出码

    帧窗口与消息来源

    道路模型以 narrowRoadCameraState 与 wideRoadCameraState 双主题计数,座舱模型以 cabinCameraState 计数,并分别追加 EncodeIdx、车辆/CAN 等辅助主题,默认窗口 START_FRAME=0、END_FRAME=60。SEND_EXTRA_INPUTS=0 时会删除裁取日志里的 extrinsicsCalibration,但随后从原路段无条件取首条标定和 deviceState,将其时间改为原日志首条时间并插入两条回放链的索引 1。故原日志为空或缺主题不是可降级场景。

    用不依赖 Cap’n Proto 的假消息执行原版 trim_logs():输入时间序列为 carState@1, other@2, narrow@3, wide@4,窗口 end_frame=1,并要求全量追加 carState。start_frame=0 的返回顺序是 carState@1, other@2, narrow@3, wide@4, carState@1;start_frame=1 返回 narrow@3, wide@4, carState@1。这说明辅助主题既可能重复又可能后置成非时间顺序;下游 migrate() 排序消息,不自动去重。另需注意双摄像头窗口的结束条件是所有相机状态计数恰等于 END_FRAME,其中一路缺帧时可能继续遍历到日志尾部。

    时序门与数值门

    时序门给出各主题的瞬时与平均上限,并统一丢弃第一条执行时间。

    主题 瞬时上限 平均上限
    modelV2 50 ms 30 ms(检测到大模型时 50 ms)
    driverStateV2 50 ms 18 ms
    统计方式 丢弃第一条执行时间 再用 np.max/np.mean;未覆盖首帧冷启动、端到端帧年龄或最坏调度延迟

    chestnut 由道路模型消息中是否有任一 big 标志推断;只有命令行指定 --chestnut 才断言道路模型每条均为大模型且至少有一条大模型输出。最后的 assert timings_ok or PC 使 PC 时序超限只打印,不阻断退出。

    非更新模式下,主程序按道路模型每帧预期 3 条输出、座舱每帧 1 条输出,从远端参考日志按位置切片,忽略 logMonoTime 和部分掉帧/执行时间字段,再调用比较。PC 又额外忽略位置、车道线、道路边缘、前车轨迹等字段,并使用 0.3 的比较容差;这不是「全部模型输出相等」。设置 CI 环境变量时,比较结果 failed 被强制清零,差异仍会写入报告。因此 CI 的零退出码不能作为严格数值门的通过证据。更新模式(--update 或 GIT_BRANCH=master)跳过参考比较,非 PC 才保存并上传新参考;上传异常只打印,不改变 failed,所以零退出码也不证明远端参考已更新。

    自制硬件与插件化验收

    应把道路模型、座舱模型和相机采集分别建成有版本的插件契约:输入包括 NV12 格式/尺寸、相机时钟和帧 ID、标定、设备状态、权重哈希;输出包括 schema 版本、每帧关联键、模型能力和执行时长。缓存以视频/权重/解码器/软件版本的内容哈希为身份,且不加载不可信 pickle。

    参考比较须声明忽略字段与阈值,单独记录实际覆盖帧数、缺帧、重复输入、首次启动耗时、持续时延与输出完整性。PC、CI 和目标车机分别给出独立通过位;实板热态/冷态、负载、供电降额、相机断流和故障回退仍需台架及封闭场地验证。本文没有验证这些动态指标。

    常见问题

    50 ms 与 30 ms 是端到端时延保证吗?

    不是。它们是样本统计:modelV2 瞬时上限 50 ms、平均上限 30 ms,两者都丢弃第一条执行时间再取 max/mean,未覆盖首帧冷启动、端到端帧年龄或最坏调度延迟。

    CI 通过就代表数值回归通过吗?

    不代表。设置 CI 环境变量时比较结果 failed 被强制清零,差异仍会写入报告;报告上传/评论异常则仍可能进入 except 导致失败。零退出码不能作为严格数值门的通过证据。

    PC 模式比较的是全部模型输出吗?

    不是。PC 额外忽略位置、车道线、道路边缘、前车轨迹等字段,并使用 0.3 的比较容差;这并不等于「全部模型输出相等」。

    输入日志的时间顺序有保证吗?

    没有。trim_logs() 把指定辅助主题的全部原始消息追加,可能重复且后置成非时间顺序;下游 migrate() 只排序,不自动去重。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PROCESS_REPLAY_MODEL_FRAMES_TIMING_REFERENCE_DEEP_DIVE.md(模型回放的帧输入、时序门、数值门与报告语义)
    • 项目 02 —— openpilot-research/ARCHITECTURE_GUIDE_CN.md(系统总架构中文导览,作为本文的补充阅读)

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

    更新记录

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

  • modeld 构建:6 通道 NV12 warp 与 256 字节对齐的带外权重

    结论:modeld 的构建产物链把三件事固定在一起:ONNX 模型编译(compile_onnx.py,--out-of-band)、相机布局相关的 warp 编译(道路 YUV420 双帧、座舱 luma 单帧)与运行期输入打包。道路 warp 把 NV12 拆成 Y 四相位加两个色度面,拼成模型的 6 通道张量;座舱 warp 只取 Y 平面并用 16 填充边框。要记住两个边界:构建脚本的种子 42/43 只是自洽检查,没有与独立 ONNX Runtime 或实车真值比较;工件是可执行对象序列化而非纯数据,不能把未认证的工件交给 pickle.load()。道路与座舱两条路径的缓冲所有权与过期帧风险不同,不能用道路双帧上传的验证代替座舱零拷贝验证。

    构建入口:三条后端路径与产物矩阵

    SConscript 在非 comma ARM64、非 Darwin 平台缺 libLLVM 时跳过模型编译。comma ARM64 用 DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1,Darwin 用 DEV=METAL JIT=2,其他平台用 DEV=CPU:LLVM。Darwin 分支的注释明确说明 JIT=2 是为了禁用在 buffer 变化后可能产生错误输出的 graph batching。这些标志写入构建动作和依赖,不能在不同后端之间只重命名 .pkl 产物。

    产物 构建调用 运行期消费者
    道路模型 driving_tinygrad.pkl 与座舱模型 dmonitoring_model_tinygrad.pkl compile_model() 调 compile_onnx.py,使用 --out-of-band --benchmark-runs 1。 modeld.ModelState.__init__() 与 dmonitoringmodeld.ModelState.__init__() 经 load_oob() 载入。
    每种相机尺寸的道路 warp SConscript 调 compile_warp.py --layout yuv420 --frames 2,frame size 按 stride 与 Y/UV 分配高度求得。 modeld 以 img/big_img 两帧和两个逆变换调用捕获图。
    每种相机尺寸的座舱 warp SConscript 调 --layout luma --border-fill 16 --transform-device NPY。 dmonitoringmodeld.ModelState.run() 用 VisionIPC 缓冲指针和主机变换矩阵执行。

    这里有一个显式的产物缺口:该 SConscript 没有定义 Chestnut 的 big_driving_tinygrad.pkl 或 big_driving_warp_* 构建动作,chestnut_compiled() 只检查这些文件是否存在。它们的来源和与当前模型的兼容性,不能由普通模型构建链推定。

    ONNX 捕获与序列化:自洽检查不等于数值正确

    onnx_metadata() 只扫描 ONNX 顶层 metadata 和 output 声明,不读取权重节点,计算由 OnnxRunner 负责。主程序从 graph input 推出 input_specs,先用随机填充输入运行一次得到 output_specs,再把 metadata、input_shapes、output_shapes、捕获的 run 与规格一起写入工件。运行时道路模型还从 metadata 的 output_slices 取切片,因此模型元数据和解析器版本属于同一接口契约。

    run() 以 TinyJit(prune=True) 捕获,对传入的 output_buffers 执行赋值并物化。构建脚本用种子 42 的固定随机输入作捕获、两次重复一致性和反序列化后一致性检查,并要求种子 43 的不同输入使输出发生变化,以避免完全忽略输入。但这些都只是自洽检查:没有与独立 ONNX Runtime、车辆标注或实车真值比较,--benchmark-runs 1 也不构成尾延迟统计。

    序列化层面,dump_pickle() 对默认设备上的 Buffer 使用 persistent_id,把内容按 256 字节对齐存到带外区域;load_pickle() 建立目标设备 arena,并按 32 MiB 逻辑块复制后恢复视图。这里的 pickle 是可执行对象序列化而非纯数据格式,自制插件不能把未认证的工件交给 pickle.load()。--retargetable 可去掉 CPU 程序中的二进制以便重新编译,但常规模型动作未传此参数。

    NV12 warp 的几何:逆 3×3、最近邻与越界填充

    warp_perspective_tinygrad() 对目标像素用逆 3×3 矩阵求源坐标,以 Tensor.round 做最近邻采样,先把索引裁到图像边界;若传 border_fill_val,再依取整后的坐标是否越界决定填充。它没有在本函数中显式处理齐次分母 src_w=0、NaN 或非法矩阵,上游标定/变换有效性必须单独校验。普通道路 warp 不给 border_fill,因此越界像素采用夹边采样;座舱 warp 明确用 16 填充。

    make_frame_prepare() 用 frame stride 与 Y 分配高度确定 UV 偏移,把 NV12 交错 UV 拆成 U/V,按缩放后的逆矩阵各自透视采样,再拼成 YUV420 面;frames_to_tensor() 把四个 Y 相位面和两个色度面排列成模型的 6 通道张量。它依赖偶数模型尺寸、相机 stride 与 Y/UV 分配高度同 get_nv12_info() 一致。道路运行时 ModelState.pack_inputs() 把两个帧的 Y/UV 有效分配区和浮点辅助输入放在一个 host upload 中,而不是复制包含尾部 padding 的整个 VisionIPC 缓冲;因此更换相机、NV12 pitch 或 buffer 长度须连同构建 warp、输入切片和运行时复制大小一起更新。

    两条运行路径的缓冲所有权不同

    make_luma_warp() 只取 NV12 的 Y 平面;座舱运行时在 ModelState.run() 中缓存 Tensor.from_blob(),让它指向 VisionIPC 帧内存,不复制完整帧。这与道路双帧的 host upload 路径在所有权和过期帧风险上都不一样,见下表。

    维度 道路模型(双帧 YUV420) 座舱模型(单帧 luma)
    输入布局 四个 Y 相位面 + 两个色度面,共 6 通道 仅 Y 平面,边框用 16 填充
    越界处理 不给 border_fill,夹边采样 明确 --border-fill 16
    上传方式 把两帧 Y/UV 有效分配区与浮点辅助输入合并为一次 host upload 缓存 Tensor.from_blob() 指向 VisionIPC 帧内存,零拷贝
    主要风险 复制大小与 NV12 pitch/stride 强耦合 帧内存所有权与过期帧

    移植边界

    源文档给出的做法是:目标硬件应固定 ONNX、元数据、相机尺寸/stride、逆变换、tinygrad 提交和设备编译器版本,先建立独立 CPU 数值基准;再比较道路 YUV420 六通道、座舱 luma 填充、模型输入/输出形状、跨帧状态与不同后端输出,最后测帧级尾延迟及断流/非法矩阵回退。核心原则是一句话:算法产物可插件化,但工件加载信任、结果有效性和车辆安全控制许可不能交由插件自证。本轮仅核对构建命令、源码和运行期消费,没有执行 ONNX/warp 构建,也未取得 LFS 模型实体。

    常见问题

    构建脚本里的数值一致性检查能证明模型正确吗?

    不能。种子 42 的捕获与一致性检查、种子 43 的输入敏感性检查都只是自洽检查,源文档明确它们没有与独立 ONNX Runtime、车辆标注或实车真值比较,--benchmark-runs 1 也不构成尾延迟统计。

    道路 warp 和座舱 warp 的关键差别是什么?

    道路 warp 用 --layout yuv420 --frames 2,把 NV12 拆成四个 Y 相位面加两个色度面凑成 6 通道,越界走夹边采样;座舱 warp 用 --layout luma --border-fill 16,只取 Y 平面并用 16 填充边框。两者的缓冲所有权与过期帧风险不同。

    warp 函数会处理非法变换矩阵吗?

    不会。warp_perspective_tinygrad() 用逆 3×3 矩阵求源坐标、Tensor.round 最近邻采样并裁到边界,但源文档明确指出它没有在本函数中显式处理齐次分母 src_w=0、NaN 或非法矩阵,上游标定/变换有效性必须单独校验。

    模型工件可以直接 pickle 加载吗?

    不可以随意加载。源文档说明这里的 pickle 是可执行对象序列化而非纯数据格式,自制插件不能把未认证的工件交给 pickle.load()。权重经 dump_pickle() 按 256 字节对齐存到带外区域,由 load_pickle() 建立 arena 后按 32 MiB 逻辑块复制恢复。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/MODELD_BUILD_ARTIFACT_WARP_CONTRACT_DEEP_DIVE.md(取用了构建后端标志与产物矩阵、ONNX 捕获与序列化边界、NV12 warp 几何与两条运行路径的缓冲所有权)。

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

    更新记录

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

  • 设备功耗验收:hwmon 增量不是逐进程瓦特计

    结论:设备功耗测试 test_power_draw.py 测的是同一 hwmon 节点读数的阶段增量,不是逐进程独立瓦特计:每个阶段把当前 get_power(1) 均值与前一阶段比较,prev 每阶段更新。因此它测到的是「依次加负载后的边际电源通道读数」,可能混入相机预热、GPU 频率、风扇、后台进程或上个阶段尚未稳定的影响。

    本文基于固定源码快照的静态分析及五项原测试类方法/假时钟探针;test_power_draw.py 依赖特定硬件、固定 sysfs 功率节点、真实进程和消息总线,本次没有运行设备测试、读取 hwmon、启动相机/模型或修改设备电源状态。底层功率采样的窗口边界属于相邻专题。

    硬件读数与函数索引

    read_power() 读取固定的 hwmon 功率节点,数值除 1e6 转为瓦特;节点缺失或读数不合法直接抛异常,没有探测或热插拔恢复。sample_power() 用 Ratekeeper(123) 读取约 123 × seconds 个样本,未返回每个样本时间戳;get_power() 对指定秒数样本取算术平均,测试基线默认 5 秒,候选窗口按每次 1 秒测。

    函数/数据 静态契约
    read_power() 读固定 hwmon 节点,除 1e6 转瓦;缺失或不合法直接抛异常,无恢复
    sample_power() Ratekeeper(123) 读约 123 倍秒数样本,未返回每样本时间戳
    get_power() 对指定秒数样本取算术平均;基线默认 5 秒,候选窗口每次 1 秒
    Proc 与 PROCS 进程名、期望功率增量、需要消息名及 np.isclose 容差;顺序为 camerad、modeld、dmonitoringmodeld、encoderd
    valid_power_draw() 比较同一 hwmon 节点读数的增量,不是进程独立瓦特计
    get_expected_messages() / valid_msg_count() 各服务标称频率乘 2 秒求和;仅比较总条数
    get_power_with_warmup_for_target() 为当前进程预订其消息并先清队列;最多 30 秒反复测 1 秒功率,保留最近两个样本及消息数
    test_camera_procs() 先测基线再逐个启动进程,当前平均功率作为下一阶段 prev;函数名虽指 camera,实际涵盖模型和编码器

    阶段增量与容差

    每一阶段比较 get_power(1) 的最近两次均值与前一阶段读数,prev 在每阶段完成后更新。测试既不把单个进程功耗隔离,也不减去同一时刻的独立空载对照。绝对容差分别是 camerad 0.4 W、modeld 0.2 W、dmonitoringmodeld 0.35 W、encoderd 0.12 W;相对容差默认 5%,实际 np.isclose 取两者合成范围。

    阶段进程 期望功率增量 绝对容差 消息门
    camerad 1.65 W(三相机消息) 0.4 W 按标称频率合计条数
    modeld 1.5 W(modelV2) 0.2 W 按标称频率合计条数
    dmonitoringmodeld 0.65 W(driverStateV2) 0.35 W 按标称频率合计条数
    encoderd 0.23 W(无消息门) 0.12 W msgs=[],期望数与实收数都为 0,恒通过

    valid_msg_count() 允许约 2% 相对误差或绝对 2 条,不逐主题核对;某一路缺失若被其它路超量抵消,仍可能通过总数门。

    测试真实覆盖与失效路径

    TestPowerDraw 继承 OpenpilotTestCase,标记为硬件测试。其 setup_method() 写入演示 CarParams;继承的 setUp() 会初始化硬件、关闭省电并终止 athena,teardown_method() 和 run() 的 finally 还会做 manager 清理。mock_messages(['deviceMotion']) 在测试期间另起后台发布线程。因此这项测试不是无副作用的算法单元测试。

    失效/边界 行为 后果
    30 秒超时退出 仍返回最后的 now/msg_counts 返回的 warmup_time 不是成功标志
    首样本就越过截止 循环在不足两个样本后退出 返回变量尚未赋值而抛 UnboundLocalError
    encoderd 的 msgs=[] 期望数与实收数都为 0 消息门恒通过,只靠功率增量间接判断
    合计消息门 只比较总条数 单主题缺失可被其它主题超量抵消

    隔离执行原 TestPowerDraw 方法后,假服务各 10 Hz、两秒窗口的 left=0 与 right=40 仍通过 40 条合计消息门;空主题列表也通过。假时钟在第一份功率样本返回后越过 30 秒,使 now/msg_counts 尚未赋值而抛 UnboundLocalError。已有两份样本但功率不达标时,超时返回最近两份均值而非错误;若两份样本达标则提前退出。该探针未执行 test_camera_procs(),不能证明真实服务频率、传感器功率或 manager 清理行为。

    manager_cleanup() 在测量尾部、测试 teardown 及继承的 run() 清理路径多次调用,主要作用是停止所有受管进程;若中途抛异常,测试体末尾的清理不会到达,但外层测试辅助类的 finally 仍尝试清理。它不能回滚硬件电源、温度或持久参数的外部状态。

    面向硬件插件的验收建议

    自研计算板/相机/推理加速器要把功耗基线、传感器/模型负载和安全 MCU 电源域分开测量,记录电压、电流、温度、频率、环境与样本时间戳,并对稳态/启动峰值/异常回退分别设门。

    1. 插件的「功率达标」不能替代图像帧有效性、模型输出数值一致性、消息新鲜度或 CAN 发送安全。
    2. 固定路径 hwmon 工具只适用于已知设备布局,不应作为跨硬件插件通用驱动接口。
    3. 验收应把电源通道、消息条数和进程启动结果分开记录,避免用单一通过条件覆盖多类风险。

    验证范围上,本专题已静态核对测试全部函数、hwmon 工具、消息模拟器和 manager 清理链,但未读取实际电源节点、运行硬件或统计噪声分布;探针只复现 Python 控制流,不表示已在具体设备上测得这些失效。

    常见问题

    这个测试能测出单个进程的独立功耗吗?

    不能。valid_power_draw() 比较的是同一 hwmon 节点读数的增量,不是进程独立瓦特计;每阶段把当前均值与前一阶段读数比较,可能混入相机预热、GPU 频率、风扇、后台进程或上个阶段尚未稳定的影响。

    warmup_time 达标就代表测试成功吗?

    不是。get_power_with_warmup_for_target() 到 30 秒退出后仍返回最后的 now/msg_counts,再由测试函数的最终断言决定失败;返回的 warmup_time 不是成功标志。

    为什么 encoderd 阶段的消息门形同虚设?

    因为该阶段 msgs=[],期望数与实收数都为 0,消息门恒通过;这一阶段只靠功率增量和进程启动无异常间接判断,没有核对编码帧、输出文件或码流有效性。

    消息条数达标就能说明各服务都正常吗?

    不能。门控只比较所列服务的合计条数,某一路缺失若被其它路超量抵消仍可能通过;valid_msg_count() 允许约 2% 相对误差或绝对 2 条,不逐主题核对。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/POWER_DRAW_HW_MONITOR_PROCESS_INCREMENT_TEST_DEEP_DIVE.md(取用了 hwmon 读数索引、阶段增量与容差表、失效路径及五项探针结论)。
    • 项目 02 —— openpilot-research/POWER_SAMPLE_WAIT_WINDOW_CONTRACT_DEEP_DIVE.md(仅用于确认底层功率采样的窗口边界,未取用其未公开内容)。

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

    更新记录

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

  • loggerd 测试:合成 VisionIPC、qlog 抽样与隐私门

    结论:loggerd 的测试用合成输入覆盖记录器主链,而不是真实相机链路:它自建名为 camerad 的 VisionIPC server,三路各 40 个 320×240 NV12 缓冲,以 20 Hz 等价循环发送,并随机发布 cereal 服务消息。这决定了它的证据边界——它能校验 rlog/qlog 数量与顺序、段文件集合与书签保留,却不能证明真实相机时序、AV 同步、失帧恢复或编码器 buffer 所有权。另外两处易被高估的断言是:test_rlog 没有在循环后断言发送列表已空,test_record_audio 的 ffprobe 未检查退出码。

    输入夹具:合成 VisionIPC 与随机 cereal 服务

    CEREAL_SERVICES 取 Event schema 中同时位于服务表、允许记录且名称不含 encode 的主题。_publish_random_messages() 启动 loggerd,对每个服务等读者;每轮给所有服务发新 Event,随机发 200~1000 轮,每 100 轮及结束时等读者更新,随后停止进程。源文档提醒,这个等待反映消息队列读者进度,不等同于磁盘同步完成。

    _publish_camera_and_audio_messages() 自建 VisionIPC server,三路各 40 个 320×240 NV12 缓冲;设置 LOGGERD_TEST 与段长,启动 loggerd、encoderd 后以 20 Hz 等价循环编号发送三路缓冲/CameraState、每轮 800 个 int16 零音频样本。它不启动真实 camerad,不测传感器寄存器、曝光、DMA 或时钟同步。源文档也说明该测试文件既有可在 PC 上运行的逻辑,也会调用本机编译产物、FFmpeg、VisionIPC 与日志文件。

    源文档特别指出:视频载荷来自 np.empty,未显式初始化为有意义像素;VisionIpcServer.send() 的时间戳参数是 uint64_t,夹具传入 n/fps 浮点秒值,因此不能把测试输出当真实纳秒级相机时间契约。

    夹具/检查 实际内容
    _get_latest_log_dir() 按目录 mtime 取最新日志段;依赖当前测试 Prefix/LOG_ROOT 下的目录列表。
    _gen_bootlog() 调本地 ./bootlog 二进制并在 5 秒 Timeout 内从输出解析文件路径;要求构建产物。
    _check_init_data() 只检查第一条消息类型为 initData;没有检查所有字段。
    _check_sentinel() 用第二条及末条消息核对 route/segment 起止标记。

    各测试的证据范围

    测试 断言与限制
    test_init_data_values() 写入构建身份等 Params,检查 initData 映射、版本、内核字段;AccessToken 虽在参数列表中但记录值必须为空;只覆盖该组参数,不证明其他敏感字段已净化。
    test_bootlog() 写 /tmp/launch_log,要求 bootlog 含两条消息、wall time 约 5 秒内、launchLog 相等;两次 bootlog 的 UID 不同且 BootCount 从 0 到 1;写入的 /tmp/launch_log 未在测试中删除。
    test_qlog() 从有/无 decimation 的服务各随机挑子集,只核对 qlog 中每主题的数量是否符合公式;不核对选中消息具体载荷或跨段抽样相位。
    test_rlog() 随机 5~10 种服务,逐条从相应服务的发送列表头部弹出并比较序列化字节;没有在循环后断言各发送列表已空。
    test_rotation() 随机 2~3 段、4~5 秒/段,要求每段文件名集合恰为 rlog、qlog、qcamera、三路 HEVC;不读取这六个文件的码流或每段内容。
    书签保留两组用例 对有/无 userBookmark 的段核对 xattr;不验证跨进程 xattr 负缓存、上传/删除器的后续保留决策。
    test_record_front() 真/假两种启动前 Params 值,仅比较最新段 dcamera.hevc 是否存在;不核对动态切换、内容、索引或隐私擦除。
    test_record_audio() 真/假两种启动前 Params 值,检查 qcamera 是否有音轨及 rlog 是否出现原始音频;ffprobe 子进程未检查退出码。

    test_qlog/test_rlog 使用随机服务及消息数量且未固定 seed,所以一次通过不能覆盖所有主题/抽样组合。源文档也澄清:test_rlog 的逐条比较确实检查了已读取消息的载荷,不应把缺少尾部断言误写成完全没有内容校验。

    test_rotation() 只要求每段文件名集合恰为六个文件,不读取码流内容,因此它验证的是段切分与命名,而不是每段编码的正确性。

    隐私与录音门的边界

    test_init_data_values() 只覆盖该组参数,源文档明确不证明其他敏感字段已净化。test_record_front() 只比较最新段 dcamera.hevc 是否存在。test_record_audio() 存在一个易漏点:subprocess.run(ffprobe..., capture_output=True) 未检查退出码,命令失败而 stdout 为空时,关闭录音用例的「无音轨」条件仍可能通过。源文档据此要求在插件化时区分「明确无音轨」和「探针失败」。

    书签保留的两组用例只核对 xattr,源文档指出它们不验证跨进程 xattr 负缓存、上传/删除器的后续保留决策;test_bootlog() 写入的 /tmp/launch_log 也未在测试中删除。这些残留与边界提醒说明,测试覆盖的是「可见输入 + 源码断言」,不是端到端的保留与清理保证。

    插件化验证要求

    将记录功能拆成插件时,应保留独立契约:服务注册/抽样、rlog 顺序和完整性、媒体包/索引对应、音频/座舱录制许可、书签后的保留与上传、异常关机后的文件可读性。对每个测试输入记录种子,断言发送与接收双向数量相等;FFprobe 等外部探针须检查退出码。

    真实相机插件还需带纳秒时间戳的帧级对齐、NV12 形状/stride/有效位、坏帧与断流注入。源文档最后限定:全部结论限定于源码断言与可见输入,未运行构建、FFmpeg、进程或媒体生成,因此无法确认各测试在本归档环境的实际通过情况,本 suite 也不能替代实机时序和隐私审计。

    常见问题

    loggerd 测试用的是真实相机吗?

    不是。源文档明确夹具自建名为 camerad 的 VisionIPC server,三路各 40 个 320×240 NV12 缓冲,不启动真实 camerad,也不测传感器寄存器、曝光、DMA 或时钟同步。

    为什么测试里的相机时间戳不能当真实契约?

    VisionIpcServer.send() 的时间戳参数是 uint64_t,而夹具传入 n/fps 浮点秒值,源文档明确不能把测试输出当真实纳秒级相机时间契约,具体转换效果需用构建后的绑定验证。

    test_rlog 通过是否说明日志完整?

    不足以保证完整。它逐条比较从各服务发送列表头部弹出的序列化字节,但没有在循环后断言发送列表已空,因此若日志只保留每主题的正确前缀,尾部缺失不会由这条断言本身发现。

    test_record_audio 判定「无音轨」可信吗?

    需要警惕。源文档指出 subprocess.run(ffprobe..., capture_output=True) 未检查退出码,命令失败而 stdout 为空时,关闭录音用例的「无音轨」条件仍可能通过,插件化时应区分「明确无音轨」和「探针失败」。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LOGGERD_TEST_SYNTHETIC_VIPC_QLOG_RLOG_PRIVACY_GATES_DEEP_DIVE.md(取用了合成 VisionIPC 与随机 cereal 夹具、各测试证据范围表、隐私与录音门边界、插件化验证要求)。

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

    更新记录

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

  • pandad 原生 CAN 编解码与健康回执:短读、下溢与 512 字节缓冲

    结论:原生 pandad 的 Panda C++ 封装把「CAN 编解码」「健康回执」「命令确认」拆成三层,但三层的校验并不对称——发送路径用 assert 而非显式长度校验,接收路径存在短读下溢,健康结构接受零字节/短读。因此「sendcan 收到」「SPI 交易成功」「MCU 已入队」「安全钩子放行」是四个不同观测点,任何一个都不能替代另一个。

    位置与调用链:从 sendcan 到 6 字节帧头

    Panda 是 pandad.cc 与 PandaSpiHandle 之间的 C++ 设备封装,而 can_header 把总线、DLC、拒绝/回环位、29 位地址和 XOR 校验压成 6 字节。构造函数创建 SPI 句柄、读取板型,再用 0xc0 清通信状态。

    发送路径从 sendcan 取消息并做一秒年龄门,再调用 can_send();接收路径从 can_receive() 得到帧,包装为 can 事件,并把 bool 结果写入事件 valid。send_panda_states() 消费健康结构,按 ignition/onroad 调整安全模式和省电,心跳由后续的 process_panda_state() 发送。封装本身不是独立的 MCU 安全策略,它只是主机侧的一段设备协议。

    函数/组件 作用与边界
    Panda::Panda()、get_hw_type() 构造 SPI 句柄、查板型、复位通信;板型读未核对返回长度。
    get_state()、get_can_state() EP0 读取 health_t/can_health_t,负值才返回空值。
    get_firmware_version()、up_to_date() 两次 64 字节读拼签名,与本地签名文件尾部比较;这不是运行中固件完整性远程证明。
    len_to_dlc()、pack_can_buffer() 过滤非本 Panda 总线,编码 DLC/头/XOR;批次达到 256 字节后调用写回调。
    can_send() 把批次写向 bulk 端点 3,未向调用方返回实际写入量或错误。
    can_receive()、unpack_can_buffer() 从端点 0x81 读最多 16 KiB,保留未完整帧;XOR 失败清接收缓存并请求 MCU 复位通信。

    CAN 字节流边界:512 字节缓冲与 256 字节软阈值

    发送缓冲为 2 * USB_TX_SOFT_LIMIT(512 字节),每个合法 CAN FD 帧至多 6 + 64 字节,写后超过 256 字节就清零位置。因此在长度被限制为 64 的前提下,单次追加不超过缓冲。

    但当前的长度检查只有 assert:代码先把 can_data.size() 传给接受 uint8_t 参数的 len_to_dlc(),再断言 <=64 及长度与 DLC 表相符。在关闭断言的构建中,超长或不能由 CAN FD DLC 表表示的负载不被拒绝,随后的 memcpy 仍按原始 can_data.size() 写进 512 字节栈缓冲。即使保留断言,这也是进程终止而非可恢复的输入错误,必须先做显式长度/DLC 校验再写缓冲。

    另一点常被误读:src 在 cereal CanData 中已经是 UInt8,因此不能把 C++ 局部 uint8_t 赋值本身当作独立的源总线截断漏洞;bus < 4 才是此封装对可发送总线的实际门。

    assert 不是输入校验:短读下溢与半包

    接收循环的条件是 pos <= size - sizeof(can_header),其中 size 为 uint32_t 而 sizeof 为无符号 size_t。当本次读取只得到 1–5 字节且之前没有半包时,减法下溢,循环仍可能进入并从不足 6 字节的数据中拷贝头,之后按头部 DLC/长度判断和移动缓冲,属于条件性越界/错误解析风险。SPI bulk 读允许返回实际收到的字节数,不能假设总是帧头以上。改法是先判断 size - pos >= sizeof(can_header),且只在 pos <= size 不变量成立时做减法。

    头中的 DLC 为 4 位,查 16 项表本身有边界;完整帧的 XOR 校验失败会清缓存,但已在同一轮压入 out_vec 的先前帧不会回滚,上层会发布 valid=false 的事件。接收缓存大小为 RECV_SIZE + sizeof(can_header) + 64,can_receive() 只用 assert 检查剩余容量再读 16 KiB;正常解析最多保留一个未完成帧,但不应依靠断言证明这个不变量。

    健康回执与命令确认的分离

    get_state()、get_can_state() 把结构体零初始化,但只以 err >= 0 接受控制读。SPI 层返回实际字节数,因此零字节或短读也可能被包装成「存在的健康结构」;send_panda_states() 只检查 optional 是否存在,随后据这些字段判断点火、模式及省电。get_serial() 同样接受非负短读;get_firmware_version() 则要求两次恰好 64 字节;风扇 RPM 与板型读没有长度门。这里属于主机读取契约风险,不能推断正常 MCU 必然会产生短响应。

    安全模式、心跳、波特率、IR、电源等控制写方法为 void,未检查 SPI 交易返回值;can_send() 也丢弃 bulk_write 的字节数/错误。这正是必须区分四类观测点的原因:

    观测点 它到底证明了什么
    sendcan 消息收到 主机侧已提交发送请求,仅此而已。
    SPI 交易完成 字节写到了端点;返回值可能被调用方丢弃。
    MCU 入队 需固件侧回执,封装层不提供确认。
    安全钩子放行 由 panda 固件独立许可,主机侧无从得知。

    自制硬件与插件重构的最低契约

    设备插件可以输出带设备身份、采样时间、实际字节数及逐字段有效位的只读健康数据;可替换的 CAN 观测解码器须显式声明帧头、DLC、校验、总线编号和分块恢复契约。发送则由单一受信任网关持有能力:插件只提交候选帧,网关在序列化前校验载荷长度/总线/地址,实施年龄门、资源上限和安全策略,并分别报告「接收请求、SPI 完成、MCU 入队、被安全拒绝」等可观测结果。安全模式、心跳和复位绝不开放给普通插件直接调用。

    离线单测应优先覆盖 0–5 字节短头、分块恰在头中间结束、CAN FD 合法长度与非法 9/65/超大长度、非本 Panda 总线过滤、坏 XOR 前已有好帧、健康结构短读,以及 bulk 部分写/负返回。随后在无车台架以 ASan/UBSan、故障注入和 MCU 回执核验;真实车辆执行控制还需车型安全钩子与接管测试。

    常见问题

    为什么「sendcan 收到」不能当作 MCU 已发送?

    因为 can_send() 丢弃 bulk_write 的字节数与错误,控制写方法为 void;sendcan 只是主机侧入队请求。SPI 交易成功、MCU 入队、安全钩子放行是另外三个独立观测点。

    发送超过 64 字节的 CAN FD 负载会被拒绝吗?

    当前长度检查只有 assert。在关闭断言的构建中不被拒绝,memcpy 仍按原始长度写进 512 字节栈缓冲;即使保留断言,这也是进程终止而非可恢复的输入错误。

    为什么 1–5 字节的 SPI 读取会导致帧头解析错误?

    因为接收循环用无符号的 size 减去 sizeof(can_header),短读时减法下溢,循环仍可能进入并从不足 6 字节的数据中拷贝头。改法是先判断 size - pos >= sizeof(can_header)。

    健康结构读到零字节意味着什么?

    get_state()、get_can_state() 只以 err >= 0 接受控制读,零字节或短读也可能被包装成「存在的健康结构」,send_panda_states() 只检查 optional 是否存在,因此不能把结构存在当作字段有效。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PANDAD_NATIVE_PANDA_CAN_CODEC_HEALTH_CONTRACT_DEEP_DIVE.md(取用了 CAN 字节流边界、短读下溢、健康回执与命令确认分离的静态结论)。
    • 项目 02 —— openpilot-research/PANDAD_NATIVE_SPI_RESPONSE_BOUNDS_RETRY_CONTRACT_DEEP_DIVE.md(仅用于确认底层 SPI 响应长度与重试契约的边界)。

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

    更新记录

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

  • 相机帧号的三重编号:request_id、IFE 帧号与发布帧号为什么不能混用

    结论:相机链路里同时存在三个不同的编号——request_id(req_mgr 队列请求号)、内核 IFE frame_id(每次 SOF 递增的内核计数)与归一化后的published frame_id。只有第三个才是跨相机配对可用的键;直接拿原始 IFE 计数当配对键,在座舱与路面这类错相路之间会整体错位。三路 SOF 的预期关系是:非错相路之间相差 0,座舱与路面路之间相差 25 ms,误差阈值 0.2 ms。此外 timestamp_eof 是「SOF 加读出时间」推算的值,不是硬件 EOF 事件,processing_time 也不能当作曝光时长。以下均为固定快照的静态结构分析,未运行相机、模型或实板测试。

    三个编号:request_id、IFE 帧号与发布帧号

    handle_camera_event()从 req_mgr 的 SOF 事件里一次读出三样东西:request_id、内核 IFE frame_id 和内核 IRQ 时间戳。request_id 是队列请求号,IFE frame_id 是每次 SOF 递增的内核计数,两者都不是最终发布给模型的帧号。validateEvent()在 skip_expected 为假时分别检查两种序号是否各自连续;request_id == 0 会被跳过,连续无效超过环深加二就触发重排。

    真正决定跨相机对齐的是 syncFirstFrame():它为每一路记录首次 SOF 时间,并把 ife_frame_id + 1 存为该路的帧号偏移;等所有已启用相机都到齐且时间关系满足后,才设置共享的同步完成标志。processFrame()此后才计算 published frame_id = ife_frame_id - frame_id_offset。换句话说,进入同步状态的那一次调用仍返回 false,下一帧才可能成为首个发布帧。

    编号 来源 是否可作跨相机配对键
    request_id req_mgr SOF 事件读出的队列请求号 否;request_id == 0 会被跳过,仅供本路连续性检查。
    IFE frame_id 每次 SOF 递增的内核计数 否;三路 IFE 计数可以不同,不能直接当配对键。
    published frame_id ife_frame_id - frame_id_offset 是;偏移在首帧对齐时按路记录。

    还有一条恢复边界:若某路的 IFE 帧号已超过 40 且仍未对齐,代码会记错误并把共享同步标志置为已同步,但没有在该分支证明各路满足了 0.2 ms 关系。此时若某已启用路尚无首帧记录,它后续首次进入已同步分支时用的偏移状态需要另行动态验证。

    时间域:SOF、推算 EOF 与处理时间

    发布出去的几个时间字段语义差别很大。timestamp_sof 来自内核 SOF IRQ,是 modeld 配对窄路与广路图像的依据;timestamp_eof 则是 processFrame() 用 SOF 加 readout_time_ns 推算出来的,读出的只是估计值而非硬件 EOF 事件。

    字段 来源与含义 下游影响
    timestamp_sof handle_camera_event()读取的 SOF IRQ 时间 modeld 按它配对窄/广路图像。
    timestamp_eof processFrame() 用 SOF 加 readout_time_ns 推算 cameraOdometry 携带它,locationd 再减模型姿态延迟并做时间校验。
    processing_time 等待 fence 后,以当前单调时间减估计 EOF 写入相机状态;不能当曝光时长或端到端控制延迟。
    VisionIpcBufExtra sendFrameToVipc() 附带归一化帧号、SOF、EOF encoderd 核对缓冲帧号与通知帧号,失配时跳帧。

    读出时间本身是传感器相关常量:OS04C10 与 OX03C10 各有自己的取值。开发版的 webcamerad._send_yuv() 把 frame_id * 0.05 s 同时当作 SOF 和 EOF,状态消息只带帧号和单位变换——它能替代输入用于开发,却不能验证硬件时钟同源、真实读出时间或三路同步。

    同步建立:25 ms 错相、0.2 ms 阈值与 40 帧兜底

    同步阈值集中在一处配置里,含义需要分清「预期差值」与「允许误差」。非错相路之间预期相差 0;座舱路与路面路之间预期相差 25 ms;可接受的误差是 0.2 ms。这些是当前实现与测试里的常量,不是任意硬件的通用标准。

    量 取值 说明
    非错相路预期差 0 同相两路应同时曝光。
    座舱与路面预期差 25 ms 错相路的标称错位。
    同步误差阈值 0.2 ms 判断某路是否满足时间关系。
    未对齐放弃门 40 帧 超过后置同步标志,但不证明各路满足 0.2 ms。

    重排会记录 last_requeue_ts,并允许预期中的序号空洞出现。收到过旧 SOF、故障注入跳过、序号不连续,或 waitForFrameReady() 等待 IFE/BPS fence 失败时,当前帧都不会发布。这些行为的共同点是:链路并不保证「图像流始终连续」。

    消费者各用各的同步策略

    同一个相机流,三个消费方用的对齐策略并不统一。modeld 先取得比上一广角 SOF 至少晚 25 ms 的主帧,再读取广角帧;两路 SOF 相差超过 10 ms 时记录错误日志,但该分支仍会进入后续推理,没有直接丢弃这对图像。模型 frameId 来自 VisionIPC 主帧,frameIdExtra 来自另一路,而 frameAge 是拿异步收到的 narrowRoadCameraState.frameId 与主帧比较,不能误读成同一缓冲的自身年龄。

    encoderd.sync_encoders() 在启动时选跨路共同起始帧,并在 encoder_thread() 里按帧号决定切段,它不按 SOF 重新校正物理曝光时刻。因此相机帧号重置、断流重连或缺一路时,编码器启动门与模型配对门是否仍符合预期,需要单独验证。相机状态消息则先发布 VisionIPC、再填充发送状态消息,两条传输路径不应仅凭到达顺序关联,而应以帧号和时间戳核对。

    硬件插件接口与验收

    建议相机插件输出同一采集 epoch 下的 stream_id、request_id、原始硬件帧号、归一化帧号、单调时钟域 SOF、EOF 来源(实测或估计)、读出时间、缓冲有效性和同步状态,并把 sync_lost、重排代数与丢帧原因作为可观测事件。平台适配器负责时钟换算、帧号归一化和缓冲所有权;模型与编码插件只消费明确标注时间质量的帧,不可把 webcam 合成时间或日志发布时间暗中转成硬件 SOF。

    现有 test_camerad.py 是硬件门控测试,检查帧号连续、窄/广路 SOF 差小于 1.1 ms、座舱错相 20–30 ms、EOF 晚于 SOF,并有故障注入下的序号跳变检查;这些测试本轮未运行。新硬件还应在台架与实车分别测试启动缺一路、40 帧超时、掉帧与重排、时钟漂移、读出时间误差、IPC 复用以及编码与模型重连。

    常见问题

    IFE 帧号能直接当两路相机的配对键吗?

    不能。IFE frame_id 是三路各自递增的内核计数,彼此可以不同;跨相机配对要用减去首帧偏移后的 published frame_id,而偏移由 syncFirstFrame() 在首帧对齐时逐路记录。

    timestamp_eof 是硬件 EOF 事件吗?

    不是。它是 processFrame() 用 SOF 加 readout_time_ns 推算出来的估计值,读出时间随传感器不同(OS04C10、OX03C10 各有取值);processing_time 也不是曝光时长。

    座舱与路面路的错相预期是多少?

    非错相路之间预期相差 0,座舱路与路面路之间预期相差 25 ms,误差阈值 0.2 ms。这是当前实现与测试的常量,不是任意硬件的通用标准。

    两路 SOF 相差超过 10 ms,模型会丢帧吗?

    源码在该分支记录错误日志,但仍进入后续推理,并没有直接丢弃这对图像。实际的丢帧根因需要区分采集、IPC 与模型消费落后三种情况。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CAMERA_FRAME_TIME_SYNC_CONTRACT_DEEP_DIVE.md(取用了三个编号的定义、首帧对齐偏移、25 ms/0.2 ms/40 帧阈值、SOF 与推算 EOF 字段表、消费者同步策略差异与硬件测试项)。

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

    更新记录

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