# 双ROI导出模型设计说明(新版) > 若当前使用的是带 `difficulty` 分支或 `fake 3D` 分支的新版本双 ROI 合并模型,请优先参考: > `tools/model_inference/docs/two_roi_model_design_with_diff_and_fake3d.md` ## 1. 文档目标 本文档以 `tools/model_inference/run_two_roi_exported_onnx_infer.py` 和 `tools/model_inference/core/run_two_roi_exported_onnx_infer.py` 的当前实现为准,说明新版双 ROI 导出模型在部署侧的输入约定、前处理、输出张量、后处理、结果序列化方式,以及与旧版说明文档之间的差异。 本文档关注的是“部署可执行设计”而不是训练侧的完整网络细节。凡与脚本实现冲突之处,以当前推理脚本和其依赖的本地工具模块为准。 如需查看旧版文档,可参考 `tools/model_inference/docs/two_roi_model_design_bak.md`。 --- ## 2. 方案概览 新版方案仍然采用双 ROI 单目 3D 检测思路,但部署形态已经收敛为一个“自包含的合并导出模型 + Python 侧解码”的运行时: 1. 同一帧原图按两套 ROI 规则分别裁剪,得到 `ROI0` 和 `ROI1`。 2. 两个 ROI 图像分别送入一个合并后的导出模型,模型输出每个 ROI 的原始检测头张量。 3. 2D 框解码、Top-K 选择、3D 反归一化、深度恢复、3D 框重建、edge yaw 精化、可视化和 JSON 序列化全部在 Python 侧完成。 当前实现支持: - `ONNX` 推理:通过 `onnxruntime` - `TorchScript` 推理:通过 `torch.jit.load` - 单 case 推理:`--case-dir` - mined eval 数据集批量推理:`--eval-dir` 当前运行时不依赖 `ultralytics`。 --- ## 3. 模型与运行时契约 ### 3.1 导出模型形态 当前脚本只接受双 ROI 合并后的导出模型: - 后缀为 `.onnx` 时走 ONNX Runtime - 后缀为 `.torchscript` / `.ts` / `.jit` 时走 TorchScript 脚本会读取导出清单: - ONNX 优先读取同名 sidecar:`merged_model.export.json` - TorchScript 优先读 sidecar,sidecar 不存在时再读模型内嵌的 `config.txt` 当前实现会显式校验: ```text export_mode == "raw_head_outputs" ``` 也就是说,当前部署脚本只支持“原始检测头输出”模式,不接受 `hybrid_outputs`、`postprocessed_outputs` 等其他导出模式。 ### 3.2 输入输出名称 若导出清单可用,则直接使用清单中的: - `input_names` - `output_names` - `input_sizes_wh` 若清单缺失,则回退到默认约定: - 输入名:`roi0_input`、`roi1_input` - 输出名: - `roi0_boxes_head_raw` - `roi0_scores_head_raw` - `roi0_preds_3d_head_raw` - `roi0_preds_edge_head_raw` - `roi1_boxes_head_raw` - `roi1_scores_head_raw` - `roi1_preds_3d_head_raw` - `roi1_preds_edge_head_raw` ### 3.3 默认 ROI 配置 默认 ROI 配置来自 `tools/model_inference/core/two_roi_infer_utils.py` 中的 `DEFAULT_DATASET_CONFIG`: | ROI | 裁剪尺寸 `(w, h)` | 裁剪中心模式 | `virtual_fx` | 默认输入尺寸 `(w, h)` | |---|---:|---|---:|---:| | `ROI0` | `1920 x 880` | `cxvy` | `537.0` | `768 x 352` | | `ROI1` | `768 x 352` | `vxvy` | `537.0` | `768 x 352` | 其中: - `cxvy`:裁剪中心 `x` 取原图水平中心,`y` 取灭点 `vp_y` - `vxvy`:裁剪中心 `x` 取灭点 `vp_x`,`y` 取灭点 `vp_y` 这些默认值可以被 `--data-config`、`--roi0-data`、`--roi1-data` 中的 `roi_configs` 覆盖;若命令行显式传参,也可以进一步覆盖。 ### 3.4 运行时元数据 推理脚本会从数据配置中加载以下元数据: - `class_map` - `face_3d_classes` - `complete_3d_classes` - `norm_scales_3d` 若没有额外 YAML,使用内置默认值。 --- ## 4. 前处理设计 ### 4.1 相机标定读取 当前脚本兼容两种 `camera4.json` 结构: 1. 扁平格式: ```json { "focal_u": ..., "focal_v": ..., "cu": ..., "cv": ..., "pitch": ..., "yaw": ..., "distort_coeffs": [...] } ``` 2. 合并格式: ```json { "intrinsics": { "camera4.json": { ... } }, "extrinsics": { "camera4.json": { "rpy": [...] } } } ``` 脚本会规范化出: - `focal_u`, `focal_v` - `cu`, `cv` - `pitch`, `yaw` - `distort_coeffs` - `angle_unit` ### 4.2 灭点计算 灭点计算逻辑保持不变: ```text vp_x = cu + focal_u * tan(yaw) vp_y = cv - focal_v * tan(pitch) ``` 其中角度会先根据 `angle_unit` 统一到弧度制。 ### 4.3 ROI 裁剪 对每一帧原图: 1. 读取原始宽高 `ori_w`, `ori_h` 2. 根据 ROI 配置确定目标裁剪尺寸 `roi_w`, `roi_h` 3. 根据 `crop_center_mode` 计算裁剪中心 4. 通过 `compute_centered_roi_bounds()` 做边界裁剪 裁剪边界为: ```text crop_x1 = clamp(center_x - roi_w / 2, 0, ori_w - roi_w) crop_y1 = clamp(center_y - roi_h / 2, 0, ori_h - roi_h) crop_x2 = crop_x1 + roi_w crop_y2 = crop_y1 + roi_h ``` ### 4.4 缩放与标定更新 这是新版实现中一个关键变化点。 旧版文档默认把 ROI 裁剪结果直接一次性 resize 到模型输入尺寸;而当前实现使用 `_resize_ground3d_image_in_steps()`,先重复做若干次 `0.5x` 下采样,再做最后一次 resize,以匹配 Ground3D 训练侧的图像缩放行为。 缩放完成后,相机标定会更新到 ROI-resized 坐标系: ```text scale_x = target_w / crop_w scale_y = target_h / crop_h fx = focal_u * scale_x fy = focal_v * scale_y cx = (cu - crop_x1) * scale_x cy = (cv - crop_y1) * scale_y depth_scale = fx / virtual_fx ``` 其中: - `fx, fy, cx, cy` 是 resized ROI 空间下的标定 - `depth_scale` 用于后处理阶段把网络预测深度恢复到真实焦距尺度 ### 4.5 图像张量化 当前脚本的输入张量构造为: ```python image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) array = image_rgb.transpose(2, 0, 1).astype(np.float32) / 256.0 input = array[None, ...] ``` 注意这里是: ```text / 256.0 ``` 而不是旧文档中的 `/ 255.0`。 TorchScript 路径与 ONNX 路径共用同一份前处理逻辑。 --- ## 5. 输出张量设计 ### 5.1 每个 ROI 的输出分支 当前部署脚本假设每个 ROI 仍然输出四个分支: 1. `boxes_head_raw` 2. `scores_head_raw` 3. `preds_3d_head_raw` 4. `preds_edge_head_raw` 即合并模型总共有 8 个输出张量。 ### 5.2 3D 分支定义(41 维) 3D 分支通道布局沿用旧版设计: | 通道范围 | 含义 | |---|---| | `0-5` | front face:`z3d, u_offset, v_offset, h, w, visible_score` | | `6-11` | rear face:`z3d, u_offset, v_offset, h, w, visible_score` | | `12-17` | left face:`z3d, u_offset, v_offset, l, h, visible_score` | | `18-23` | right face:`z3d, u_offset, v_offset, l, h, visible_score` | | `24` | whole-box `z3d` | | `25-26` | whole-box `u_offset, v_offset` | | `27-29` | whole-box `l, h, w` | | `30-33` | yaw 4-bin 分类 logits | | `34-37` | yaw 残差 `sin(delta)` | | `38-40` | cut state logits | ### 5.3 Edge 分支定义(60 维) Edge 分支依然是四个面的底边缘采样点,每个采样点 3 维: ```text [du, dv, z] ``` 每个面 5 个点,共 `5 x 3 = 15` 维,四个面共 60 维: | 面 | 通道范围 | |---|---| | front | `0-14` | | rear | `15-29` | | left | `30-44` | | right | `45-59` | ### 5.4 默认输出形状 对默认输入尺寸 `768 x 352`: ```text A = (352/8 * 768/8) + (352/16 * 768/16) + (352/32 * 768/32) = 4224 + 1056 + 264 = 5544 ``` 因此典型输出为: | 输出名 | 形状 | 含义 | |---|---|---| | `roi*_boxes_head_raw` | `[1, 4 * reg_max, 5544]` | 2D 框原始回归输出 | | `roi*_scores_head_raw` | `[1, nc, 5544]` | 分类原始 logits | | `roi*_preds_3d_head_raw` | `[1, 41, 5544]` | 3D 原始预测 | | `roi*_preds_edge_head_raw` | `[1, 60, 5544]` | edge 原始预测 | 脚本支持 `reg_max > 1` 的通用 DFL 解码;当前导出模型若 `reg_max == 1`,会退化为直接距离回归。 --- ## 6. 后处理设计 ### 6.1 2D 框解码 2D 框解码由 `decode_boxes_xyxy()` 完成: 1. 将 `boxes_head_raw` 还原为 `l, t, r, b` 2. 与预先缓存的 anchor 网格中心做几何组合 3. 再乘以 stride 还原到 ROI-resized 图像像素坐标 anchor 由 `build_anchor_cache()` 预生成,默认 stride 为: ```text (8, 16, 32) ``` ### 6.2 Top-K 选择 当前脚本没有做 NMS,而是严格遵循导出 raw head 的 one-to-one Top-K 路径: 1. 对分类 logits 做 sigmoid 2. 每个 anchor 取最大类别分数 3. 先按 anchor 最大分类分数做一轮 Top-K 4. 再对 gather 后的 `(anchor, class)` 展平分数做第二轮 Top-K 5. 生成 `detections = [x1, y1, x2, y2, conf, cls_id]` ### 6.3 3D / Edge 反归一化 `preds_3d_head_raw` 和 `preds_edge_head_raw` 会用 `norm_scales_3d` 做反归一化: | 项目 | 反归一化方式 | |---|---| | `z3d` | `raw * z3d_scale + z3d_offset` | | `u/v offset` | `sigmoid(raw) * 16 - 8` | | `size` | `raw * size_scale + size_offset` | | yaw residual | `tanh(raw)` | | edge `z` | `raw * z3d_scale + z3d_offset` | | edge `du/dv` | `sigmoid(raw) * 16 - 8` | 默认值来自内置配置: ```text z3d_scale = 24.415 z3d_offset = 39.937 size_scale = 1.945 size_offset = 3.780 ``` ### 6.4 深度恢复 由于训练时使用 `virtual_fx`,部署时必须恢复真实焦距尺度: ```text preds_3d[:, (0, 6, 12, 18, 24)] *= depth_scale preds_edge[:, 2::3] *= depth_scale ``` 这里的 `depth_scale = fx / virtual_fx` 是按每个 ROI、每一帧动态计算的。 ### 6.5 置信度与类别过滤 当前脚本在完成 Top-K 后,再通过 `filter_prediction_rows()` 做最终过滤: - `conf >= roi.spec.conf` - 若给了 `--classes`,则再做类别白名单过滤 - 最终数量再截断到 `max_det` ### 6.6 3D 框解码与重建 #### 6.6.1 面型 3D 类别 对 `face_3d_classes` 中的类别,当前实现使用“可见面驱动”的 3D 解码: 1. 根据 `pred_41` 中的 face visibility 选择可见面 2. 根据最佳可见面读取 `z_face + uv_face_offset` 3. 利用整体尺寸 `dims_whole` 和回归的 `yaw` 重建 3D 框 4. 若 `pred_edge_60` 可用,则同时解码可见底边缘点 5. 若目标处于 cut 状态,还会尝试解码 partial side edge,并把它补充进可见面集合 这部分比旧版文档中的描述更具体,已经显式纳入了: - cut state 感知 - partial edge 补边 - 多可见面 edge 采样 #### 6.6.2 完整体 3D 类别 对 `complete_3d_classes` 中的类别,直接使用 whole-box 分支: - `z_whole` - `uv_whole` - `dims_whole` - `yaw` 重建完整 3D 包围盒。 #### 6.6.3 其他类别 对不属于上述两类集合的类别,脚本通常不会生成可视化 3D 框,但仍会保留 whole-box 分支解码出的: - `center_3d` - `dims` - `yaw_rad` 用于结果序列化。 ### 6.7 Yaw 解码 Yaw 解码仍是 4-bin 分类 + 残差方式: ```text best_bin = argmax(pred[30:34]) yaw = arcsin(clamp(pred[34 + best_bin], -1, 1)) + yaw_bin_offset[best_bin] ``` ### 6.8 Edge Yaw 与 Edge Box 重建 新版实现中,edge 分支不只是“可选 yaw 精化”,而是形成了一条完整的 edge-based 几何诊断链: 1. 从 `pred_edge_60` 解码选中的可见底边缘点 2. 反投影得到 3D edge points 3. 由 `decode_edge_yaw_selection_from_prediction()` 选择最可信的单面或双面组合 4. 计算 `edge_yaw_rad` 5. 判断横向距离是否满足 `max_lateral_dist_m` 6. 基于选中的 edge 几何重建 `edge_box` 7. 若重建可信,则生成 `decoded_edge_heading` 默认横向距离阈值为: ```text edge_yaw_max_lateral_dist_m = 5.0 ``` 当前实现额外产出以下诊断信息: - `edge_yaw_confident` - `edge_yaw_lateral_distance_m` - `edge_yaw_lateral_ok` - `edge_yaw_two_face_eligible` - `edge_yaw_selected_face_types` - `edge_yaw_selected_face_is_partial` - `edge_vs_reg_yaw_rad` - `edge_box_center_3d` - `edge_box_dims` - `edge_box_mode` - `edge_box_length_source` - `edge_box_width_source` - `selected_edge_direct_box_fit_*` - `selected_edge_edgeyaw_box_fit_*` - `selected_edge_fit_gain_px` ### 6.9 可视化 每个 ROI 会输出三张 panel: 1. `2D`:绘制 ROI-resized 坐标系中的检测框 2. `3D`:绘制常规回归 yaw 的 3D 框 3. `3D EdgeRecon (1+ face)`:只绘制 `edge_yaw_confident=True` 的 edge 重建结果 最终按 ROI 拼成一个总览网格图。 --- ## 7. 输出数据设计 ### 7.1 输出目录结构 单 case 推理时,输出目录下会生成: ```text output_dir/ ├── visualizations/ │ ├── *.jpg ├── predictions/ │ ├── *.json └── predictions.json ``` 其中: - `visualizations/*.jpg`:每帧一个可视化网格图 - `predictions/*.json`:下游消费的简化逐帧结果 - `predictions.json`:完整聚合结果 批量 eval 推理时,会在 `output_dir` 下保留与 `eval_dir` 相同的相对目录层级。 ### 7.2 完整聚合结果 `predictions.json` 聚合结果顶层结构为: ```json { "case_name": "...", "images_dir": "...", "calib_file": "...", "exported_model_path": "...", "edge_yaw_max_lateral_dist_m": 5.0, "frames": [ { "frame_index": 0, "frame_name": "000000.png", "visualization": ".../visualizations/000000.jpg", "rois": { "roi0": { "crop_bounds": [x1, y1, x2, y2], "vp_x": ..., "vp_y": ..., "crop_center_x": ..., "crop_center_y": ..., "edge_yaw_max_lateral_dist_m": 5.0, "calib": { "fx": ..., "fy": ..., "cx": ..., "cy": ..., "depth_scale": ... }, "predictions": [ { "bbox_xyxy": [...], "original_bbox_xyxy": [...], "confidence": 0.93, "cls_id": 6, "cls_name": "truck", "center_uv": [...], "center_3d": [...], "dims": [...], "yaw_rad": ..., "edge_yaw_rad": ..., "edge_yaw_confident": true, "cut_cls": 0, "roi_id": 0, "visible_face_type": 1, "visible_face_types": [1, 2] } ] } } } ] } ``` ### 7.3 简化逐帧结果 `predictions/*.json` 脚本还会额外导出更接近下游消费格式的逐帧 JSON,例如: ```json { "0": { "type": "0", "type_name": "truck", "score": "0.9664", "roi_id": "0", "box2d": ["1357.25", "542.76", "1743.78", "803.98"], "xyzlhwyaw": ["3.58", "0.69", "5.21", "4.62", "1.92", "1.48", "-1.57"], "face_cls": "tail", "cut_cls": "0", "edge_yaw_rad": "-1.56", "edge_yaw_confident": true, "edge_vs_reg_yaw_rad": "0.01" } } ``` 这里有一个新版语义变化: - `box2d` 优先使用 `original_bbox_xyxy` - 若原图坐标不可用,才回退到 `bbox_xyxy` 也就是说,简化逐帧 JSON 里的 2D 框优先是“原图坐标系”。 ### 7.4 坐标系约定 当前实现涉及四种常见坐标系: 1. `bbox_xyxy` ROI 裁剪并缩放后的图像坐标系 2. `original_bbox_xyxy` 原始整图坐标系 3. `center_uv` ROI-resized 图像坐标系下的中心点 4. `center_3d` 相机坐标系,单位米 旧版文档中“所有二维坐标均在 ROI 坐标系”这一表述对当前实现已不完全成立,因为现在明确同时输出了 `original_bbox_xyxy`。 --- ## 8. 默认类别配置 当前运行时默认的 `class_map` 为: | `cls_id` | 类别 | |---|---| | `0` | `car` | | `1` | `suv` | | `2` | `pickup` | | `3` | `medium_car` | | `4` | `van` | | `5` | `bus` | | `6` | `truck` / `tanker` / `large_truck` / `construction_vehicle` | | `7` | `special_vehicle` | | `8` | `unknown` | | `9` | `pedestrian` | | `10` | `bicyclist` / `motorcyclist` | | `11` | `bicycle` / `motorcycle` | | `12` | `tricycle` / `tricyclist` | | `13` | `traffic_sign` | | `14` | `wheel` | | `15` | `plate` | | `16` | `face` | 默认 3D 类别分组为: - `face_3d_classes = {0,1,2,3,4,5,6,7,8}` - `complete_3d_classes = {9,10,11,12}` 因此: - `0-8` 使用“可见面驱动”的 3D 解码 - `9-12` 使用 whole-box 3D 解码 - `13-16` 默认不绘制 3D 框,但仍保留部分序列化属性 --- ## 9. 新旧版本差异总结 下表总结了新版实现相对旧版说明文档的主要差异。 | 项目 | 旧版说明 | 新版实现 | |---|---|---| | 导出模式 | 文档列出了 `raw_head_outputs`、`hybrid_outputs`、`postprocessed_outputs`、`denorm_branch_outputs` | 当前脚本只接受 `raw_head_outputs`,并在启动时校验 | | 后端支持 | 主要按 ONNX 方案描述 | 当前同时支持 `ONNX` 和 `TorchScript`,并自动解析 manifest | | 运行时依赖 | 更偏训练/导出视角 | 当前是完全自包含的部署侧实现,不依赖 `ultralytics` | | ROI 缩放 | 单次 resize 描述 | 当前实现先多次 `0.5x` 下采样,再最终 resize,以贴合训练前处理 | | 图像归一化 | `/255.0` | 当前脚本实际使用 `/256.0` | | 输入尺寸来源 | 假定固定输入尺寸 | 当前优先读 manifest 中的 `input_sizes_wh`,也支持命令行覆盖 | | 标定输入格式 | 主要描述平铺内参格式 | 当前同时兼容 flat `camera4.json` 和 combined calibration | | 类别定义 | 类别表较粗,`face_3d_classes` 和 `complete_3d_classes` 范围较小 | 默认类表扩展到 17 个 id,`face_3d_classes=0-8`,`complete_3d_classes=9-12` | | 3D 解码 | 描述了基础 face / whole 两条路径 | 当前新增 cut-aware partial edge、edge 几何筛选、edge box 重建等更完整逻辑 | | edge yaw | 作为可选精化模块描述 | 当前 edge yaw 已融入主结果结构,带 lateral gating、双面可见判断和拟合残差诊断 | | 输出 JSON | 主要描述聚合 JSON,字段较少 | 当前同时输出完整聚合 JSON 和简化逐帧 JSON,并新增大量 edge 诊断字段 | | 2D 坐标语义 | 默认都在 ROI-resized 坐标系 | 当前同时输出 `bbox_xyxy` 和 `original_bbox_xyxy`,两种坐标系并存 | | 可视化 | 只描述 2D/3D 结果 | 当前每个 ROI 输出 `2D`、`3D`、`3D EdgeRecon` 三个 panel | | 运行模式 | 以单 case 为主 | 当前新增 `--eval-dir`,支持整个 mined eval 数据集复用同一个模型批量跑 | ### 9.1 对齐建议 如果后续继续维护部署文档,建议把以下三项视为“新版真值来源”: 1. `tools/model_inference/run_two_roi_exported_onnx_infer.py` 2. `tools/model_inference/core/two_roi_infer_utils.py` 3. `tools/model_inference/core/two_roi_3d_utils.py` 特别是以下内容最容易因实现更新而与文档产生偏差: - 输入归一化常数 - ROI resize 策略 - 类别映射与 3D 类别分组 - edge yaw 的筛选和诊断字段 - 简化 JSON 中 `box2d` 的坐标系定义