Files
yolov26_3d/tools/model_inference/docs/two_roi_model_design.md

655 lines
18 KiB
Markdown
Raw Normal View History

2026-06-24 09:35:46 +08:00
# 双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 优先读 sidecarsidecar 不存在时再读模型内嵌的 `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` 的坐标系定义