# 双ROI导出模型设计说明（Diff 与 Fake3D 版本）

## 1. 文档目标

本文档说明当前新版双 ROI 合并导出模型在部署侧的输入约定、输出张量、后处理流程，以及 `difficulty` 分支与 `fake 3D` 分支的运行时语义。

本文档覆盖的对象是：

- `tools/model_merging/merge_models_of_2roi_yolo26.py`
- `tools/model_inference/core/run_two_roi_exported_onnx_infer.py`
- `tools/model_inference/core/two_roi_infer_utils.py`
- `ultralytics/nn/modules/head.py`

凡与旧文档冲突之处，以当前代码实现为准。

如只查看旧版双 ROI 合并模型，请参考：

- `tools/model_inference/docs/two_roi_model_design.md`

---

## 2. 版本背景

当前仓库中的双 ROI 合并导出模型已经从旧版的：

- `2D boxes + class scores + 3D branch (+ 可选 edge branch)`

演进为新版可选扩展的形态：

- `2D boxes + class scores + 3D branch + difficulty branch`
- `2D boxes + class scores + 3D branch + difficulty branch + fake 3D branch`
- `(+ 可选 edge branch)`

其中：

- `difficulty branch` 用于输出目标 difficulty 二分类 logit
- `fake 3D branch` 用于输出 fake class 专用的 3D 预测

当前合并导出脚本支持以下控制项：

- `--edge-head-mode keep|drop`
- `--fake-3d-branch-mode keep|drop`

因此新版模型存在两条主要导出变体：

1. `drop fake_3d`：导出 `3D + diff`
2. `keep fake_3d`：导出 `3D + diff + fake_3d`

---

## 3. 方案概览

当前运行时仍然沿用双 ROI 单目 3D 检测的总体思路，但部署形态已经收敛为：

1. 原图按 `ROI0` 和 `ROI1` 两套规则分别裁剪
2. 两个 ROI 图像送入同一个合并导出模型
3. 模型输出每个 ROI 的原始检测头张量
4. 2D 框解码、Top-K、3D 反归一化、深度恢复、3D 框重建、fake-class 路由、可视化与 JSON 序列化全部在 Python 侧完成

当前支持：

- ONNX：`onnxruntime`
- TorchScript：`torch.jit.load`
- 单 case 推理
- mined / eval 数据集批量推理

当前部署脚本不依赖训练时的 `ultralytics` dataloader，只依赖导出张量契约和运行时元数据。

---

## 4. 模型与运行时契约

### 4.1 导出模型形态

当前推理脚本只接受双 ROI 合并后的导出模型：

- `.onnx`
- `.torchscript` / `.ts` / `.jit`

脚本会读取导出清单：

- ONNX 优先读取 sidecar：`merged_model.export.json`
- TorchScript 优先读 sidecar，若 sidecar 缺失再读取模型内嵌 `config.txt`

当前部署脚本会显式要求：

```text
export_mode == "raw_head_outputs"
```

也就是说，当前文档只讨论原始检测头输出模式，不讨论 `hybrid_outputs`、`postprocessed_outputs` 等其他导出形态。

### 4.2 输入名称

默认输入名为：

- `roi0_input`
- `roi1_input`

默认输入尺寸为：

- `768 x 352`

若导出清单里提供了 `input_names` 和 `input_sizes_wh`，运行时以清单为准。

### 4.3 导出控制开关

当前导出脚本暴露两个关键开关：

```bash
--edge-head-mode keep|drop
--fake-3d-branch-mode keep|drop
```

它们会直接影响导出后的输出张量数量与名称。

### 4.4 运行时元数据

运行时会从数据配置 YAML 中读取以下元数据：

- `class_map`
- `face_3d_classes`
- `complete_3d_classes`
- `fake_3d_classes`
- `norm_scales_3d`

若未提供额外 YAML，则使用 `tools/model_inference/core/two_roi_infer_utils.py` 中的 `DEFAULT_DATASET_CONFIG`。

当前内置默认值包括：

- `face_3d_classes = [0,1,2,3,4,5,6,7,8,17]`
- `complete_3d_classes = [9,10,11,12,18,19]`
- `fake_3d_classes = [17,18,19]`

对应 fake 类别名称：

- `car_fake`
- `bicyclist_fake`
- `pedestrian_fake`

### 4.5 新旧版本输出类别对比

当前部署侧的 `cls_id` / `cls_name` 由 `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` |

新版默认类别定义为：

| `cls_id` | 新版默认类别 |
|---|---|
| `0-16` | 与旧版相同 |
| `17` | `car_fake` |
| `18` | `bicyclist_fake` |
| `19` | `pedestrian_fake` |

