# 双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` 的坐标系定义