因此默认 `scores_head_raw` 的类别数从旧版的：

- `nc = 17`

扩展为新版的：

- `nc = 20`

对应的 3D 类别分组也从旧版：

- `face_3d_classes = [0,1,2,3,4,5,6,7,8]`
- `complete_3d_classes = [9,10,11,12]`

扩展为新版：

- `face_3d_classes = [0,1,2,3,4,5,6,7,8,17]`
- `complete_3d_classes = [9,10,11,12,18,19]`
- `fake_3d_classes = [17,18,19]`

语义上可以理解为：

- `0-16`：延续旧版类别语义
- `17-19`：新增 fake 类别，2D 分类仍走统一 `scores_head_raw`
- `17` 会按 face 类路径参与 3D 解码，但在保留 fake 分支时优先读取 `preds_3d_fake_head_raw`
- `18-19` 会按 complete 类路径参与 3D 解码，但在保留 fake 分支时同样优先读取 `preds_3d_fake_head_raw`

---

## 5. ROI 与前处理设计

### 5.1 默认 ROI 配置

默认 ROI 配置来自 `DEFAULT_DATASET_CONFIG["roi_configs"]`：

| 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`

### 5.2 标定与灭点

当前运行时兼容扁平 `camera4.json` 与合并格式 `camera4.json`。

灭点计算逻辑：

```text
vp_x = cu + focal_u * tan(yaw)
vp_y = cv - focal_v * tan(pitch)
```

### 5.3 ROI 裁剪

裁剪边界通过 `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
```

### 5.4 resize 与标定更新

当前脚本会把 ROI 图像 resize 到模型输入尺寸，并同步更新 resized ROI 坐标系下的标定：

```text
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
```

其中 `depth_scale` 会在 3D 后处理阶段恢复真实焦距下的深度尺度。

### 5.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`。

---

## 6. 输出张量设计

### 6.1 总体原则

当前部署脚本只支持每个 ROI 输出原始检测头张量。  
输出张量是否包含 `preds_edge`、`preds_3d_fake` 由导出开关控制。

每个 ROI 至少包含：

1. `boxes_head_raw`
2. `scores_head_raw`
3. `preds_3d_head_raw`
4. `preds_diff_head_raw`

### 6.2 `drop fake_3d` 且 `drop edge`

当：

```bash
--fake-3d-branch-mode drop
--edge-head-mode drop
```

合并模型总输出为 8 个：

1. `roi0_boxes_head_raw`
2. `roi0_scores_head_raw`
3. `roi0_preds_3d_head_raw`
4. `roi0_preds_diff_head_raw`
5. `roi1_boxes_head_raw`
6. `roi1_scores_head_raw`
7. `roi1_preds_3d_head_raw`
8. `roi1_preds_diff_head_raw`

### 6.3 `keep fake_3d` 且 `drop edge`

当：

```bash
--fake-3d-branch-mode keep
--edge-head-mode drop
```

合并模型总输出为 10 个：

1. `roi0_boxes_head_raw`
2. `roi0_scores_head_raw`
3. `roi0_preds_3d_head_raw`
4. `roi0_preds_diff_head_raw`
5. `roi0_preds_3d_fake_head_raw`
6. `roi1_boxes_head_raw`
7. `roi1_scores_head_raw`
8. `roi1_preds_3d_head_raw`
9. `roi1_preds_diff_head_raw`
10. `roi1_preds_3d_fake_head_raw`

### 6.4 `keep edge` 的扩展

若再加：

```bash
--edge-head-mode keep
```

则每个 ROI 还会额外多一个：

- `roi*_preds_edge_head_raw`

### 6.5 典型 shape

对默认输入尺寸 `768 x 352`：

```text
A = (352/8 * 768/8) + (352/16 * 768/16) + (352/32 * 768/32)
  = 4224 + 1056 + 264
  = 5544
```

所以典型输出 shape 为：

| 输出名 | 形状 | 含义 |
|---|---|---|
| `roi*_boxes_head_raw` | `[1, 4, 5544]` 或 `[1, 4*reg_max, 5544]` | 2D 框回归头原始输出 |
| `roi*_scores_head_raw` | `[1, nc, 5544]` | 分类原始 logits，旧版默认 `nc=17`，新版默认 `nc=20` |
| `roi*_preds_3d_head_raw` | `[1, 41, 5544]` | 常规 3D 分支 |
| `roi*_preds_diff_head_raw` | `[1, 1, 5544]` | difficulty 二分类分支 |
| `roi*_preds_3d_fake_head_raw` | `[1, 41, 5544]` | fake class 专用 3D 分支 |
| `roi*_preds_edge_head_raw` | `[1, 60, 5544]` | edge 分支 |

注意：

- 实际 `boxes_head_raw` 通道数取决于 `reg_max`
- 旧版本文档里常见的 `[1, 4*reg_max, 5544]` 写法仍然成立
- 你当前部分导出模型如果已在导出前把 `reg_max` 相关结构折叠，也可能看到 `[1, 4, 5544]`

### 6.6 各分支语义

#### 6.6.1 `boxes_head_raw`

2D 框回归头的原始输出，逐 anchor 表示边界框回归量。  
运行时需要结合 anchor 网格与 stride 做 decode，不能直接作为最终 bbox。

#### 6.6.2 `scores_head_raw`

分类头的原始 logits，逐 anchor、逐类输出。  
运行时会先做 sigmoid，再走 one-to-one Top-K 选择。

#### 6.6.3 `preds_3d_head_raw`

常规 3D 分支，适用于常规 3D 类别。  
该分支输出 41 维 3D 表达，后续会按 `norm_scales_3d` 做反归一化。

#### 6.6.4 `preds_diff_head_raw`

difficulty 分支输出，每个 anchor 只有 1 维 logit。  
当前训练逻辑中，difficulty 被视为二分类：

- `0/1 -> easy-like`
- `2/3 -> hard-like`

部署侧目前保留该分支输出，但默认主推理可视化链路还不会直接把它画到结果图上。

#### 6.6.5 `preds_3d_fake_head_raw`

fake class 专用 3D 分支。  
该分支与常规 3D 分支一样，也是 41 维，但只用于 fake 类别：

- `car_fake`
- `bicyclist_fake`
- `pedestrian_fake`

当导出模型包含这个张量且当前 detection 的 `cls_id` 落在 `fake_3d_classes` 中时，运行时会优先用它，而不是 `preds_3d_head_raw`。

#### 6.6.6 `preds_edge_head_raw`

edge 分支输出 60 维。  
每个面的底边缘由 5 个采样点组成，每个采样点 3 维：

```text
[du, dv, z]
```

4 个面一共 60 维。

---

## 7. 3D 分支定义

### 7.1 常规 3D 分支（41 维）

通道布局：

| 通道范围 | 含义 |
|---|---|
| `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 |

### 7.2 fake 3D 分支（41 维）

`preds_3d_fake_head_raw` 与 `preds_3d_head_raw` 在张量结构上完全一致，也是 41 维。  
区别只在于用途：

- `preds_3d_head_raw`：常规类别使用
- `preds_3d_fake_head_raw`：fake class 使用

### 7.3 edge 分支（60 维）

| 面 | 通道范围 |
|---|---|
| front | `0-14` |
| rear | `15-29` |
| left | `30-44` |
| right | `45-59` |

---

## 8. 后处理设计

### 8.1 2D 框解码

运行时用 `decode_boxes_xyxy()` 对 `boxes_head_raw` 解码：

1. 还原 `l, t, r, b`
2. 与 anchor 网格中心组合
3. 乘以 stride 还原到 ROI-resized 图像坐标

anchor 由 `build_anchor_cache()` 预生成，默认 stride：

```text
(8, 16, 32)
```

### 8.2 Top-K 选择

当前推理脚本沿用 one-to-one Top-K 路径：

1. 对分类 logits 做 sigmoid
2. 每个 anchor 取最大类别分数
3. 做 Top-K
4. 生成 `detections = [x1, y1, x2, y2, conf, cls_id]`

### 8.3 3D / Edge 反归一化

`preds_3d_head_raw`、`preds_3d_fake_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
yaw_scale   = 1.5707963
```

### 8.4 深度恢复

部署时需要按真实焦距恢复深度尺度：

```text
preds_3d[:, (0, 6, 12, 18, 24)] *= depth_scale
preds_3d_fake[:, (0, 6, 12, 18, 24)] *= depth_scale
preds_edge[:, 2::3] *= depth_scale
```

其中：

```text
depth_scale = fx / virtual_fx
```

### 8.5 fake class 的 3D 路由

这是新版运行时与旧版的关键差异之一。

当前 exported inference 在逐检测框解码时，会做如下判断：

1. 读取 detection 的 `cls_id`
2. 若 `cls_id in fake_3d_classes`
3. 且当前导出模型包含 `preds_3d_fake`
4. 则用 `preds_3d_fake` 作为该框的 3D 解码输入
5. 否则继续使用 `preds_3d`

也就是说：

- `keep fake_3d` 时：fake 类别走专用 3D 分支
- `drop fake_3d` 时：所有类别都走主 `preds_3d`

### 8.6 difficulty 分支的使用

当前部署侧已经能把 `preds_diff` 读入运行时结果结构：

- `ROISelectedPredictions.preds_diff`

但默认主推理链路暂未把它用于：

- 过滤
- 合并
- 可视化绘制
- JSON 输出主字段

因此当前它的状态是：

- 已导出
- 已可解析
- 已可供后续扩展
- 默认主链路尚未深度消费

### 8.7 edge yaw 与 edge box

若导出模型包含 edge 分支，则运行时可继续执行 edge-based 诊断链：

1. 反投影可见底边缘点
2. 选择单面或双面组合
3. 计算 `edge_yaw`
4. 约束横向距离阈值
5. 重建 `edge_box`
6. 生成额外诊断信息

若使用：

```bash
--edge-head-mode drop
```

则该能力在部署侧被关闭。

---

## 9. 下游解析逻辑

### 9.1 新旧输出协议兼容

当前 `run_two_roi_exported_onnx_infer.py` 对以下情况都兼容：

1. 旧版仅 `boxes + scores + preds_3d`
2. 新版 `boxes + scores + preds_3d + preds_diff`
3. 新版 `boxes + scores + preds_3d + preds_diff + preds_3d_fake`
4. 上述版本再叠加 `preds_edge`

兼容方式为：

- 必需输出只要求旧版最小集合
- 若发现 `preds_3d_fake` / `preds_diff` / `preds_edge` 则自动启用
- 若缺失则退化为旧行为

### 9.2 建议的外部调用方式

外部调用方不要按固定 output index 写死解析逻辑。  
推荐使用：

1. 先读取 `merged_model.export.json`
2. 按 `output_names` 解析输出

原因是：

- `keep/drop fake_3d_branch` 会改变输出总数
- `keep/drop edge_head` 也会改变输出总数

---

## 10. 与旧版文档的主要差异

相对旧版双 ROI 文档，当前新版多出以下变化：

1. 新增 `preds_diff_head_raw`
2. 新增可选 `preds_3d_fake_head_raw`
3. 默认输出类别从 `17` 类扩展到 `20` 类，新增：
   - `car_fake`
   - `bicyclist_fake`
   - `pedestrian_fake`
4. 运行时引入 `fake_3d_classes`
5. fake 类别不再强制走主 `preds_3d`
6. 导出 manifest 中新增：
   - `fake_3d_branch_mode`
   - `keep_fake_3d_branch`
7. 输出总数不再固定为 8

典型情况：

- 旧版 no-edge：8 输出
- 新版 drop fake + drop edge：8 输出
- 新版 keep fake + drop edge：10 输出
- 新版 keep fake + keep edge：12 输出

---

## 11. 导出示例

### 11.1 保留 fake 3D 分支

```bash
python tools/model_merging/merge_models_of_2roi_yolo26.py \
  --roi0-model-path runs/detect/mono3d_roi0_20260506_epoch99.pt \
  --roi1-model-path runs/detect/mono3d_roi1_20260506_epoch99.pt \
  --save-dir runs/export/train_mono3d_two_roi_20260506-keep_fake_3d_branch \
  --imgsz 768 352 \
  --edge-head-mode drop \
  --fake-3d-branch-mode keep
```

输出：

- `boxes`
- `scores`
- `preds_3d`
- `preds_diff`
- `preds_3d_fake`

### 11.2 去掉 fake 3D 分支

```bash
python tools/model_merging/merge_models_of_2roi_yolo26.py \
  --roi0-model-path runs/detect/mono3d_roi0_20260506_epoch99.pt \
  --roi1-model-path runs/detect/mono3d_roi1_20260506_epoch99.pt \
  --save-dir runs/export/train_mono3d_two_roi_20260506-drop_fake_3d_branch \
  --imgsz 768 352 \
  --edge-head-mode drop \
  --fake-3d-branch-mode drop
```

输出：

- `boxes`
- `scores`
- `preds_3d`
- `preds_diff`

---

## 12. 建议与注意事项

1. 如果下游要使用 fake class 的专用 3D 几何，请使用 `--fake-3d-branch-mode keep`。
2. 如果只想保持旧版解析复杂度，可使用 `--fake-3d-branch-mode drop`。
3. 如果外部部署程序不是本仓库当前的 `run_two_roi_exported_onnx_infer.py`，请务必按 manifest 的 `output_names` 解析。
4. `preds_diff` 当前默认未用于主要可视化与合并策略，但导出时建议保留，便于后续直接扩展。
5. `keep fake_3d` 与 `drop fake_3d` 的输出总数不同，不能混用固定 index 的后处理脚本。
