单目3D初始代码

This commit is contained in:
zhao.zhu
2026-06-24 09:35:46 +08:00
commit 04a5895b6b
1153 changed files with 340700 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
# 使用示例
本文档提供详细的使用示例,帮助快速上手真值可视化脚本。
## 示例1: 基础使用(原始图像可视化)
最简单的使用方式,不应用任何变换,直接可视化原始图像和标签。
### 数据准备
假设你的数据结构如下:
```
/data/
├── images/
│ └── frame_001.jpg # 原始图像 (1920x1080)
├── labels/
│ └── frame_001.txt # 标签文件
└── calib/
└── L2_calib/
└── camera4.json # 相机标定
```
### 运行命令
```bash
python scripts_for_gt/visualize_single_frame.py \
--image /data/images/frame_001.jpg \
--label /data/labels/frame_001.txt \
--output ./viz_output
```
### 输出结果
```
viz_output/
├── frame_001_2d_gt.jpg # 2D边界框可视化
├── frame_001_3d_gt.jpg # 3D投影可视化
├── frame_001_bev_gt.jpg # BEV俯视图
└── frame_001_combined.jpg # 组合可视化
```
### 说明
- 脚本会自动查找标定文件(`../calib/L2_calib/camera4.json`
- 如果找不到标定文件会跳过3D可视化只生成2D可视化
- 不应用ROI变换使用原始图像尺寸
---
## 示例2: ROI模式与训练一致
当你的模型使用ROI训练时应该使用相同的ROI配置进行可视化以确保标签与训练时一致。
### 配置参数
从你的数据集配置文件(如`data/mono3d.yaml`)中获取:
```yaml
# data/mono3d.yaml
roi: [704, 352] # ROI尺寸 [width, height]
virtual_fx: 500 # 虚拟焦距
ori_img_size: [1920, 1080] # 原始图像尺寸 [width, height]
```
### 运行命令
```bash
python scripts_for_gt/visualize_single_frame.py \
--image /data/images/frame_001.jpg \
--label /data/labels/frame_001.txt \
--output ./viz_output_roi \
--roi 704 352 \
--virtual-fx 500 \
--ori-img-size 1920 1080
```
### 效果对比
| 模式 | 图像尺寸 | 深度归一化 | 用途 |
|------|---------|-----------|------|
| 原始 | 1920x1080 | 否 | 查看原始数据 |
| ROI | 704x352 | 是 | 与训练一致 |
### 说明
- ROI会裁剪图像到指定区域以消失点为中心
- 深度值会根据`virtual_fx`进行归一化
- 标签的2D坐标和UV坐标会相应调整
- 这与训练时的数据处理完全一致
---
## 示例3: 批量处理
当需要可视化多帧图像时,使用批量脚本更高效。
### 数据结构
```
/data/seq_001/
├── images/
│ ├── frame_001.jpg
│ ├── frame_002.jpg
│ └── ...
├── labels/
│ ├── frame_001.txt
│ ├── frame_002.txt
│ └── ...
└── calib/
└── L2_calib/
└── camera4.json
```
### 运行命令
```bash
python scripts_for_gt/visualize_batch.py \
--image-dir /data/seq_001/images \
--label-dir /data/seq_001/labels \
--output ./batch_viz \
--roi 704 352 \
--virtual-fx 500 \
--max-samples 20
```
### 输出结构
```
batch_viz/
├── frame_001/
│ ├── frame_001_2d_gt.jpg
│ ├── frame_001_3d_gt.jpg
│ ├── frame_001_bev_gt.jpg
│ └── frame_001_combined.jpg
├── frame_002/
│ └── ...
└── ...
```
### 说明
- `--max-samples`: 限制处理数量,用于快速测试
- 脚本会自动匹配图像和标签文件(基于文件名)
- 处理失败的样本会被跳过并记录错误
---
## 示例4: 自动测试
最简单的方式,让脚本自动找到并可视化一个样本。
### 运行命令
```bash
python scripts_for_gt/test_visualization.py \
--data data/mono3d.yaml \
--output ./test_output
```
### 脚本行为
1.`data/mono3d.yaml`读取验证集路径
2. 在验证集中查找第一个有标签的样本
3. 自动查找对应的标定文件
4. 生成两组可视化:
- `test_output/no_roi/`: 原始图像可视化
- `test_output/with_roi/`: ROI变换可视化
### 说明
- 用于快速验证脚本功能
- 自动使用数据集配置中的参数
- 适合首次使用或调试
---
## 常见场景
### 场景1: 只想查看2D标签
```bash
python scripts_for_gt/visualize_single_frame.py \
--image image.jpg \
--label label.txt \
--output ./2d_only
# 不提供标定文件自动跳过3D可视化
```
### 场景2: 指定标定文件路径
```bash
python scripts_for_gt/visualize_single_frame.py \
--image image.jpg \
--label label.txt \
--calib /custom/path/camera4.json \
--output ./custom_viz
```
### 场景3: 不同数据集配置
如果你有多个数据集配置如roi0和roi1
```bash
# ROI 0 配置
python scripts_for_gt/visualize_single_frame.py \
--image image.jpg \
--label label.txt \
--output ./viz_roi0 \
--roi 704 352 \
--virtual-fx 500
# ROI 1 配置
python scripts_for_gt/visualize_single_frame.py \
--image image.jpg \
--label label.txt \
--output ./viz_roi1 \
--roi 800 400 \
--virtual-fx 600
```
---
## 输出说明
### 2D可视化 (`*_2d_gt.jpg`)
- 显示2D边界框
- 不同类别使用不同颜色
- 显示类别名称
- 左上角标注"2D GT"
### 3D可视化 (`*_3d_gt.jpg`)
- 显示3D框投影到2D的效果
- 12条边前面红色后面绿色连接边蓝色
- 车辆类标记最佳可见面的中心点
- 左上角标注"3D GT"
### BEV可视化 (`*_bev_gt.jpg`)
- 俯视图视角
- 显示物体在地面的投影
- 绿色表示真值GT
- 箭头指示物体朝向
### 组合可视化 (`*_combined.jpg`)
- 水平排列2D | 3D | BEV
- 一眼看到所有信息
- 适合对比和分析
---
## 参数速查表
| 参数 | 必需 | 默认值 | 说明 |
|------|------|--------|------|
| `--image` | 是 | - | 图像文件路径 |
| `--label` | 是 | - | 标签文件路径 |
| `--output` | 否 | ./gt_visualization | 输出目录 |
| `--calib` | 否 | 自动推断 | 标定文件路径 |
| `--roi` | 否 | None | ROI尺寸 (宽 高) |
| `--virtual-fx` | 否 | None | 虚拟焦距 |
| `--ori-img-size` | 否 | 自动检测 | 原始图像尺寸 (宽 高) |
批量脚本额外参数:
| 参数 | 必需 | 默认值 | 说明 |
|------|------|--------|------|
| `--image-dir` | 是 | - | 图像目录 |
| `--label-dir` | 是 | - | 标签目录 |
| `--max-samples` | 否 | None | 最大处理数量 |
---
## 故障排查
### 问题1: "无法读取图像"
**原因**: 图像路径错误或文件不存在
**解决**:
```bash
# 检查文件是否存在
ls -lh /path/to/image.jpg
# 使用绝对路径
python scripts_for_gt/visualize_single_frame.py \
--image $(pwd)/image.jpg \
--label $(pwd)/label.txt
```
### 问题2: "未找到标定文件"
**原因**: 标定文件路径不符合默认规则
**解决**:
```bash
# 手动指定标定文件
python scripts_for_gt/visualize_single_frame.py \
--image image.jpg \
--label label.txt \
--calib /correct/path/to/camera4.json
```
### 问题3: "没有成功解码的3D框"
**原因**:
- 标签格式不完整只有2D信息
- 深度值无效z <= 0或NaN
- 面可见性标记无效
**解决**:
```bash
# 检查标签内容
cat label.txt
# 如果只有2D标签这是正常的会跳过3D可视化
```
### 问题4: "ROI后标签数量为0"
**原因**: ROI区域没有包含任何目标
**解决**:
```bash
# 先不使用ROI查看原始标签
python scripts_for_gt/visualize_single_frame.py \
--image image.jpg \
--label label.txt \
--output ./check
# 调整ROI参数或选择其他样本
```
---
## 进阶使用
### 自定义类别名称
修改脚本中的`names`字典:
```python
names = {
0: "Car",
1: "Person",
2: "Bike",
3: "Rider",
13: "Tricycle",
}
```
### 调整可视化参数
修改脚本中的`scale_factor`参数:
```python
# 更高分辨率输出
img_2d = plot_2d_boxes(..., scale_factor=3)
# 更小文件大小
img_2d = plot_2d_boxes(..., scale_factor=1)
```
### 导出3D参数
在脚本中添加导出逻辑:
```python
# 在decoded循环中
if decoded and decoded.get('object_3d'):
x, y, z, l, h, w, rot_y, _ = decoded['object_3d']
print(f"Object: x={x:.2f}, y={y:.2f}, z={z:.2f}, dims=[{l:.2f},{h:.2f},{w:.2f}], rot_y={rot_y:.2f}")
```

View File

@@ -0,0 +1,377 @@
# 条件过滤可视化使用指南
## 概述
`visualize_filtered_train.py` 是一个强大的数据分析工具,可以从训练集或验证集中筛选符合特定条件的样本进行可视化。
## 主要功能
1. **类别过滤** - 只查看特定类别的样本
2. **3D空间过滤** - 只查看特定3D空间范围内的目标
3. **批量处理** - 自动查找并可视化符合条件的样本
4. **摘要生成** - 自动生成包含所有样本信息的摘要文件
## 使用场景
### 场景1: 数据质量检查
**需求**: 检查两轮车标注质量特别是近距离目标0-50米
```bash
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class bicycle \
--z-range 0 50 \
--max-samples 30 \
--output ./qa_bicycle_near
```
**输出**:
- 30个包含近距离两轮车的样本
- 每个样本生成4张可视化图2D, 3D, BEV, Combined
- `summary.txt` 文件包含所有样本的详细信息
### 场景2: 类别分布分析
**需求**: 分析训练集中行人的分布情况
```bash
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class pedestrian \
--max-samples 100 \
--output ./analysis_pedestrian
```
这会找到前100个包含行人的样本通过可视化可以
- 查看行人在不同位置、距离的标注质量
- 检查遮挡情况
- 分析姿态多样性
### 场景3: 特定区域分析
**需求**: 分析车辆在车道中心区域左右±3米的标注
```bash
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class vehicle \
--x-range -3 3 \
--z-range 5 100 \
--max-samples 50 \
--output ./analysis_vehicle_center_lane
```
### 场景4: 边界情况分析
**需求**: 查看距离较远的目标50-100米
```bash
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--z-range 50 100 \
--max-samples 40 \
--output ./analysis_far_distance
```
### 场景5: 横向位置分析
**需求**: 分析左侧负X和右侧正X目标的分布
```bash
# 左侧目标
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--x-range -20 -5 \
--max-samples 30 \
--output ./analysis_left_side
# 右侧目标
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--x-range 5 20 \
--max-samples 30 \
--output ./analysis_right_side
```
## 参数详解
### 基本参数
- `--data`: 数据集配置文件路径
- 默认: `data/mono3d.yaml`
- 从配置文件中读取train/val路径
- `--output`: 输出目录
- 默认: `./filtered_train_viz`
- 会在此目录下为每个样本创建子目录
- `--split`: 数据集划分
- 可选: `train``val`
- 默认: `train`
### 过滤参数
#### 类别过滤
- `--target-class`: 目标类别名称
- 可选值:
* `vehicle` - 车辆(小汽车等)
* `pedestrian` - 行人
* `bicycle` - 两轮车
* `rider` - 骑手
* `motorcycle` - 摩托车
* `tricycle` - 三轮车
- 默认: `None`(不过滤类别)
#### 空间范围过滤
基于相机坐标系:
```
Y (下)
|
|
|_____ X (右)
/
Z (前)
```
- `--x-range MIN MAX`: X坐标范围左右方向
- 单位: 米
- 示例: `--x-range -10 10` (左右各10米)
- X < 0: 左侧
- X > 0: 右侧
- `--y-range MIN MAX`: Y坐标范围上下方向
- 单位: 米
- 示例: `--y-range -2 1`
- Y < 0: 相机上方
- Y > 0: 相机下方
- 注意: 通常目标在地面Y值接近0
- `--z-range MIN MAX`: Z坐标范围前后方向/深度)
- 单位: 米
- 示例: `--z-range 0 50` (前方0到50米)
- Z值: 距离相机的深度
#### 其他过滤参数
- `--min-targets`: 最少目标数量
- 默认: 1
- 示例: `--min-targets 2` (至少包含2个符合条件的目标)
- `--max-samples`: 最大可视化样本数
- 默认: 20
- 用于限制输出数量
### 可视化选项
- `--use-roi`: 使用ROI变换
- 如果设置此标志会应用训练时的ROI变换
- 从数据集配置中读取roi、virtual_fx等参数
## 输出结构
```
output_dir/
├── summary.txt # 摘要文件
├── sample_001/
│ ├── sample_001_2d_gt.jpg # 2D可视化
│ ├── sample_001_3d_gt.jpg # 3D可视化
│ ├── sample_001_bev_gt.jpg # BEV可视化
│ └── sample_001_combined.jpg # 组合可视化
├── sample_002/
│ └── ...
└── ...
```
### 摘要文件内容
`summary.txt` 包含:
1. 过滤条件
2. 统计信息(总样本数、成功/失败数)
3. 每个样本的详细信息:
- 文件名
- 符合条件的目标数量
- 每个目标的类别和3D位置
示例:
```
过滤可视化摘要
==================================================
过滤条件:
目标类别: bicycle
X范围: [-10.0, 10.0] 米
Z范围: [0.0, 50.0] 米
最少目标数: 1
数据集: train
可视化结果:
总样本数: 20
成功: 20
失败: 0
样本列表:
--------------------------------------------------
1. frame_001234.jpg
符合条件的目标数: 2
- bicycle: (-3.45, 0.12, 15.67)m
- bicycle: (5.23, -0.08, 22.34)m
2. frame_005678.jpg
符合条件的目标数: 1
- bicycle: (1.23, 0.05, 8.90)m
...
```
## 高级用法
### 组合多个条件
```bash
# 查看近距离0-20米、车道中心左右±5米的行人
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class pedestrian \
--x-range -5 5 \
--z-range 0 20 \
--min-targets 1 \
--max-samples 50 \
--output ./pedestrian_near_center
```
### 从验证集筛选
```bash
# 从验证集而非训练集筛选
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--split val \
--target-class vehicle \
--z-range 30 80 \
--max-samples 30 \
--output ./val_vehicle_medium_distance
```
### 使用ROI变换
```bash
# 使用ROI变换进行可视化与训练时一致
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class bicycle \
--x-range -10 10 \
--z-range 0 50 \
--use-roi \
--max-samples 20 \
--output ./bicycle_with_roi
```
## 工作流程
脚本的工作流程如下:
```
1. 读取数据集配置 (data/mono3d.yaml)
2. 获取train/val图像列表
3. 遍历每个图像
├─ 读取对应的标签文件
├─ 解析标签47维数组
├─ 检查类别过滤条件
├─ 检查3D空间范围条件
└─ 如果符合条件,添加到候选列表
4. 对候选样本进行可视化
├─ 调用 visualize_single_frame()
├─ 生成 2D/3D/BEV/Combined 图像
└─ 保存到对应子目录
5. 生成摘要文件 (summary.txt)
```
## 性能优化
### 快速预览
如果只想快速查看几个样本:
```bash
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class bicycle \
--max-samples 5 # 只查看5个样本
```
### 限制搜索范围
脚本会遍历整个数据集直到找到足够的样本。如果数据集很大,可以:
1. 使用更宽松的过滤条件
2. 减少 `--max-samples` 数量
### 进度显示
脚本会每100个图像显示一次进度
```
进度: 100/5000, 已找到 5 个符合条件的样本
进度: 200/5000, 已找到 12 个符合条件的样本
...
```
## 常见问题
### Q1: 为什么找不到符合条件的样本?
**可能原因**:
1. 过滤条件太严格(空间范围太小)
2. 数据集中该类别样本较少
3. 标签文件缺少3D信息
**解决方法**:
- 放宽空间范围
- 不设置空间范围,只过滤类别
- 检查标签文件格式
### Q2: 如何确定合适的空间范围?
**建议**:
1. 先不设置范围,可视化一些样本
2. 观察目标的典型位置
3. 根据观察结果设置范围
常见参考范围:
- 近距离: `--z-range 0 20`
- 中距离: `--z-range 20 50`
- 远距离: `--z-range 50 100`
- 车道中心: `--x-range -5 5`
- 侧边: `--x-range -20 -5``--x-range 5 20`
### Q3: 可以同时过滤多个类别吗?
目前不支持多类别过滤。如需查看多个类别,请分别运行:
```bash
python scripts_for_gt/visualize_filtered_train.py --target-class bicycle ...
python scripts_for_gt/visualize_filtered_train.py --target-class motorcycle ...
```
### Q4: 摘要文件在哪里?
摘要文件位于输出目录下:`{output_dir}/summary.txt`
## 实用技巧
1. **数据探索**: 先不设置过滤条件,随机可视化一些样本了解数据分布
2. **质量检查**: 使用空间范围过滤,系统地检查不同区域的标注质量
3. **类别平衡**: 分别统计各类别的样本数量,评估数据集平衡性
4. **边界情况**: 重点检查远距离、侧边等边界情况的标注质量
## 与其他脚本的对比
| 脚本 | 用途 | 输入 | 过滤能力 |
|------|------|------|---------|
| visualize_single_frame.py | 单帧可视化 | 单个图像+标签 | 无 |
| visualize_batch.py | 批量可视化 | 图像目录 | 无 |
| **visualize_filtered_train.py** | **条件过滤可视化** | **数据集配置** | **类别+空间** |
| test_visualization.py | 快速测试 | 数据集配置 | 无(自动找第一个) |

View File

@@ -0,0 +1,398 @@
# 真值可视化流程分析文档
## 一、完整数据流程分析
### 1.1 从文件到内存的加载流程
```
原始文件
└─> load_label() [dataloaders3d.py:1625]
├─> 解析标签文本文件
│ └─> 支持3种格式
│ ├─ 2D only (6 dims)
│ ├─ 2D+3D simple (18 dims)
│ └─ 2D+3D+faces (50 dims)
└─> 返回 (N, 47) numpy数组
└─> 列结构:
[0]: class
[1-4]: xywh (normalized)
[5]: score
[6-8]: xyz3d (camera coords)
[9-11]: dimensions (l,h,w)
[12]: rot_y
[13-14]: center_2d_uv (normalized)
[15]: alpha
[16-23]: front_face [xyz, alpha, uv, score, visible]
[24-31]: rear_face
[32-39]: left_face
[40-47]: right_face
```
### 1.2 ROI变换流程
```
原始标签 (N, 47)
└─> post_process_labels_to_roi() [dataloaders3d.py:1060]
├─> 步骤1: 计算ROI区域
│ └─> vanish_y = cy - fy * tan(pitch)
│ └─> crop_center = (oriW/2, vanish_y)
│ └─> roi_bounds = [x1, y1, x2, y2]
├─> 步骤2: 转换2D边界框
│ └─> xywhn -> xyxy (absolute)
│ └─> shift to ROI coords
│ └─> clip to ROI bounds
│ └─> normalize to ROI size
├─> 步骤3: 转换UV坐标
│ └─> 对每个面的中心投影点 (5对UV)
│ └─> u_roi = (u_orig * oriW - roi_x1) / roi_w
│ └─> v_roi = (v_orig * oriH - roi_y1) / roi_h
└─> 步骤4: 过滤无效框
└─> 移除完全在ROI外的框
└─> 标记部分可见的框(cut-in/cut-out)
```
### 1.3 深度归一化流程
```
ROI标签 (N, 47)
└─> scale_z3d() [dataloaders3d.py:1518]
├─> 计算缩放因子
│ └─> scale = virtual_fx / fx_roi
├─> 缩放Z坐标 (仅Z不包括X,Y)
│ ├─> 列7: z3d_center *= scale
│ ├─> 列17: z3d_front *= scale
│ ├─> 列25: z3d_rear *= scale
│ ├─> 列33: z3d_left *= scale
│ └─> 列41: z3d_right *= scale
└─> 原理说明:
投影公式: u = fx * X/Z + cx
同时缩放fx和Z -> 投影不变
X和Y从UV和Z恢复无需缩放
```
## 二、可视化实现流程
### 2.1 2D可视化流程
```
标签数组 (N, 47)
└─> plot_2d_boxes()
├─> 图像预处理
│ └─> resize(scale_factor=2) -> 更清晰
│ └─> BGR -> RGB for Annotator
├─> 遍历每个标签
│ ├─> 提取: cls, xywh
│ ├─> 转换: xywhn -> xyxy (pixel)
│ ├─> 绘制: box + label
│ └─> 颜色: colors(cls)
└─> 后处理
└─> RGB -> BGR
└─> 添加标签文字 (cv2.putText)
```
### 2.2 3D可视化流程车辆类
```
标签 (47,) + calib
└─> decode_and_reconstruct_3d_box_from_target() [plots.py:908]
├─> 步骤1: 恢复原始深度
│ └─> z_original = z_normalized * depth_scale
│ └─> depth_scale = fx_roi / virtual_fx
├─> 步骤2: 选择最佳可见面
│ └─> 遍历4个面 (front, rear, left, right)
│ └─> 选择: visible==1 && score>0.3 && 最高score
├─> 步骤3: 从面重建3D中心
│ └─> _reconstruct_3d_box_from_face()
│ ├─> uv -> 射线方向
│ │ └─> X = (u - cx) * Z / fx
│ │ └─> Y = (v - cy) * Z / fy
│ │
│ ├─> 根据面类型计算偏移
│ │ └─> front: center = face - [l/2, 0, 0]
│ │ └─> rear: center = face + [l/2, 0, 0]
│ │ └─> left: center = face - [0, 0, w/2]
│ │ └─> right: center = face + [0, 0, w/2]
│ │
│ └─> 计算8个角点
│ └─> compute_3d_box_corners_4face()
└─> 返回: {corners_3d, face_center_2d, face_color, ...}
```
### 2.3 3D可视化流程行人类
```
标签 (47,) + calib
└─> decode_and_reconstruct_3d_box_from_target() [plots.py:908]
├─> 步骤1: 恢复原始深度
│ └─> z_original = z_normalized * depth_scale
├─> 步骤2: 提取3D参数
│ └─> center = [x3d, y3d, z3d]
│ └─> dims = [l, h, w]
│ └─> rot_y
├─> 步骤3: 计算8个角点
│ └─> compute_3d_box_corners_4face()
│ └─> 基于center, dims, rot_y
│ └─> 注意: GT不需要corner reordering
└─> 返回: {corners_3d, center_2d, object_3d, ...}
```
### 2.4 投影和绘制流程
```
decoded_results + image + calib
└─> plot_3d_boxes_from_decoded_targets() [plots.py:1343]
├─> 图像预处理
│ └─> tensor -> numpy
│ └─> normalize -> [0,255]
│ └─> resize(scale_factor=2)
├─> 标定参数缩放
│ └─> fx, fy, cx, cy *= scale_factor
├─> 遍历每个decoded对象
│ └─> draw_3d_box_from_corners() [plots.py:1198]
│ │
│ ├─> 步骤1: 3D角点 -> 2D投影
│ │ └─> u = fx * X/Z + cx
│ │ └─> v = fy * Y/Z + cy
│ │ └─> 应用畸变校正 (如果有)
│ │
│ ├─> 步骤2: 绘制12条边
│ │ ├─> 前面4条边: 红色/face_color
│ │ ├─> 后面4条边: 绿色
│ │ └─> 连接边4条: 蓝色
│ │
│ └─> 步骤3: 标记面中心 (车辆)
│ └─> circle at face_center_2d
└─> 后处理
└─> 添加标签文字
└─> BGR -> RGB
```
### 2.5 BEV可视化流程
```
object_3d = [x, y, z, l, h, w, rot_y, face_type]
└─> drawbev() [plots.py:2404]
├─> 坐标系转换
│ └─> 相机坐标 -> BEV坐标
│ └─> bev_x = z (深度 -> 前后)
│ └─> bev_y = -x (右 -> 左右)
├─> 计算4个角点 (俯视)
│ └─> 基于 (bev_x, bev_y), l, w, rot_y
├─> 投影到图像坐标
│ └─> scale: 200m -> 800px
│ └─> origin: bottom-center
└─> 绘制
├─> 填充矩形: 半透明
├─> 边框: 实线
├─> 方向指示: 箭头
└─> 颜色: GT=green, Pred=red
```
## 三、关键坐标变换
### 3.1 归一化坐标 ↔ 像素坐标
```python
# 2D边界框
xywh_norm -> xyxy_pixel:
x1 = (x - w/2) * img_width
y1 = (y - h/2) * img_height
x2 = (x + w/2) * img_width
y2 = (y + h/2) * img_height
# UV坐标
uv_norm -> uv_pixel:
u_pixel = u_norm * img_width
v_pixel = v_norm * img_height
```
### 3.2 相机坐标 ↔ 图像坐标
```python
# 投影3D -> 2D
u = fx * X/Z + cx
v = fy * Y/Z + cy
# 反投影2D + Z -> 3D
X = (u - cx) * Z / fx
Y = (v - cy) * Z / fy
# 注意:
# - 需要已知深度Z
# - 可能需要畸变校正
```
### 3.3 ROI坐标变换
```python
# 原始图像 -> ROI图像
u_roi = (u_orig - roi_x1) / roi_width
v_roi = (v_orig - roi_y1) / roi_height
# ROI图像 -> 原始图像
u_orig = u_roi * roi_width + roi_x1
v_orig = v_roi * roi_height + roi_y1
# 3D坐标
# - xyz3d保持不变相机坐标系
# - 仅z3d需要深度归一化
```
### 3.4 深度归一化变换
```python
# 训练时(原始 -> 归一化)
z_normalized = z_original * (virtual_fx / fx_original)
# 推理/可视化时(归一化 -> 原始)
z_original = z_normalized * (fx_original / virtual_fx)
= z_normalized * depth_scale
# 原理:
# 投影: u = fx * X/Z + cx
# 缩放: u' = (fx*s) * X/(Z*s) + cx = u
# 即同时缩放fx和Z保持投影不变
```
## 四、常见问题与注意事项
### 4.1 深度值处理
**问题**: 为什么只缩放z3d不缩放x3d和y3d
**答案**:
- 投影公式: `u = fx * X/Z + cx`
- X和Y是从UV和Z反推的: `X = (u-cx)*Z/fx`
- 缩放Z后如果缩放fx投影保持不变
- X和Y会自动适配新的Z值无需单独缩放
### 4.2 角点顺序
**问题**: GT和预测的角点顺序一样吗
**答案**:
- GT: 直接使用`compute_3d_box_corners_4face()`输出
- 预测: 需要根据`rot_y`的符号reorder角点
- 在可视化时需要注意这个差异
### 4.3 坐标系统
**相机坐标系** (右手系):
```
Y (down)
|
|_____ X (right)
/
Z (forward, depth)
```
**图像坐标系**:
```
(0,0)_____ u (right)
|
|
v (down)
```
**BEV坐标系** (俯视):
```
bev_x (depth) ↑
|
|
bev_y <———————·
(left)
```
### 4.4 畸变处理
当标定参数包含`distort_coeffs`时:
- ROI模式: 保留畸变系数(需要在可视化时处理)
- 虚拟相机模式: 已经去畸变(`distort_coeffs=[]`
### 4.5 类别差异
不同类别使用不同的重建策略:
- **车辆(0, 13)**: 基于最佳可见面
- **行人(1, 2, 3)**: 基于完整3D框
原因:
- 车辆可能被部分遮挡或出画,面策略更鲁棒
- 行人尺寸小,完整框更简单准确
## 五、调试技巧
### 5.1 检查标签加载
```python
labels = load_label_file(label_path)
print(f"标签数量: {len(labels)}")
print(f"类别分布: {np.unique(labels[:, 0])}")
print(f"第一个标签:\n{labels[0]}")
```
### 5.2 检查ROI变换
```python
print(f"原始标签数量: {len(labels_orig)}")
print(f"ROI后标签数量: {len(labels_roi)}")
# 检查第一个框的变换
if len(labels_roi) > 0:
print(f"原始xywh: {labels_orig[0, 1:5]}")
print(f"ROI xywh: {labels_roi[0, 1:5]}")
```
### 5.3 检查深度值
```python
# 打印深度值范围
z_values = labels[:, 7][~np.isnan(labels[:, 7])]
print(f"深度范围: [{z_values.min():.2f}, {z_values.max():.2f}]")
print(f"平均深度: {z_values.mean():.2f}")
```
### 5.4 检查解码结果
```python
decoded = decode_and_reconstruct_3d_box_from_target(...)
if decoded:
print(f"类别: {decoded['cls']}")
print(f"角点形状: {decoded['corners_3d'].shape if decoded['corners_3d'] is not None else None}")
if decoded.get('object_3d'):
print(f"3D位置: {decoded['object_3d'][:3]}")
```
## 六、性能优化建议
1. **批量处理**: 使用`visualize_batch.py`而不是循环调用单帧脚本
2. **分辨率控制**: 调整`scale_factor`参数控制输出图像大小
3. **选择性可视化**: 根据需要选择2D、3D或BEV可视化
4. **并行处理**: 可以使用多进程批量处理独立的图像

View File

@@ -0,0 +1,273 @@
# 真值可视化工具集
## 📚 文档索引
本目录提供了完整的真值可视化工具和详细文档,帮助理解从数据加载到可视化的完整流程。
### 核心脚本
1. **[visualize_single_frame.py](visualize_single_frame.py)** - 单帧可视化脚本 ⭐
- 读取单帧图像和标签
- 支持2D、3D、BEV可视化
- 支持ROI变换和深度归一化
- 推荐用于详细分析单个样本
2. **[visualize_batch.py](visualize_batch.py)** - 批量可视化脚本
- 批量处理多帧图像
- 自动匹配图像和标签
- 支持限制处理数量
- 推荐用于快速浏览数据集
3. **[visualize_filtered_train.py](visualize_filtered_train.py)** - 条件过滤可视化脚本 🔥
- 从训练集/验证集筛选特定条件的样本
- 支持类别过滤(如:只看两轮车)
- 支持3D空间范围过滤左右10米、纵向50米
- 自动生成摘要文件
- 推荐用于数据分析和质量检查
4. **[test_visualization.py](test_visualization.py)** - 快速测试脚本
- 自动从验证集查找样本
- 测试不同配置的可视化效果
- 推荐用于首次使用或调试
### 文档说明
1. **[README.md](README.md)** - 总体说明 📖
- 真值读取到可视化的完整流程概述
- 标签格式说明47维数组结构
- ROI变换和深度归一化原理
- 不同类别的处理策略
- 使用方法和参数说明
2. **[FLOW_ANALYSIS.md](FLOW_ANALYSIS.md)** - 详细流程分析 🔍
- 数据加载流程(标签、图像、标定)
- ROI变换的详细步骤
- 深度归一化的数学原理
- 2D/3D可视化的实现细节
- 坐标系统和变换关系
- 调试技巧和优化建议
3. **[EXAMPLES.md](EXAMPLES.md)** - 使用示例 💡
- 基础使用示例(原始图像)
- ROI模式示例与训练一致
- 批量处理示例
- 自动测试示例
- 常见场景和故障排查
- 参数速查表
4. **[FILTER_GUIDE.md](FILTER_GUIDE.md)** - 条件过滤指南 🎯
- 条件过滤可视化的详细使用方法
- 类别过滤和空间范围过滤
- 多种使用场景(数据质量检查、类别分布分析等)
- 参数详解和坐标系说明
- 高级用法和性能优化
- 常见问题和实用技巧
## 🚀 快速开始
### 方式1: 最简单(自动测试)
```bash
# 自动查找样本并可视化
python scripts_for_gt/test_visualization.py --data data/mono3d.yaml --output ./test_viz
```
### 方式2: 单帧可视化(原始图像)
```bash
# 基础使用不使用ROI直接可视化原始图像
python scripts_for_gt/visualize_single_frame.py \
--image /path/to/image.jpg \
--label /path/to/label.txt \
--output ./viz_output
```
### 方式3: 单帧可视化ROI模式与训练一致
```bash
# 使用ROI变换和深度归一化与训练时一致
python scripts_for_gt/visualize_single_frame.py \
--image /path/to/image.jpg \
--label /path/to/label.txt \
--output ./viz_output_roi \
--roi 704 352 \
--virtual-fx 500 \
--ori-img-size 1920 1080
```
### 方式4: 批量处理
```bash
# 批量可视化多帧
python scripts_for_gt/visualize_batch.py \
--image-dir /path/to/images \
--label-dir /path/to/labels \
--output ./batch_viz \
--roi 704 352 \
--virtual-fx 500 \
--max-samples 20
```
## 📊 输出结果
每个样本会生成4个可视化文件
| 文件 | 内容 | 用途 |
|------|------|------|
| `*_2d_gt.jpg` | 2D边界框 | 查看目标检测标注 |
| `*_3d_gt.jpg` | 3D框投影 | 查看3D标注质量 |
| `*_bev_gt.jpg` | 鸟瞰图 | 查看空间位置关系 |
| `*_combined.jpg` | 组合视图 | 一眼看到所有信息 |
## 🔑 关键概念
### 标签格式47维数组
```
[0]: class # 类别ID
[1-4]: x, y, w, h # 2D边界框归一化
[5]: score # 置信度
[6-8]: x3d, y3d, z3d # 3D中心相机坐标系
[9-11]: length, height, width # 3D尺寸
[12]: rot_y # 偏航角
[13-14]: center_u, center_v # 3D中心投影归一化
[15]: alpha # 观测角
[16-23]: front_face # 前面信息
[24-31]: rear_face # 后面信息
[32-39]: left_face # 左面信息
[40-47]: right_face # 右面信息
```
### ROI变换
1. 计算ROI区域以消失点为中心
2. 裁剪图像到ROI区域
3. 调整2D边界框和UV坐标到ROI空间
4. 根据`virtual_fx`缩放深度值
### 深度归一化
```python
# 训练时
z_normalized = z_original * (virtual_fx / fx_original)
# 可视化时(恢复)
z_original = z_normalized * depth_scale
# 其中 depth_scale = fx_original / virtual_fx
```
**为什么只缩放Z**
- 投影公式: `u = fx * X/Z + cx`
- 同时缩放`fx``Z`可以保持投影不变
- `X``Y``UV``Z`恢复,无需单独缩放
### 不同类别的处理策略
| 类别 | 策略 | 原因 |
|------|------|------|
| 车辆(0, 13) | 基于最佳可见面 | 可能被部分遮挡 |
| 行人(1, 2, 3) | 基于完整3D框 | 尺寸小,完整框更准确 |
## 🔧 常见问题
### Q1: 为什么需要ROI变换
**A**: ROI变换有两个目的
1. **聚焦感兴趣区域**: 去除天空等无关区域
2. **统一焦距**: 通过`virtual_fx`统一不同相机的焦距,提高泛化能力
### Q2: 什么时候使用ROI模式
**A**:
- **查看原始数据**: 不使用ROI了解数据本身
- **与训练一致**: 使用ROI验证训练数据处理
- **调试模型**: 使用ROI与模型输入一致
### Q3: 标定文件必需吗?
**A**:
- **2D可视化**: 不需要
- **3D可视化**: 需要用于3D到2D的投影
### Q4: 如何验证可视化是否正确?
**A**: 检查以下几点:
1. 2D框是否对齐目标
2. 3D框投影是否合理不应该明显错位
3. BEV中的位置是否符合直觉距离、朝向
4. 不同面的颜色是否正确(前红后绿)
## 📖 推荐阅读顺序
1. **新手**: README.md → EXAMPLES.md → 运行test_visualization.py
2. **数据分析**: FILTER_GUIDE.md → 运行visualize_filtered_train.py
3. **开发**: FLOW_ANALYSIS.md → 阅读脚本代码 → 自定义功能
4. **调试**: EXAMPLES.md故障排查 → FLOW_ANALYSIS.md调试技巧
## 🎯 常用命令速查
```bash
# 快速测试
python scripts_for_gt/test_visualization.py
# 原始图像可视化
python scripts_for_gt/visualize_single_frame.py --image img.jpg --label label.txt --output out
# ROI模式与训练一致
python scripts_for_gt/visualize_single_frame.py --image img.jpg --label label.txt \
--roi 704 352 --virtual-fx 500 --output out
# 批量处理前10个样本
python scripts_for_gt/visualize_batch.py --image-dir imgs/ --label-dir labels/ \
--output batch_out --max-samples 10
# 批量+ROI
python scripts_for_gt/visualize_batch.py --image-dir imgs/ --label-dir labels/ \
--roi 704 352 --virtual-fx 500 --output batch_out
# 条件过滤可视化包含两轮车的样本左右10米、纵向50米范围
python scripts_for_gt/visualize_filtered_train.py --data data/mono3d.yaml \
--target-class bicycle --x-range -10 10 --z-range 0 50 --max-samples 20
# 条件过滤:可视化包含行人的样本
python scripts_for_gt/visualize_filtered_train.py --data data/mono3d.yaml \
--target-class pedestrian --max-samples 50
# 条件过滤:从验证集筛选
python scripts_for_gt/visualize_filtered_train.py --data data/mono3d.yaml \
--split val --target-class vehicle --z-range 10 30 --max-samples 20
```
## 📞 需要帮助?
1. 查看 [EXAMPLES.md](EXAMPLES.md) 的故障排查章节
2. 查看 [FLOW_ANALYSIS.md](FLOW_ANALYSIS.md) 的调试技巧章节
3. 检查标签文件格式是否正确
4. 检查标定文件是否存在且格式正确
## 🔄 与模型代码的对应关系
| 功能 | 脚本位置 | 模型代码位置 |
|------|---------|------------|
| 标签加载 | `load_label_file()` | `utils/dataloaders3d.py:load_label()` |
| ROI变换 | `apply_roi_transform()` | `utils/dataloaders3d.py:post_process_labels_to_roi()` |
| 深度归一化 | `scale_z3d()` | `utils/dataloaders3d.py:_scale_z3d()` |
| 3D解码GT | - | `utils/plots.py:decode_and_reconstruct_3d_box_from_target()` |
| 3D解码预测 | - | `utils/plots.py:decode_and_reconstruct_3d_box()` |
| 2D可视化 | `plot_2d_boxes()` | `test_val_visualize.py:plot_2d_boxes_to_image()` |
| 3D可视化 | - | `utils/plots.py:plot_3d_boxes_from_decoded_targets()` |
## 📝 版本历史
- **v1.0** (2026-02-07): 初始版本
- 单帧可视化脚本
- 批量可视化脚本
- 自动测试脚本
- 完整文档README, FLOW_ANALYSIS, EXAMPLES
## 🎓 相关资源
- 主项目文档: [CLAUDE.md](../CLAUDE.md)
- 数据集配置: [data/mono3d.yaml](../data/mono3d.yaml)
- 3D可视化指南: [3D_VISUALIZATION_GUIDE.md](../3D_VISUALIZATION_GUIDE.md)
- 验证脚本: [test_val_visualize.py](../test_val_visualize.py)

View File

@@ -0,0 +1,378 @@
# 真值可视化脚本说明
## 概述
本目录包含用于单帧图像真值可视化的脚本可以实现2D和3D边界框的可视化。
## 真值读取到可视化的完整流程
### 1. 数据加载流程utils/dataloaders3d.py
#### 1.1 标签读取 (`load_label()`)
```python
# 标签文件格式47维数组
# - 列0: class (类别ID)
# - 列1-4: x, y, w, h (归一化的2D边界框中心和宽高)
# - 列5: score (置信度)
# - 列6-8: x3d, y3d, z3d (3D中心点在相机坐标系下的坐标)
# - 列9-11: length, height, width (3D框的尺寸)
# - 列12: rot_y (偏航角旋转)
# - 列13-14: xc, yc (3D框中心投影到2D的归一化坐标)
# - 列15: alpha (观测角)
# - 列16-23: 前面信息 [x3d, y3d, z3d, alpha, xc, yc, score, visible]
# - 列24-31: 后面信息
# - 列32-39: 左面信息
# - 列40-47: 右面信息
```
支持三种标签格式:
- **2D only (6列)**: 仅包含2D边界框
- **2D+3D simple (18列)**: 针对行人、自行车、骑手包含基本3D信息
- **2D+3D+faces (50列)**: 针对车辆、三轮车包含完整的4个面信息
#### 1.2 图像读取 (`load_image()`)
```python
# 使用cv2.imread读取BGR格式图像
img = cv2.imread(image_path) # (H, W, 3) BGR
```
#### 1.3 标定参数读取
```python
# 从 camera4.json 读取相机内参
{
"focal_u": fx, # x方向焦距
"focal_v": fy, # y方向焦距
"cu": cx, # x方向主点
"cv": cy, # y方向主点
"pitch": pitch, # 相机俯仰角
"distort_coeffs": [] # 畸变系数
}
```
### 2. 数据预处理流程
#### 2.1 ROI变换可选
```python
# 计算消失点和裁剪中心
vanish_y = cy - fy * tan(pitch * π/180)
crop_center = (oriW/2, vanish_y)
# 计算ROI边界
roi_x1 = crop_center_x - roi_width/2
roi_y1 = crop_center_y - roi_height/2
roi_x2 = roi_x1 + roi_width
roi_y2 = roi_y1 + roi_height
# 裁剪图像
img_roi = img[roi_y1:roi_y2, roi_x1:roi_x2]
```
#### 2.2 标签坐标转换
```python
# 将2D边界框从原始图像坐标转换到ROI坐标
# 1. 归一化xywh -> 绝对xyxy
# 2. 平移到ROI相对坐标
# 3. 裁剪到ROI边界内
# 4. 重新归一化
# UV坐标面中心投影也需要相应转换
```
#### 2.3 深度归一化(可选)
```python
# 当使用虚拟焦距时需要缩放z3d深度值
# 只缩放Z坐标X和Y从UV和Z恢复
scale = virtual_fx / fx_original
z3d_normalized = z3d_original * scale
# 包括:
# - 列7: 整体中心z3d
# - 列17: 前面中心z3d
# - 列25: 后面中心z3d
# - 列33: 左面中心z3d
# - 列41: 右面中心z3d
```
### 3. 可视化流程test_val_visualize.py
#### 3.1 2D可视化
```python
# 使用 plot_2d_boxes_to_image() 或类似逻辑
# 1. 放大图像scale_factor=2获得更清晰的可视化
# 2. 将归一化坐标转换为像素坐标
# 3. 使用Annotator绘制边界框和类别标签
# 4. 添加标签文字(如"2D GT"
```
#### 3.2 3D可视化
**步骤1解码3D框** (`decode_and_reconstruct_3d_box_from_target()`)
```python
# 针对不同类别采用不同策略:
# 车辆类class 0, 13基于最佳可见面重建
# 1. 遍历4个面前、后、左、右
# 2. 选择可见且得分最高的面
# 3. 从面中心(u,v,z)反投影得到3D中心
# 4. 使用dimensions和rot_y计算8个3D角点
# 行人类class 1, 2, 3基于完整3D框
# 1. 直接使用(x3d, y3d, z3d)作为3D中心
# 2. 使用dimensions和rot_y计算8个3D角点
# 3. 注意:需要将归一化深度转换回原始尺度
# z3d_original = z3d_normalized * depth_scale
```
**步骤2投影和绘制** (`plot_3d_boxes_from_decoded_targets()`)
```python
# 1. 将3D角点投影到2D图像平面
# u = fx * X/Z + cx
# v = fy * Y/Z + cy
# 2. 绘制3D框的12条边
# 3. 使用不同颜色区分前后面
# 4. 对于车辆类,标记最佳可见面的中心点
```
#### 3.3 BEV可视化
```python
# 1. 创建空白BEV图像 (draw_bev_blank)
# 2. 对每个3D框调用 drawbev() 绘制俯视图
# 3. 使用不同颜色区分真值(绿色)和预测(红色)
```
### 4. 关键坐标系统
#### 4.1 图像坐标系
- 原点:图像左上角
- x轴向右
- y轴向下
- 单位:像素或归一化[0,1]
#### 4.2 相机坐标系
- 原点:相机光心
- x轴向右
- y轴向下
- z轴向前深度方向
- 单位:米
#### 4.3 投影关系
```
u = fx * X/Z + cx
v = fy * Y/Z + cy
# 反投影已知u,v,Z求X,Y
X = (u - cx) * Z / fx
Y = (v - cy) * Z / fy
```
## 使用方法
### 基本用法不应用ROI
```bash
python scripts_for_gt/visualize_single_frame.py \
--image /path/to/images/frame_001.jpg \
--label /path/to/labels/frame_001.txt \
--output ./gt_visualization
```
### 带ROI变换与训练一致
```bash
python scripts_for_gt/visualize_single_frame.py \
--image /path/to/images/frame_001.jpg \
--label /path/to/labels/frame_001.txt \
--output ./gt_visualization \
--roi 704 352 \
--virtual-fx 500 \
--ori-img-size 1920 1080
```
### 参数说明
- `--image`: 图像文件路径(必需)
- `--label`: 标签文件路径(必需)
- `--output`: 输出目录(默认:./gt_visualization
- `--calib`: 相机标定文件路径camera4.json如不提供会自动从图像路径推断
- `--roi`: ROI尺寸宽 高),如:`--roi 704 352`
- `--virtual-fx`: 虚拟焦距,用于深度归一化
- `--ori-img-size`: 原始图像尺寸(宽 高),如不提供则从图像自动获取
### 输出文件
脚本会在输出目录生成以下文件:
- `{filename}_2d_gt.jpg`: 2D边界框可视化
- `{filename}_3d_gt.jpg`: 3D边界框投影可视化
- `{filename}_bev_gt.jpg`: 鸟瞰图BEV可视化
- `{filename}_combined.jpg`: 组合可视化2D + 3D + BEV
## 示例
假设数据结构如下:
```
/data/
├── images/
│ └── frame_001.jpg
├── labels/
│ └── frame_001.txt
└── calib/
└── L2_calib/
└── camera4.json
```
运行命令:
```bash
python scripts_for_gt/visualize_single_frame.py \
--image /data/images/frame_001.jpg \
--label /data/labels/frame_001.txt \
--output ./output \
--roi 704 352 \
--virtual-fx 500
```
## 注意事项
1. **标定文件自动推断**:如果不指定`--calib`,脚本会自动查找与图像路径对应的标定文件(`../calib/L2_calib/camera4.json`
2. **深度归一化**:当使用`--virtual-fx`z3d坐标会被缩放以匹配虚拟焦距这与训练时的处理一致
3. **坐标系统**
- 输入标签使用归一化坐标[0,1]
- 3D坐标使用相机坐标系
- 深度归一化后需要在可视化时转换回原始尺度
4. **支持的类别**
- 0: vehicle车辆- 使用面策略
- 1: pedestrian行人- 使用完整框
- 2: bicycle自行车- 使用完整框
- 3: rider骑手- 使用完整框
- 13: tricycle三轮车- 使用面策略
## 快速测试
提供了一个自动测试脚本,可以自动从验证集中找到第一个有效样本并进行可视化:
```bash
# 使用默认数据集配置
python scripts_for_gt/test_visualization.py
# 指定数据集配置
python scripts_for_gt/test_visualization.py --data data/mono3d.yaml --output ./test_output
```
测试脚本会:
1. 从数据集配置中读取验证集路径
2. 自动查找第一个有标签的样本
3. 分别测试原始图像可视化和ROI变换可视化
4. 生成所有可视化结果到指定目录
## 批量处理
如果需要批量处理多帧图像,可以使用批量脚本:
```bash
python scripts_for_gt/visualize_batch.py \
--image-dir /path/to/images \
--label-dir /path/to/labels \
--output ./batch_output \
--roi 704 352 \
--virtual-fx 500 \
--max-samples 10
```
参数说明:
- `--image-dir`: 图像目录
- `--label-dir`: 标签目录
- `--output`: 输出目录
- `--calib-dir`: 标定文件目录(可选)
- `--roi`: ROI尺寸
- `--virtual-fx`: 虚拟焦距
- `--max-samples`: 最大处理样本数(可选)
## 条件过滤可视化
如果需要从训练集或验证集中筛选特定条件的样本进行可视化,可以使用过滤脚本:
### 示例1: 可视化包含两轮车的样本(空间范围过滤)
```bash
# 只可视化两轮车在左右各10米、纵向50米范围内的样本
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class bicycle \
--x-range -10 10 \
--z-range 0 50 \
--max-samples 20 \
--output ./bicycle_samples
```
### 示例2: 可视化包含行人的样本
```bash
# 可视化前50个包含行人的训练样本
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class pedestrian \
--max-samples 50 \
--output ./pedestrian_samples
```
### 示例3: 可视化特定空间范围内的车辆
```bash
# 可视化车辆在左右5米、纵向10-30米范围内的样本
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class vehicle \
--x-range -5 5 \
--z-range 10 30 \
--max-samples 30 \
--output ./vehicle_samples
```
### 过滤参数说明
- `--target-class`: 目标类别vehicle, pedestrian, bicycle, rider, motorcycle, tricycle
- `--x-range MIN MAX`: X坐标范围左右方向单位
- `--y-range MIN MAX`: Y坐标范围上下方向单位
- `--z-range MIN MAX`: Z坐标范围前后方向/深度),单位:米
- `--min-targets`: 最少目标数量默认1
- `--max-samples`: 最大可视化样本数
- `--split`: 数据集划分train或val默认train
- `--use-roi`: 使用ROI变换
### 坐标系说明
相机坐标系:
- **X轴**: 向右为正(单位:米)
- **Y轴**: 向下为正(单位:米)
- **Z轴**: 向前为正(深度方向,单位:米)
示例:
- `--x-range -10 10`: 左右各10米范围
- `--z-range 0 50`: 前方0到50米范围
- `--y-range -2 1`: 相机水平面上下2米到下方1米范围
## 脚本说明
本目录包含以下脚本:
1. **visualize_single_frame.py** - 单帧可视化脚本
- 核心功能读取单帧图像和标签生成2D、3D和BEV可视化
- 支持ROI变换和深度归一化
2. **visualize_batch.py** - 批量可视化脚本
- 批量处理多帧图像
- 自动匹配图像和标签文件
3. **test_visualization.py** - 快速测试脚本
- 自动查找验证集样本
- 测试不同配置的可视化效果
## 扩展功能
可以基于此脚本进行扩展:
- 添加不同的可视化风格
- 输出3D框参数到文件
- 与预测结果对比
- 统计分析(深度分布、尺寸分布等)

View File

@@ -0,0 +1,103 @@
# 保存类型选择功能说明
## 功能概述
新增`--save-types`参数,可以选择性地保存可视化结果,节省磁盘空间和处理时间。
## 使用方法
### 1. 只保存组合图(默认)
```bash
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class bicycle \
--x-range -10 10 \
--z-range 0 50 \
--max-samples 30
# 默认只保存 *_combined.jpg
```
### 2. 保存多种类型
```bash
# 保存2D和组合图
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class bicycle \
--save-types 2d combined
# 保存3D、BEV和组合图
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class pedestrian \
--save-types 3d bev combined
# 保存所有类型
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class vehicle \
--save-types 2d 3d bev combined
```
## 可选类型说明
| 类型 | 文件名模式 | 说明 |
|------|-----------|------|
| `2d` | `*_2d_gt.jpg` | 仅2D边界框可视化 |
| `3d` | `*_3d_gt.jpg` | 3D框投影到图像 |
| `bev` | `*_bev_gt.jpg` | 鸟瞰图视角 |
| `combined` | `*_combined.jpg` | 2D+3D+BEV组合图 |
## 性能优势
| 场景 | 磁盘空间节省 | 处理速度提升 |
|------|-------------|-------------|
| 只保存combined | ~75% | ~20% |
| 只保存2d | ~75% | ~60% |
| 保存2d+combined | ~50% | ~40% |
## 适用场景
### 只保存combined
- **适用场景**:快速浏览数据分布
- **优点**:节省最多空间,一图看全
- **推荐用于**:数据质量检查、分布分析
### 保存所有类型
- **适用场景**:详细分析、问题排查
- **优点**:每个角度都有单独的图
- **推荐用于**:深度调试、论文图片准备
### 自定义组合
- **适用场景**:特定需求
- **示例**只关心2D框位置 → `--save-types 2d`
- **示例**只关心3D空间位置 → `--save-types 3d bev`
## 与其他脚本的兼容性
此功能也适用于其他可视化脚本:
```bash
# visualize_batch.py
python scripts_for_gt/visualize_batch.py \
--image-dir /path/to/images \
--label-dir /path/to/labels \
--save-types combined
# visualize_single_frame.py需要手动修改代码传递参数
# 在代码中调用时:
visualize_single_frame(
image_path=...,
label_path=...,
output_dir=...,
save_types=['combined'] # 只保存组合图
)
```
## 注意事项
1. **默认行为改变**`visualize_filtered_train.py`默认只保存combined与之前的"保存所有类型"不同
2. **性能提升**:只生成需要的可视化,不会生成后再删除
3. **依赖关系**combined类型需要2d、3d、bev都生成但不单独保存它们
4. **磁盘空间**如果样本很多建议只保存combined

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,849 @@
"""3D visualization plotting utilities.
Self-contained module extracted from yolov5-3d/utils/plots.py.
Contains only the functions required by visualize_single_frame.py and
visualize_batch.py for ground-truth 3D visualization, with no cross-repo
dependencies.
"""
import cv2
import numpy as np
import torch
from ultralytics.utils.plotting import Annotator # noqa: F401 (re-exported)
# ---------------------------------------------------------------------------
# Color helpers
# ---------------------------------------------------------------------------
class Colors:
"""Provides an RGB color palette derived from Ultralytics color scheme."""
def __init__(self):
hexs = (
"FF3838", "FF9D97", "FF701F", "FFB21D", "CFD231",
"48F90A", "92CC17", "3DDB86", "1A9334", "00D4BB",
"2C99A8", "00C2FF", "344593", "6473FF", "0018EC",
"8438FF", "520085", "CB38FF", "FF95C8", "FF37C7",
)
self.palette = [self.hex2rgb(f"#{c}") for c in hexs]
self.n = len(self.palette)
def __call__(self, i, bgr=False):
"""Return colour for index ``i`` (BGR if ``bgr=True``, else RGB)."""
c = self.palette[int(i) % self.n]
return (c[2], c[1], c[0]) if bgr else c
@staticmethod
def hex2rgb(h):
"""Convert hex colour string to (R, G, B) tuple."""
return tuple(int(h[1 + i: 1 + i + 2], 16) for i in (0, 2, 4))
colors = Colors() # module-level singleton; callers: from plots_3d import colors
# ---------------------------------------------------------------------------
# 3D geometry helpers
# ---------------------------------------------------------------------------
def rotation_3d_in_axis(points, angles, axis=0):
"""Rotate *points* around a specified camera-frame axis.
Args:
points (np.ndarray): (N, 3) array of 3D points.
angles (float): Rotation angle in radians.
axis (int): 0=X, 1=Y, 2=Z.
Returns:
np.ndarray: Rotated points (N, 3).
"""
rot_sin = np.sin(angles)
rot_cos = np.cos(angles)
ones = np.ones_like(rot_cos)
zeros = np.zeros_like(rot_cos)
if axis == 1:
rot_mat = np.stack([
np.stack([rot_cos, zeros, -rot_sin]),
np.stack([zeros, ones, zeros]),
np.stack([rot_sin, zeros, rot_cos]),
])
elif axis == 2:
rot_mat = np.stack([
np.stack([rot_cos, rot_sin, zeros]),
np.stack([-rot_sin, rot_cos, zeros]),
np.stack([zeros, zeros, ones]),
])
elif axis == 0:
rot_mat = np.stack([
np.stack([ones, zeros, zeros]),
np.stack([zeros, rot_cos, rot_sin]),
np.stack([zeros, -rot_sin, rot_cos]),
])
else:
raise ValueError(f"axis must be in {{0, 1, 2}}, got {axis}")
return np.dot(points, rot_mat)
def compute_3d_box_corners_4face(center_3d, dimensions, rotation, face_type=0):
"""Compute the 8 corners of a 3D bounding box from a face-center point.
Args:
center_3d (array-like): (x, y, z) centre of the specified face in camera coords.
dimensions (array-like): (length, height, width) of the box in metres.
rotation (float): rot_y — rotation about the Y axis in radians.
face_type (int): 0=front, 1=tail/rear, 2=left, 3=right, -1=box centre.
Returns:
np.ndarray: (8, 3) corner coordinates in camera frame.
"""
l, h, w = dimensions
corners_norm = np.stack(np.unravel_index(np.arange(8), [2] * 3), axis=1).astype(np.float64)
corners_norm = corners_norm[[0, 1, 3, 2, 4, 5, 7, 6]]
offsets = {
1: [0, 0.5, 0.5], # tail
0: [1, 0.5, 0.5], # front
3: [0.5, 0.5, 0], # right
2: [0.5, 0.5, 1], # left
-1: [0.5, 0.5, 0.5], # whole centre
}
corners_norm = corners_norm - offsets.get(face_type, offsets[-1])
corners = np.array([l, h, w]).reshape(1, 3) * corners_norm.reshape(8, 3)
corners = rotation_3d_in_axis(corners, rotation, axis=1)
corners += np.array(center_3d).reshape(1, 3)
return corners
# ---------------------------------------------------------------------------
# Fisheye (KB) distortion helpers
# ---------------------------------------------------------------------------
def apply_fisheye_distortion(x, y, distort_coeffs):
"""Apply Kannala-Brandt fisheye distortion to normalised camera coordinates.
Args:
x (float): Normalised x coordinate (x3d / z3d).
y (float): Normalised y coordinate (y3d / z3d).
distort_coeffs (list): [k1, k2, k3, k4] KB coefficients.
Returns:
tuple[float, float]: Distorted normalised coordinates (xd, yd).
"""
if not distort_coeffs or len(distort_coeffs) < 4:
return x, y
k1, k2, k3, k4 = distort_coeffs[:4]
r = np.sqrt(x * x + y * y)
if r < 1e-8:
return x, y
theta = np.arctan(r)
theta_d = theta * (1 + k1 * theta**2 + k2 * theta**4 + k3 * theta**6 + k4 * theta**8)
scale = theta_d / r
return x * scale, y * scale
def remove_fisheye_distortion(xd, yd, distort_coeffs, max_iter=20):
"""Remove KB fisheye distortion from normalised camera coordinates.
Args:
xd (float): Distorted normalised x coordinate.
yd (float): Distorted normalised y coordinate.
distort_coeffs (list): [k1, k2, k3, k4] KB coefficients.
max_iter (int): Maximum Newton-Raphson iterations.
Returns:
tuple[float, float]: Undistorted normalised coordinates (xn, yn).
"""
if not distort_coeffs or len(distort_coeffs) < 4:
return xd, yd
k1, k2, k3, k4 = distort_coeffs[:4]
r_d = np.sqrt(xd * xd + yd * yd)
if r_d < 1e-8:
return xd, yd
theta_d = r_d
theta = theta_d / (1 + k1 * theta_d * theta_d)
for _ in range(max_iter):
theta2 = theta * theta
theta4 = theta2 * theta2
theta6 = theta4 * theta2
theta8 = theta4 * theta4
f = theta * (1 + k1 * theta2 + k2 * theta4 + k3 * theta6 + k4 * theta8) - theta_d
f_prime = 1 + 3 * k1 * theta2 + 5 * k2 * theta4 + 7 * k3 * theta6 + 9 * k4 * theta8
theta_new = theta - f / f_prime
if abs(theta_new - theta) < 1e-8:
theta = theta_new
break
theta = theta_new
r = np.tan(theta)
scale = r / r_d
return xd * scale, yd * scale
# ---------------------------------------------------------------------------
# 3D-to-2D projection helpers
# ---------------------------------------------------------------------------
def project_3d_to_2d_with_distortion(points_3d, calib):
"""Project 3D points to 2D using KB fisheye camera calibration.
Args:
points_3d (np.ndarray): (N, 3) points in camera coordinates.
calib (dict): Camera parameters ``fx``, ``fy``, ``cx``, ``cy``,
and optional ``distort_coeffs``.
Returns:
np.ndarray: (N, 2) image coordinates (NaN for behind-camera points).
"""
fx, fy = calib['fx'], calib['fy']
cx, cy = calib['cx'], calib['cy']
distort_coeffs = calib.get('distort_coeffs', [])
points_2d = []
for x, y, z in points_3d:
if z > 0.1:
xn, yn = x / z, y / z
xd, yd = apply_fisheye_distortion(xn, yn, distort_coeffs)
points_2d.append([fx * xd + cx, fy * yd + cy])
else:
points_2d.append([np.nan, np.nan])
return np.array(points_2d)
def sample_3d_edge(p1, p2, num_samples=10):
"""Uniformly sample *num_samples* points along the 3D edge from *p1* to *p2*.
Args:
p1 (array-like): Start point (x, y, z).
p2 (array-like): End point (x, y, z).
num_samples (int): Number of sample points.
Returns:
np.ndarray: (num_samples, 3) sampled 3D points.
"""
t = np.linspace(0, 1, num_samples).reshape(-1, 1)
return p1 + t * (p2 - p1)
def project_3d_box_edges_with_distortion(corners_3d, calib, samples_per_edge=10):
"""Project 3D box edges to 2D by sampling, handling fisheye distortion.
Args:
corners_3d (np.ndarray): (8, 3) 3D corner coordinates.
calib (dict): Camera calibration dict.
samples_per_edge (int): Number of samples per edge.
Returns:
dict: Mapping edge_name → (N, 2) 2D projected points.
"""
edges = {
'back_0': (4, 5), 'back_1': (5, 6), 'back_2': (6, 7), 'back_3': (7, 4),
'connect_0': (0, 4), 'connect_1': (1, 5), 'connect_2': (2, 6), 'connect_3': (3, 7),
'front_0': (0, 1), 'front_1': (1, 2), 'front_2': (2, 3), 'front_3': (3, 0),
'front_x1': (0, 2), 'front_x2': (1, 3),
}
return {
name: project_3d_to_2d_with_distortion(
sample_3d_edge(corners_3d[i], corners_3d[j], samples_per_edge), calib
)
for name, (i, j) in edges.items()
}
def plot_box3d_on_img_with_distortion(img, edge_points_2d,
color_front=(255, 0, 0),
color_back=(0, 0, 255),
color_side=(0, 255, 255),
thickness=1):
"""Draw a 3D box on *img* using pre-projected edge point lists (fisheye-aware).
Args:
img (np.ndarray): BGR image to draw on.
edge_points_2d (dict): Output of :func:`project_3d_box_edges_with_distortion`.
color_front (tuple): BGR colour for front-face edges.
color_back (tuple): BGR colour for back-face edges.
color_side (tuple): BGR colour for side connecting edges.
thickness (int): Line thickness in pixels.
Returns:
np.ndarray: Modified image.
"""
front_edges = {'front_0', 'front_1', 'front_2', 'front_3', 'front_x1', 'front_x2'}
back_edges = {'back_0', 'back_1', 'back_2', 'back_3'}
for edge_name, points in edge_points_2d.items():
if np.any(np.isnan(points)):
continue
pts = points.astype(np.int32)
if edge_name in front_edges:
color = color_front
elif edge_name in back_edges:
color = color_back
else:
color = color_side
cv2.polylines(img, [pts], isClosed=False, color=color, thickness=thickness, lineType=cv2.LINE_AA)
return img
def project_3d_to_2d_with_calib(points_3d, calib):
"""Project 3D points to 2D using pinhole calibration (no distortion).
Args:
points_3d (np.ndarray): (N, 3) points in camera coordinates.
calib (dict): Camera parameters ``fx``, ``fy``, ``cx``, ``cy``.
Returns:
np.ndarray: (N, 2) image coordinates (NaN for behind-camera points).
"""
fx, fy = calib['fx'], calib['fy']
cx, cy = calib['cx'], calib['cy']
points_2d = []
for x, y, z in points_3d:
if z > 0.1:
points_2d.append([fx * x / z + cx, fy * y / z + cy])
else:
points_2d.append([np.nan, np.nan])
return np.array(points_2d)
def project_3d_to_2d_simple(points_3d, img_size):
"""Project 3D points to 2D using a simple estimated pinhole model.
Args:
points_3d (np.ndarray): (N, 3) points in camera coordinates.
img_size (tuple[int, int]): ``(width, height)`` of the image.
Returns:
np.ndarray: (N, 2) image coordinates.
"""
w, h = img_size
fx = fy = w * 1.2
cx, cy = w / 2, h / 2
points_2d = []
for x, y, z in points_3d:
if z > 0.1:
points_2d.append([fx * x / z + cx, fy * y / z + cy])
else:
points_2d.append([np.nan, np.nan])
return np.array(points_2d)
def plot_box3d_on_img(img, corners_2d,
color_front=(255, 0, 0),
color_back=(0, 0, 255),
color_side=(0, 255, 255),
thickness=1):
"""Draw a 3D bounding box on *img* from 2D projected corners.
Args:
img (np.ndarray): BGR image to draw on.
corners_2d (np.ndarray): (8, 2) projected corner coordinates.
color_front (tuple): BGR colour for front-face edges (indices 0-3).
color_back (tuple): BGR colour for back-face edges (indices 4-7).
color_side (tuple): BGR colour for connecting side edges.
thickness (int): Line thickness in pixels.
Returns:
np.ndarray: Modified image.
"""
line_indices = (
(4, 5), (5, 6), (6, 7), (7, 4), # back face
(0, 4), (1, 5), (2, 6), (3, 7), # side edges
(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3), # front face + X mark
)
front_edges = {(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3)}
back_edges = {(4, 5), (5, 6), (6, 7), (7, 4)}
corners = corners_2d.astype(np.int32)
for start, end in line_indices:
try:
pt1 = (corners[start, 0], corners[start, 1])
pt2 = (corners[end, 0], corners[end, 1])
if (start, end) in front_edges:
cv2.line(img, pt1, pt2, color_front, thickness, cv2.LINE_AA)
elif (start, end) in back_edges:
cv2.line(img, pt1, pt2, color_back, thickness, cv2.LINE_AA)
else:
cv2.line(img, pt1, pt2, color_side, thickness, cv2.LINE_AA)
except Exception:
pass
return img
# ---------------------------------------------------------------------------
# 3D box reconstruction from target label format
# ---------------------------------------------------------------------------
def _reconstruct_3d_box_from_face(face_uv, face_z, dims, rot_y, face_type, calib):
"""Reconstruct 3D box corners from a visible face centre.
Args:
face_uv (tuple[float, float]): Pixel coordinates (u, v) of the face centre.
face_z (float): Depth of the face centre in metres.
dims (array-like): (length, height, width) in metres.
rot_y (float): Yaw rotation in radians.
face_type (int): 0=front, 1=rear, 2=left, 3=right.
calib (dict): Camera calibration dict.
Returns:
tuple[np.ndarray, list] | None: ``(corners_3d, object_3d)`` or ``None`` on failure.
"""
if calib is None:
return None
fx, fy = calib['fx'], calib['fy']
cx, cy = calib['cx'], calib['cy']
distort_coeffs = calib.get('distort_coeffs', [])
u_face, v_face = face_uv
xd = (u_face - cx) / fx
yd = (v_face - cy) / fy
if distort_coeffs and len(distort_coeffs) >= 4:
xn, yn = remove_fisheye_distortion(xd, yd, distort_coeffs)
else:
xn, yn = xd, yd
l, h, w = dims
if np.isnan(l) or np.isnan(h) or np.isnan(w) or np.isnan(rot_y):
return None
face_center_3d = np.array([xn * face_z, yn * face_z, face_z])
corners_3d = compute_3d_box_corners_4face(face_center_3d, dims, rot_y, face_type=face_type)
object_3d = [face_center_3d[0], face_center_3d[1], face_center_3d[2], l, h, w, rot_y, face_type]
return corners_3d, object_3d
def decode_and_reconstruct_3d_box_from_target(target, calib, img_width, img_height,
face_3d_classes=None, complete_3d_classes=None):
"""Decode a ground-truth target vector and reconstruct its 3D box.
The target array follows the 48-column format used in ``YOLOGround3DDataset``:
* col 0 — image index
* col 1 — class id
* cols 2-5 — normalised 2D bbox [x, y, w, h]
* cols 6-8 — 3D centre [x3d, y3d, z3d] in camera coords
* cols 9-11 — dimensions [l, h, w]
* col 12 — rot_y
* cols 13-14 — normalised UV projection of 3D centre
* col 15 — alpha
* cols 16-23 — front face [x3d, y3d, z3d, alpha, xc, yc, score, visible]
* cols 24-31 — rear face (same layout)
* cols 32-39 — left face
* cols 40-47 — right face
Args:
target (np.ndarray): (48,) label vector.
calib (dict): Camera calibration dict; must contain ``depth_scale``.
img_width (int): Image width in pixels.
img_height (int): Image height in pixels.
face_3d_classes (list | None): Class IDs using face-based reconstruction.
Defaults to ``[0, 13]`` (vehicles, tricycles).
complete_3d_classes (list | None): Class IDs using complete-box
reconstruction. Defaults to ``[1, 2, 3]`` (pedestrians, bicycles,
riders).
Returns:
dict | None: Result dict with keys ``should_draw``, ``cls``,
``corners_3d``, ``face_center_2d``, ``face_color``, ``center_2d``,
``object_3d``; or ``None`` if the target is invalid / unsupported.
"""
if face_3d_classes is None:
face_3d_classes = [0, 1, 2, 3, 4, 5, 6, 7, 8] # vehicles
if complete_3d_classes is None:
complete_3d_classes = [9, 10, 11, 12] # pedestrian / cyclists
if len(target) < 15 or np.isnan(target[1]):
return None
cls = int(target[1])
if cls not in face_3d_classes and cls not in complete_3d_classes:
return None
depth_scale = calib['depth_scale']
result = {
'should_draw': True,
'cls': cls,
'corners_3d': None,
'face_center_2d': None,
'face_color': None,
'center_2d': None,
}
if cls in face_3d_classes:
if len(target) < 48:
return None
face_offsets = [16, 24, 32, 40]
# BGR: front=red, rear=blue, left=green, right=yellow
face_colors = [(0, 0, 255), (255, 0, 0), (0, 255, 0), (0, 255, 255)]
best_face_type = -1
best_score = -1.0
best_face_data = None
for face_type, face_offset in enumerate(face_offsets):
if target.shape[0] < face_offset + 8:
continue
face_data = target[face_offset:face_offset + 8]
_, _, z3d_face, _, xc_face, yc_face, score, is_visible = face_data
if is_visible == -1 or np.isnan(is_visible) or is_visible != 1:
continue
if np.isnan(score) or score < 0.3:
continue
if np.isnan(xc_face) or np.isnan(yc_face) or np.isnan(z3d_face) or z3d_face <= 0:
continue
if score > best_score:
best_score = score
best_face_type = face_type
best_face_data = face_data
if best_face_type != -1 and best_face_data is not None:
xc_face = best_face_data[4]
yc_face = best_face_data[5]
z3d_face = best_face_data[2] * depth_scale
u_face = xc_face * img_width
v_face = yc_face * img_height
dims = target[9:12]
rot_y = target[12]
result_face = _reconstruct_3d_box_from_face(
(u_face, v_face), z3d_face, dims, rot_y, best_face_type, calib
)
if result_face is not None:
corners_3d, object_3d = result_face
result['corners_3d'] = corners_3d
result['face_center_2d'] = (u_face, v_face)
result['face_color'] = face_colors[best_face_type]
result['object_3d'] = object_3d
elif cls in complete_3d_classes:
x3d, y3d, z3d = target[6:9]
dimensions = target[9:12]
rot_y = target[12]
xc_norm, yc_norm = target[13:15]
z3d = z3d * depth_scale
if np.isnan(z3d) or z3d <= 0 or np.any(np.isnan(dimensions)):
return None
if np.isnan(x3d) or np.isnan(y3d):
return None
corners_3d = compute_3d_box_corners_4face(
np.array([x3d, y3d, z3d]), dimensions, rot_y, face_type=-1
)
result['corners_3d'] = corners_3d
result['center_2d'] = (xc_norm * img_width, yc_norm * img_height)
result['object_3d'] = [
x3d, y3d, z3d,
dimensions[0], dimensions[1], dimensions[2],
rot_y, -1,
]
return result
# ---------------------------------------------------------------------------
# Drawing helpers
# ---------------------------------------------------------------------------
def draw_3d_box_from_corners(im, corners_3d, calib, img_shape,
face_center_2d=None, face_color=None, thickness=1):
"""Project and draw a 3D box given raw corner coordinates.
Args:
im (np.ndarray): BGR image array (H, W, 3).
corners_3d (np.ndarray): (8, 3) corners in camera frame
(output of :func:`compute_3d_box_corners_4face`).
calib (dict | None): Camera calibration dict.
img_shape (tuple[int, int]): ``(width, height)`` of *im*.
face_center_2d (tuple[float, float] | None): Pixel coords to mark with a dot.
face_color (tuple | None): BGR colour for the face-centre dot.
thickness (int): Line thickness in pixels.
Returns:
np.ndarray: Modified image.
"""
w, h = img_shape
# compute_3d_box_corners_4face places rear at indices 0-3 and front at 4-7.
# plot_box3d_on_img expects front at indices 0-3 (drawn red) and rear at 4-7.
corners_3d = corners_3d[[4, 5, 6, 7, 0, 1, 2, 3]]
color_front = (0, 0, 255) # Red (BGR)
color_back = (255, 0, 0) # Blue (BGR)
color_side = (255, 255, 0) # Cyan (BGR)
if calib and calib.get('distort_coeffs'):
edge_pts = project_3d_box_edges_with_distortion(corners_3d, calib, samples_per_edge=15)
im = plot_box3d_on_img_with_distortion(
im, edge_pts, color_front, color_back, color_side, thickness=thickness
)
else:
corners_2d = (project_3d_to_2d_with_calib(corners_3d, calib)
if calib is not None
else project_3d_to_2d_simple(corners_3d, (w, h)))
if not np.any(np.isnan(corners_2d)):
im = plot_box3d_on_img(im, corners_2d, color_front, color_back, color_side, thickness=thickness)
if face_center_2d is not None and face_color is not None:
cv2.circle(im, (int(face_center_2d[0]), int(face_center_2d[1])), 2, face_color, -1, cv2.LINE_AA)
return im
def plot_3d_boxes_from_decoded_targets(im, decoded_results, paths, calib=None, names=None,
label_text=None, scale_factor=2):
"""Render 3D boxes from pre-decoded ground-truth targets onto *im*.
Args:
im (torch.Tensor): Batch tensor (N, 3, H, W) normalised [0, 1] in BGR
format (as returned by the 3D dataloader).
decoded_results (list[list[dict]]): ``decoded_results[i]`` holds a list of
result dicts for image *i*, each produced by
:func:`decode_and_reconstruct_3d_box_from_target`.
paths (list[str]): Image file paths (informational only).
calib (dict | list[dict] | None): Camera calibration dict (or first element
of a list) used for 3D→2D projection.
names (dict | None): Mapping from class id to class name (unused here but
kept for API symmetry).
label_text (str | None): Text to overlay in the top-left corner, e.g.
``"3D GT"``.
scale_factor (int): Upscale factor applied to the output image.
Returns:
np.ndarray | None: (H*scale_factor, W*scale_factor, 3) RGB image with 3D
boxes drawn, or ``None`` if the input is empty.
"""
if im.ndim == 3:
im = im.unsqueeze(0)
im_np = im[0].cpu().numpy().transpose(1, 2, 0)
im_np = np.ascontiguousarray(im_np * 255, dtype=np.uint8)
h_orig, w_orig = im_np.shape[:2]
h_new = h_orig * scale_factor
w_new = w_orig * scale_factor
im_bgr = cv2.resize(im_np, (w_new, h_new), interpolation=cv2.INTER_LINEAR)
if isinstance(calib, list):
calib = calib[0] if calib else None
calib_scaled = None
if calib is not None:
calib_scaled = {
'fx': calib['fx'] * scale_factor,
'fy': calib['fy'] * scale_factor,
'cx': calib['cx'] * scale_factor,
'cy': calib['cy'] * scale_factor,
'distort_coeffs': calib.get('distort_coeffs', []),
}
for decoded in decoded_results[0]:
if not decoded or not decoded['should_draw'] or decoded['corners_3d'] is None:
continue
cls = decoded['cls']
if cls in range(9): # vehicles (car/suv/pickup/...special_vehicle/unknown) — face-based
face_center_2d = decoded.get('face_center_2d')
if face_center_2d is not None:
face_center_2d = (
face_center_2d[0] * scale_factor,
face_center_2d[1] * scale_factor,
)
im_bgr = draw_3d_box_from_corners(
im_bgr,
decoded['corners_3d'],
calib_scaled,
(w_new, h_new),
face_center_2d=face_center_2d,
face_color=decoded.get('face_color'),
)
elif cls in (9, 10, 11, 12): # pedestrian / bicyclists / bicycles / tricycles — complete box
im_bgr = draw_3d_box_from_corners(
im_bgr,
decoded['corners_3d'],
calib_scaled,
(w_new, h_new),
thickness=2,
)
if decoded.get('center_2d'):
color = colors(cls)
u, v = decoded['center_2d']
u_s, v_s = int(u * scale_factor), int(v * scale_factor)
cv2.circle(im_bgr, (u_s, v_s), 4, color, -1, cv2.LINE_AA)
cv2.circle(im_bgr, (u_s, v_s), 6, (255, 255, 255), 2, cv2.LINE_AA)
if label_text:
cv2.putText(im_bgr, label_text, (10, 50), cv2.FONT_HERSHEY_SIMPLEX,
1.5, (0, 255, 255), 3, cv2.LINE_AA)
return cv2.cvtColor(im_bgr, cv2.COLOR_BGR2RGB)
# ---------------------------------------------------------------------------
# Bird's-Eye View (BEV) visualization
# ---------------------------------------------------------------------------
def draw_bev_blank(max_range=200):
"""Create a blank BEV image with distance and lateral grid lines.
Args:
max_range (int): Maximum forward range in metres (default 200).
Returns:
np.ndarray: (H, W, 3) BGR image with grid and ego-vehicle box drawn.
"""
pixels_per_meter = 20
img_height = max_range * pixels_per_meter # e.g. 4000 px for 200 m
img_width = 100 * pixels_per_meter # lateral range -50 m … +50 m
bevimg = np.ones((img_height, img_width, 3), dtype=np.uint8) * 255
ego_center_x = img_width // 2
ego_center_y = img_height # ego position at bottom-centre
ego_half_l = int(4.5 * pixels_per_meter) // 2
ego_half_w = int(1.8 * pixels_per_meter) // 2
ego_box = np.array([
[ego_center_x - ego_half_w, ego_center_y - ego_half_l],
[ego_center_x + ego_half_w, ego_center_y - ego_half_l],
[ego_center_x + ego_half_w, ego_center_y + ego_half_l],
[ego_center_x - ego_half_w, ego_center_y + ego_half_l],
], dtype=np.int32)
# Horizontal (range) grid
grid_step_px = 20 * pixels_per_meter # every 20 m
for i in range(max_range // 20 + 1):
y_pos = ego_center_y - i * grid_step_px
if y_pos >= 0:
cv2.line(bevimg, (0, y_pos), (img_width, y_pos), (180, 180, 180), 3, cv2.LINE_AA)
cv2.putText(bevimg, f"{i * 20}m", (ego_center_x + 15, y_pos - 15),
cv2.FONT_HERSHEY_SIMPLEX, 1.2, (80, 80, 80), 2, cv2.LINE_AA)
# Vertical (lateral) grid
lat_step_px = 10 * pixels_per_meter # every 10 m
for i in range(-5, 6):
x_pos = ego_center_x + i * lat_step_px
if 0 <= x_pos < img_width:
cv2.line(bevimg, (x_pos, 0), (x_pos, img_height), (180, 180, 180), 3, cv2.LINE_AA)
if i != 0:
cv2.putText(bevimg, f"{i * 10}m", (x_pos - 40, img_height - 20),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (80, 80, 80), 2, cv2.LINE_AA)
cv2.drawContours(bevimg, [ego_box], 0, (255, 0, 0), -1) # blue-filled ego box
return bevimg
def cam_corners_front_rear(pred3d, facetype):
"""Compute 8 box corners from a face-centre 3D representation.
Args:
pred3d (array-like): ``[x, y, z, l, h, w, rot_y]`` (7 values).
facetype (int | str): Face type identifier — ``0``/``'front'``,
``1``/``'tail'``, ``2``/``'left'``, ``3``/``'right'``,
``-1``/``'whole'``.
Returns:
np.ndarray: (8, 3) 3D corner coordinates in camera frame.
"""
dims = pred3d[3:6]
corners_norm = np.stack(np.unravel_index(np.arange(8), [2] * 3), axis=1)
corners_norm = corners_norm[[0, 1, 3, 2, 4, 5, 7, 6]].astype(float)
if facetype in ('tail', 1):
corners_norm -= [0, 0.5, 0.5]
elif facetype in ('front', 0):
corners_norm -= [1, 0.5, 0.5]
elif facetype in ('right', 3):
corners_norm -= [0.5, 0.5, 0]
elif facetype in ('left', 2):
corners_norm -= [0.5, 0.5, 1]
elif facetype in ('whole', -1):
corners_norm -= [0.5, 0.5, 0.5]
else:
raise AssertionError(f"Non-valid face type: {facetype}")
corners = dims.reshape(1, 3) * corners_norm.reshape(8, 3)
corners = rotation_3d_in_axis(corners, pred3d[6], axis=1)
corners += pred3d[:3].reshape(1, 3)
return corners
def drawbev(bevimg, vehicle3d, is_pred=True):
"""Draw a single vehicle box on a BEV image.
Args:
bevimg (np.ndarray): BEV image produced by :func:`draw_bev_blank`.
vehicle3d (list | np.ndarray): ``[x, y, z, l, h, w, ..., rot_y, face_type]``.
The second-to-last element is ``rot_y`` and the last is ``face_type``.
is_pred (bool): ``True`` → red box (prediction); ``False`` → green box (GT).
Returns:
np.ndarray: Modified BEV image.
"""
x, y, z = vehicle3d[0], vehicle3d[1], vehicle3d[2]
l, h, w = vehicle3d[3], vehicle3d[4], vehicle3d[5]
rotation_y = vehicle3d[-2]
face_type = vehicle3d[-1]
pixels_per_meter = 20
max_range = 200
lateral_range = 50
img_height = bevimg.shape[0]
img_width = bevimg.shape[1]
ego_center_x = img_width // 2
ego_center_y = img_height
if x > lateral_range or x < -lateral_range or z > max_range or z < 0:
return bevimg
corners = cam_corners_front_rear(np.array([x, y, z, l, h, w, rotation_y]), face_type)
xyz3d_front = np.mean(corners[4:8, :], axis=0)
xyz3d_center = np.mean(corners[0:8, :], axis=0)
center = (
int(ego_center_x + xyz3d_center[0] * pixels_per_meter),
int(ego_center_y - xyz3d_center[2] * pixels_per_meter),
)
front_point = (
int(ego_center_x + xyz3d_front[0] * pixels_per_meter),
int(ego_center_y - xyz3d_front[2] * pixels_per_meter),
)
rect = (center, (l * pixels_per_meter, w * pixels_per_meter), np.degrees(rotation_y))
box = np.intp(cv2.boxPoints(rect))
color = (0, 0, 255) if is_pred else (0, 255, 0)
cv2.drawContours(bevimg, [box], 0, color, 3, cv2.LINE_AA)
cv2.arrowedLine(bevimg, center, front_point, color, thickness=3, tipLength=0.3, line_type=cv2.LINE_AA)
return bevimg

View File

@@ -0,0 +1,187 @@
"""
快速测试脚本 - 自动查找并可视化第一个有效样本
用途:自动从验证集中查找一个带标签的样本并进行可视化,用于测试脚本功能
使用方法:
python scripts_for_gt/test_visualization.py --data data/mono3d.yaml
"""
import argparse
import sys
from pathlib import Path
import yaml
# Add project root to path
FILE = Path(__file__).resolve()
ROOT = FILE.parents[1]
if str(ROOT) not in sys.path:
sys.path.append(str(ROOT))
from visualize_single_frame import visualize_single_frame
def find_first_valid_sample(data_yaml_path):
"""从数据集配置中查找第一个有效样本
Args:
data_yaml_path: 数据集YAML配置文件路径
Returns:
tuple: (image_path, label_path, calib_path) 或 None
"""
# 读取数据集配置
with open(data_yaml_path, 'r') as f:
data = yaml.safe_load(f)
# 获取验证集路径
val_path = data.get('val')
if val_path is None:
print("错误:数据集配置中未找到'val'字段")
return None
# 如果是相对路径相对于yaml文件的位置
val_path = Path(val_path)
if not val_path.is_absolute():
val_path = Path(data_yaml_path).parent / val_path
# 读取验证集图像列表
if val_path.is_file():
# 如果是文件列表
with open(val_path, 'r') as f:
image_files = [line.strip() for line in f.readlines() if line.strip()]
else:
# 如果是目录
image_files = list(val_path.glob("**/*.jpg"))
image_files.extend(list(val_path.glob("**/*.png")))
print(f"在验证集中找到 {len(image_files)} 个图像文件")
# 遍历查找第一个有标签的样本
for image_path in image_files:
image_rel_path = Path(image_path)
image_path = val_path.parent / image_rel_path if not image_rel_path.is_absolute() else image_rel_path
# 推断标签路径
label_path = Path(str(image_path).replace('/images/', '/labels/').replace('.jpg', '.txt').replace('.png', '.txt'))
# 推断标定路径
calib_path = image_path.parent.parent / 'calib' / 'L2_calib' / 'camera4.json'
if label_path.exists():
print(f"\n找到有效样本:")
print(f" 图像: {image_path}")
print(f" 标签: {label_path}")
print(f" 标定: {calib_path if calib_path.exists() else '未找到'}")
return str(image_path), str(label_path), str(calib_path) if calib_path.exists() else None
print("错误:未找到有效样本")
return None
def test_visualization(data_yaml_path, output_dir="./test_gt_viz", use_roi=True):
"""测试可视化功能
Args:
data_yaml_path: 数据集YAML配置文件路径
output_dir: 输出目录
use_roi: 是否使用ROI变换
"""
# 读取数据集配置
with open(data_yaml_path, 'r') as f:
data = yaml.safe_load(f)
# 查找第一个有效样本
result = find_first_valid_sample(data_yaml_path)
if result is None:
return
image_path, label_path, calib_path = result
# 获取配置参数
roi = data.get('roi') # [width, height]
virtual_fx = data.get('virtual_fx')
ori_img_size = data.get('ori_img_size') # [width, height]
print(f"\n数据集配置:")
print(f" ROI: {roi}")
print(f" 虚拟焦距: {virtual_fx}")
print(f" 原始图像尺寸: {ori_img_size}")
# 类别名称
names = {
0: "vehicle",
1: "pedestrian",
2: "bicycle",
3: "rider",
13: "tricycle",
}
print(f"\n开始可视化...")
try:
# 测试1不使用ROI
print("\n=== 测试1原始图像可视化不使用ROI===")
visualize_single_frame(
image_path=image_path,
label_path=label_path,
output_dir=f"{output_dir}/no_roi",
calib_path=calib_path,
roi_size=None,
virtual_fx=None,
ori_img_size=None,
names=names,
)
print("✓ 测试1完成")
# 测试2使用ROI如果配置了
if use_roi and roi is not None and virtual_fx is not None:
print("\n=== 测试2ROI变换后可视化 ===")
visualize_single_frame(
image_path=image_path,
label_path=label_path,
output_dir=f"{output_dir}/with_roi",
calib_path=calib_path,
roi_size=tuple(roi),
virtual_fx=virtual_fx,
ori_img_size=tuple(ori_img_size) if ori_img_size else None,
names=names,
)
print("✓ 测试2完成")
print(f"\n✅ 所有测试完成!结果保存在: {output_dir}")
print("\n生成的文件:")
output_path = Path(output_dir)
for file in sorted(output_path.rglob("*.jpg")):
print(f" {file.relative_to(output_path)}")
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
def parse_args():
parser = argparse.ArgumentParser(description="快速测试真值可视化功能")
parser.add_argument("--data", type=str, default="data/mono3d.yaml",
help="数据集YAML配置文件")
parser.add_argument("--output", type=str, default="./test_gt_viz",
help="输出目录")
parser.add_argument("--no-roi", action="store_true",
help="不测试ROI变换")
return parser.parse_args()
def main():
args = parse_args()
test_visualization(
data_yaml_path=args.data,
output_dir=args.output,
use_roi=not args.no_roi,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,209 @@
"""
批量可视化脚本
用途:批量读取多帧图像和对应的真值标签文件,生成可视化结果
使用方法:
python scripts_for_gt/visualize_batch.py \
--image-dir /path/to/images \
--label-dir /path/to/labels \
--output output_dir \
--roi 704 352 \
--virtual-fx 500 \
--max-samples 100
"""
import argparse
from pathlib import Path
import sys
# Add project root to path
FILE = Path(__file__).resolve()
ROOT = FILE.parents[1]
if str(ROOT) not in sys.path:
sys.path.append(str(ROOT))
from visualize_single_frame import (
DEFAULT_CLASS_NAMES,
format_target_classes,
resolve_target_class_ids,
visualize_single_frame,
)
def resolve_label_path(label_dir, image_stem, preferred_extension=None):
"""按同名文件自动查找标签,支持 .txt / .json。"""
candidate_extensions = []
if preferred_extension:
normalized_extension = preferred_extension if preferred_extension.startswith(".") else f".{preferred_extension}"
candidate_extensions.append(normalized_extension)
for extension in (".txt", ".json"):
if extension not in candidate_extensions:
candidate_extensions.append(extension)
for extension in candidate_extensions:
candidate_path = label_dir / f"{image_stem}{extension}"
if candidate_path.exists():
return candidate_path
return None
def visualize_batch(
image_dir,
label_dir,
output_dir,
calib_dir=None,
roi_size=None,
virtual_fx=None,
ori_img_size=None,
max_samples=None,
sample_interval=1,
file_extension=".jpg",
label_extension=None,
target_class_ids=None,
):
"""批量可视化图像
Args:
image_dir: 图像目录
label_dir: 标签目录
output_dir: 输出目录
calib_dir: 标定文件目录包含L2_calib/camera4.json
roi_size: ROI尺寸 (width, height)
virtual_fx: 虚拟焦距
ori_img_size: 原始图像尺寸 (width, height)
max_samples: 最大处理样本数
sample_interval: 采样间隔,每隔 N 帧处理一帧
file_extension: 图像文件扩展名
label_extension: 标签文件扩展名,默认自动在 .txt/.json 中查找
target_class_ids: 目标类别ID集合None表示不过滤
"""
image_dir = Path(image_dir)
label_dir = Path(label_dir)
output_dir = Path(output_dir)
# 获取所有图像文件
image_files = sorted(image_dir.glob(f"*{file_extension}"))
if sample_interval > 1:
image_files = image_files[::sample_interval]
if max_samples is not None:
image_files = image_files[:max_samples]
print(f"找到 {len(image_files)} 个图像文件(采样间隔={sample_interval}")
# 类别名称
names = DEFAULT_CLASS_NAMES.copy()
if target_class_ids is not None:
print(f"类别过滤: {format_target_classes(target_class_ids, names=names)}")
success_count = 0
error_count = 0
for i, image_path in enumerate(image_files):
print(f"\n[{i+1}/{len(image_files)}] 处理: {image_path.name}")
# 查找对应的标签文件
label_path = resolve_label_path(label_dir, image_path.stem, preferred_extension=label_extension)
if label_path is None:
expected_extensions = [label_extension] if label_extension else [".txt", ".json"]
print(f" 警告:未找到标签文件 {image_path.stem},已检查扩展名: {expected_extensions}")
error_count += 1
continue
# 查找对应的标定文件
if calib_dir is not None:
# 兼容 L2_calib 子目录和直接放在 calib/ 下两种结构
calib_path = Path(calib_dir) / "L2_calib" / "camera4.json"
if not calib_path.exists():
calib_path = Path(calib_dir) / "camera4.json"
else:
# 自动推断:依次尝试两种常见目录结构
base = image_path.parent.parent
calib_path = base / "calib" / "L2_calib" / "camera4.json"
if not calib_path.exists():
calib_path = base / "calib" / "camera4.json"
if not calib_path.exists():
calib_path = None
print(f" 警告未找到标定文件将仅进行2D可视化")
try:
# 可视化单帧
sample_output_dir = output_dir
visualize_single_frame(
image_path=str(image_path),
label_path=str(label_path),
output_dir=str(sample_output_dir),
calib_path=str(calib_path) if calib_path else None,
roi_size=roi_size,
virtual_fx=virtual_fx,
ori_img_size=ori_img_size,
names=names,
save_types='combined', # 只保存合成图
target_class_ids=target_class_ids,
)
success_count += 1
except Exception as e:
print(f" 错误:{e}")
error_count += 1
print(f"\n处理完成!")
print(f" 成功: {success_count}")
print(f" 失败: {error_count}")
print(f" 输出目录: {output_dir}")
def parse_args():
parser = argparse.ArgumentParser(description="批量可视化真值标签")
parser.add_argument("--image-dir", type=str, required=True, help="图像目录")
parser.add_argument("--label-dir", type=str, required=True, help="标签目录")
parser.add_argument("--output", type=str, default="./gt_visualization_batch", help="输出目录")
parser.add_argument("--calib-dir", type=str, default=None,
help="标定文件目录包含L2_calib/camera4.json")
parser.add_argument("--roi", type=int, nargs=2, default=None, metavar=("WIDTH", "HEIGHT"),
help="ROI尺寸宽 高),如: --roi 704 352")
parser.add_argument("--virtual-fx", type=float, default=None,
help="虚拟焦距,用于深度归一化")
parser.add_argument("--ori-img-size", type=int, nargs=2, default=None, metavar=("WIDTH", "HEIGHT"),
help="原始图像尺寸(宽 高)")
parser.add_argument("--max-samples", type=int, default=None,
help="最大处理样本数")
parser.add_argument("--sample-interval", type=int, default=1,
help="采样间隔,每隔 N 帧处理一帧默认1即处理全部")
parser.add_argument("--file-extension", type=str, default=".jpg",
help="图像文件扩展名")
parser.add_argument("--label-extension", type=str, default=None,
help="标签文件扩展名,默认自动在 .txt/.json 中查找")
parser.add_argument("--classes", type=str, nargs="+", default=None,
help="仅可视化指定类别,支持类别名/类别ID可传多个值或逗号分隔")
return parser.parse_args()
def main():
args = parse_args()
target_class_ids = resolve_target_class_ids(args.classes, names=DEFAULT_CLASS_NAMES)
visualize_batch(
image_dir=args.image_dir,
label_dir=args.label_dir,
output_dir=args.output,
calib_dir=args.calib_dir,
roi_size=tuple(args.roi) if args.roi else None,
virtual_fx=args.virtual_fx,
ori_img_size=tuple(args.ori_img_size) if args.ori_img_size else None,
max_samples=args.max_samples,
sample_interval=args.sample_interval,
file_extension=args.file_extension,
label_extension=args.label_extension,
target_class_ids=target_class_ids,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# 对 DL_KPI_SCENE 下的所有 case 批量进行 GT 可视化
# 用法:
# bash tools/scripts_for_gt/visualization/visualize_batch.sh [SAMPLE_INTERVAL]
# 例:
# bash tools/scripts_for_gt/visualization/visualize_batch.sh 5 # 每隔5帧处理一帧
set -euo pipefail
SCENE_ROOT="/data1/dongying/Mono3d/G1Q3/dataset_for_evaluation/DL_KPI_SCENE"
SAMPLE_INTERVAL="${1:-20}" # 第一个位置参数为采样间隔默认为20
FILE_EXTENSION=".png"
# 收集所有 case 目录(含 images/ 子目录的)
mapfile -t CASE_DIRS < <(find "$SCENE_ROOT" -mindepth 1 -maxdepth 1 -type d | sort)
echo "共找到 ${#CASE_DIRS[@]} 个 case采样间隔=${SAMPLE_INTERVAL}"
echo "======================================================"
SUCCESS=0
SKIP=0
FAIL=0
for CASE_DIR in "${CASE_DIRS[@]}"; do
IMAGE_DIR="${CASE_DIR}/images"
LABEL_DIR="${CASE_DIR}/labels_json"
OUTPUT_DIR="${CASE_DIR}/vis_json"
if [[ ! -d "$IMAGE_DIR" ]]; then
echo "[SKIP] ${CASE_DIR##*/} — 无 images/ 目录"
(( SKIP++ )) || true
continue
fi
if [[ ! -d "$LABEL_DIR" ]]; then
echo "[SKIP] ${CASE_DIR##*/} — 无 labels_json/ 目录"
(( SKIP++ )) || true
continue
fi
echo ""
echo ">>> 处理 case: ${CASE_DIR##*/}"
python tools/scripts_for_gt/visualization/visualize_batch.py \
--image-dir "$IMAGE_DIR" \
--label-dir "$LABEL_DIR" \
--output "$OUTPUT_DIR" \
--file-extension "$FILE_EXTENSION" \
--sample-interval "$SAMPLE_INTERVAL" \
&& (( SUCCESS++ )) || (( FAIL++ )) || true
done
echo ""
echo "======================================================"
echo "全部完成:成功=${SUCCESS} 跳过=${SKIP} 失败=${FAIL}"

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# 对单个 case 目录批量进行 GT 可视化
# 用法:
# bash tools/scripts_for_gt/visualization/visualize_batch_case.sh [SAMPLE_INTERVAL] [CLASSES]
# 例:
# bash tools/scripts_for_gt/visualization/visualize_batch_case.sh
# bash tools/scripts_for_gt/visualization/visualize_batch_case.sh 5 car
# bash tools/scripts_for_gt/visualization/visualize_batch_case.sh 1 car,pedestrian
# OUTPUT_DIR=/tmp/vis_car bash tools/scripts_for_gt/visualization/visualize_batch_case.sh 1 car
set -euo pipefail
IMAGE_DIR="/data1/dongying/Mono3d/G1Q3/dataset_for_evaluation/DL_KPI_SCENE/019b6aa1-a8ed-7029-8b8c-68311394dade/images"
LABEL_DIR="/data1/dongying/Mono3d/G1Q3/dataset_for_evaluation/DL_KPI_SCENE/019b6aa1-a8ed-7029-8b8c-68311394dade/labels_json"
DEFAULT_OUTPUT_DIR="/data1/dongying/Mono3d/G1Q3/dataset_for_evaluation/DL_KPI_SCENE/019b6aa1-a8ed-7029-8b8c-68311394dade/vis_json_car_suv"
FILE_EXTENSION=".png"
SAMPLE_INTERVAL="${1:-1}"
TARGET_CLASSES="${2:-${TARGET_CLASSES:-car,suv}}"
sanitize_for_path() {
local value="$1"
value="${value//,/__}"
value="${value// /}"
value="${value//\//_}"
value="${value//:/_}"
printf '%s' "$value"
}
if [[ -n "${OUTPUT_DIR:-}" ]]; then
FINAL_OUTPUT_DIR="${OUTPUT_DIR}"
elif [[ -n "${TARGET_CLASSES}" ]]; then
FINAL_OUTPUT_DIR="${DEFAULT_OUTPUT_DIR}_$(sanitize_for_path "${TARGET_CLASSES}")"
else
FINAL_OUTPUT_DIR="${DEFAULT_OUTPUT_DIR}"
fi
echo "image_dir: ${IMAGE_DIR}"
echo "label_dir: ${LABEL_DIR}"
echo "output_dir: ${FINAL_OUTPUT_DIR}"
echo "sample_interval: ${SAMPLE_INTERVAL}"
if [[ -n "${TARGET_CLASSES}" ]]; then
echo "target_classes: ${TARGET_CLASSES}"
else
echo "target_classes: all"
fi
PYTHON_ARGS=(
python tools/scripts_for_gt/visualization/visualize_batch.py
--image-dir "${IMAGE_DIR}"
--label-dir "${LABEL_DIR}"
--output "${FINAL_OUTPUT_DIR}"
--file-extension "${FILE_EXTENSION}"
--sample-interval "${SAMPLE_INTERVAL}"
)
if [[ -n "${TARGET_CLASSES}" ]]; then
PYTHON_ARGS+=(--classes "${TARGET_CLASSES}")
fi
"${PYTHON_ARGS[@]}"

View File

@@ -0,0 +1,474 @@
"""
训练数据过滤可视化脚本
用途:从训练集中筛选符合条件的样本并进行可视化
功能:
- 支持类别过滤(如:只可视化包含两轮车的样本)
- 支持空间范围过滤只可视化指定3D空间范围内的目标
- 支持批量处理
使用方法:
# 可视化包含两轮车的样本两轮车在左右10米、纵向50米范围内
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class bicycle \
--x-range -10 10 \
--z-range 0 50 \
--max-samples 20
# 可视化包含行人的样本
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class pedestrian \
--max-samples 50
# 可视化特定空间范围内的所有车辆
python scripts_for_gt/visualize_filtered_train.py \
--data data/mono3d.yaml \
--target-class vehicle \
--x-range -5 5 \
--z-range 10 30 \
--max-samples 30
"""
import argparse
import sys
from pathlib import Path
import yaml
import numpy as np
# Add project root to path
FILE = Path(__file__).resolve()
ROOT = FILE.parents[1]
if str(ROOT) not in sys.path:
sys.path.append(str(ROOT))
from visualize_single_frame import (
visualize_single_frame,
load_label_file,
)
# 类别名称到ID的映射
CLASS_NAME_TO_ID = {
'vehicle': 0,
'pedestrian': 1,
'bicycle': 2,
'rider': 3,
'tricycle': 13,
}
CLASS_ID_TO_NAME = {v: k for k, v in CLASS_NAME_TO_ID.items()}
def check_sample_matches_filter(
label_path,
target_class_id=None,
x_range=None,
y_range=None,
z_range=None,
min_targets=1,
):
"""检查样本是否符合过滤条件
Args:
label_path: 标签文件路径
target_class_id: 目标类别IDNone表示不过滤类别
x_range: X坐标范围 (min, max)单位None表示不过滤
y_range: Y坐标范围 (min, max)单位None表示不过滤
z_range: Z坐标范围 (min, max)单位None表示不过滤
min_targets: 至少需要多少个符合条件的目标
Returns:
tuple: (matches, matched_count, matched_objects_info)
- matches: bool是否符合条件
- matched_count: int符合条件的目标数量
- matched_objects_info: list of dict符合条件的目标信息
"""
try:
labels = load_label_file(label_path)
except Exception as e:
print(f" 警告:无法读取标签文件 {label_path}: {e}")
return False, 0, []
if len(labels) == 0:
return False, 0, []
matched_objects = []
for label in labels:
# 检查类别
cls = int(label[0])
if target_class_id is not None and cls != target_class_id:
continue
# 检查是否有3D信息
x3d, y3d, z3d = label[6:9]
if np.isnan(x3d) or np.isnan(y3d) or np.isnan(z3d):
continue
# 检查空间范围
in_range = True
if x_range is not None:
if not (x_range[0] <= x3d <= x_range[1]):
in_range = False
if y_range is not None:
if not (y_range[0] <= y3d <= y_range[1]):
in_range = False
if z_range is not None:
if not (z_range[0] <= z3d <= z_range[1]):
in_range = False
if in_range:
obj_info = {
'class': cls,
'class_name': CLASS_ID_TO_NAME.get(cls, f'class_{cls}'),
'x3d': x3d,
'y3d': y3d,
'z3d': z3d,
'bbox_2d': label[1:5],
}
matched_objects.append(obj_info)
matched_count = len(matched_objects)
matches = matched_count >= min_targets
return matches, matched_count, matched_objects
def find_filtered_samples(
data_yaml_path,
target_class=None,
x_range=None,
y_range=None,
z_range=None,
min_targets=1,
max_samples=None,
split='train',
):
"""从数据集中查找符合过滤条件的样本
Args:
data_yaml_path: 数据集YAML配置文件路径
target_class: 目标类别名称(如'bicycle'
x_range: X坐标范围 (min, max)
y_range: Y坐标范围 (min, max)
z_range: Z坐标范围 (min, max)
min_targets: 最少目标数量
max_samples: 最大返回样本数
split: 数据集划分('train''val')
Returns:
list: 符合条件的样本信息列表
"""
# 读取数据集配置
with open(data_yaml_path, 'r') as f:
data = yaml.safe_load(f)
# 获取数据集路径
dataset_path = data.get(split)
if dataset_path is None:
print(f"错误:数据集配置中未找到'{split}'字段")
return []
# 如果是相对路径相对于yaml文件的位置
dataset_path = Path(dataset_path)
if not dataset_path.is_absolute():
dataset_path = Path(data_yaml_path).parent / dataset_path
# 读取图像列表
if dataset_path.is_file():
# 如果是文件列表
with open(dataset_path, 'r') as f:
image_files = [line.strip() for line in f.readlines() if line.strip()]
else:
# 如果是目录
image_files = list(dataset_path.glob("**/*.jpg"))
image_files.extend(list(dataset_path.glob("**/*.png")))
print(f"{split}集中找到 {len(image_files)} 个图像文件")
# 转换类别名称到ID
target_class_id = None
if target_class is not None:
if target_class.lower() in CLASS_NAME_TO_ID:
target_class_id = CLASS_NAME_TO_ID[target_class.lower()]
else:
print(f"警告:未知类别 '{target_class}',将不过滤类别")
# 打印过滤条件
print("\n过滤条件:")
if target_class_id is not None:
print(f" 目标类别: {target_class} (ID={target_class_id})")
else:
print(f" 目标类别: 所有类别")
if x_range is not None:
print(f" X范围: [{x_range[0]:.1f}, {x_range[1]:.1f}] 米 (左右)")
if y_range is not None:
print(f" Y范围: [{y_range[0]:.1f}, {y_range[1]:.1f}] 米 (上下)")
if z_range is not None:
print(f" Z范围: [{z_range[0]:.1f}, {z_range[1]:.1f}] 米 (前后)")
print(f" 最少目标数: {min_targets}")
# 遍历查找符合条件的样本
matched_samples = []
for i, image_file in enumerate(image_files):
if (i + 1) % 100 == 0:
print(f" 进度: {i+1}/{len(image_files)}, 已找到 {len(matched_samples)} 个符合条件的样本")
image_rel_path = Path(image_file)
image_path = dataset_path.parent / image_rel_path if not image_rel_path.is_absolute() else image_rel_path
# 推断标签路径
label_path = Path(str(image_path).replace('/images/', '/labels/').replace('.jpg', '.txt').replace('.png', '.txt'))
if not label_path.exists():
continue
# 检查是否符合过滤条件
matches, matched_count, matched_objects = check_sample_matches_filter(
label_path,
target_class_id=target_class_id,
x_range=x_range,
y_range=y_range,
z_range=z_range,
min_targets=min_targets,
)
if matches:
# 推断标定路径
calib_path = image_path.parent.parent / 'calib' / 'L2_calib' / 'camera4.json'
sample_info = {
'image_path': str(image_path),
'label_path': str(label_path),
'calib_path': str(calib_path) if calib_path.exists() else None,
'matched_count': matched_count,
'matched_objects': matched_objects,
}
matched_samples.append(sample_info)
# 检查是否达到最大样本数
if max_samples is not None and len(matched_samples) >= max_samples:
print(f"\n已找到 {len(matched_samples)} 个符合条件的样本(达到最大限制)")
break
print(f"\n总共找到 {len(matched_samples)} 个符合条件的样本")
return matched_samples
def visualize_filtered_samples(
data_yaml_path,
output_dir="./filtered_train_viz",
target_class=None,
x_range=None,
y_range=None,
z_range=None,
min_targets=1,
max_samples=20,
use_roi=False,
split='train',
save_types=None,
):
"""可视化符合条件的样本
Args:
data_yaml_path: 数据集YAML配置文件路径
output_dir: 输出目录
target_class: 目标类别名称
x_range: X坐标范围
y_range: Y坐标范围
z_range: Z坐标范围
min_targets: 最少目标数量
max_samples: 最大样本数
use_roi: 是否使用ROI变换
split: 数据集划分
save_types: 保存类型列表 ['2d', '3d', 'bev', 'combined']None表示全部保存
"""
# 读取数据集配置
with open(data_yaml_path, 'r') as f:
data = yaml.safe_load(f)
# 查找符合条件的样本
matched_samples = find_filtered_samples(
data_yaml_path=data_yaml_path,
target_class=target_class,
x_range=x_range,
y_range=y_range,
z_range=z_range,
min_targets=min_targets,
max_samples=max_samples,
split=split,
)
if len(matched_samples) == 0:
print("\n未找到符合条件的样本!")
return
# 获取配置参数
roi = data.get('roi') if use_roi else None
virtual_fx = data.get('virtual_fx') if use_roi else None
ori_img_size = data.get('ori_img_size') if use_roi else None
# 类别名称
names = {
0: "vehicle",
1: "pedestrian",
2: "bicycle",
3: "rider",
4: "motorcycle",
13: "tricycle",
}
print(f"\n开始可视化 {len(matched_samples)} 个样本...")
success_count = 0
error_count = 0
for i, sample in enumerate(matched_samples):
print(f"\n[{i+1}/{len(matched_samples)}] 处理样本:")
print(f" 图像: {Path(sample['image_path']).name}")
print(f" 符合条件的目标数: {sample['matched_count']}")
# 打印目标信息
for j, obj in enumerate(sample['matched_objects']):
print(f" 目标{j+1}: {obj['class_name']}, "
f"位置=({obj['x3d']:.2f}, {obj['y3d']:.2f}, {obj['z3d']:.2f})m")
try:
# 创建子目录
sample_name = Path(sample['image_path']).stem
sample_output_dir = Path(output_dir)
# 可视化
visualize_single_frame(
image_path=sample['image_path'],
label_path=sample['label_path'],
output_dir=str(sample_output_dir),
calib_path=sample['calib_path'],
roi_size=tuple(roi) if roi else None,
virtual_fx=virtual_fx,
ori_img_size=tuple(ori_img_size) if ori_img_size else None,
names=names,
save_types=save_types,
)
success_count += 1
except Exception as e:
print(f" 错误: {e}")
error_count += 1
print(f"\n===== 可视化完成 =====")
print(f"成功: {success_count}")
print(f"失败: {error_count}")
print(f"输出目录: {output_dir}")
# 生成摘要文件
summary_path = Path(output_dir) / "summary.txt"
with open(summary_path, 'w', encoding='utf-8') as f:
f.write("过滤可视化摘要\n")
f.write("=" * 50 + "\n\n")
f.write("过滤条件:\n")
if target_class:
f.write(f" 目标类别: {target_class}\n")
if x_range:
f.write(f" X范围: [{x_range[0]:.1f}, {x_range[1]:.1f}] 米\n")
if y_range:
f.write(f" Y范围: [{y_range[0]:.1f}, {y_range[1]:.1f}] 米\n")
if z_range:
f.write(f" Z范围: [{z_range[0]:.1f}, {z_range[1]:.1f}] 米\n")
f.write(f" 最少目标数: {min_targets}\n")
f.write(f" 数据集: {split}\n\n")
f.write(f"可视化结果:\n")
f.write(f" 总样本数: {len(matched_samples)}\n")
f.write(f" 成功: {success_count}\n")
f.write(f" 失败: {error_count}\n\n")
f.write("样本列表:\n")
f.write("-" * 50 + "\n")
for i, sample in enumerate(matched_samples):
f.write(f"{i+1}. {Path(sample['image_path']).name}\n")
f.write(f" 符合条件的目标数: {sample['matched_count']}\n")
for obj in sample['matched_objects']:
f.write(f" - {obj['class_name']}: ({obj['x3d']:.2f}, {obj['y3d']:.2f}, {obj['z3d']:.2f})m\n")
f.write("\n")
print(f"摘要已保存: {summary_path}")
def parse_args():
parser = argparse.ArgumentParser(description="训练数据过滤可视化脚本")
# 基本参数
parser.add_argument("--data", type=str, default="data/mono3d.yaml",
help="数据集YAML配置文件")
parser.add_argument("--output", type=str, default="/data1/dongying/Mono3d/G1M3/filtered_train_viz",
help="输出目录")
parser.add_argument("--split", type=str, default="train", choices=['train', 'val'],
help="数据集划分train或val")
# 过滤条件
parser.add_argument("--target-class", type=str, default='bicycle',
choices=['vehicle','pedestrian','bicycle'],
help="目标类别")
parser.add_argument("--x-range", type=float, nargs=2, default=[-10, 10], metavar=("MIN", "MAX"),
help="X坐标范围左右单位如: --x-range -10 10")
parser.add_argument("--y-range", type=float, nargs=2, default=None, metavar=("MIN", "MAX"),
help="Y坐标范围上下单位")
parser.add_argument("--z-range", type=float, nargs=2, default=[0, 50], metavar=("MIN", "MAX"),
help="Z坐标范围前后单位如: --z-range 0 50")
parser.add_argument("--min-targets", type=int, default=1,
help="最少目标数量")
parser.add_argument("--max-samples", type=int, default=5000,
help="最大可视化样本数")
# 可视化选项
parser.add_argument("--use-roi", action="store_true",
help="使用ROI变换")
parser.add_argument("--save-types", type=str, nargs='+',
choices=['2d', '3d', 'bev', 'combined'],
default=['combined'],
help="保存的图像类型可多选默认只保存combined")
return parser.parse_args()
def main():
args = parse_args()
# 验证范围参数
if args.x_range and args.x_range[0] >= args.x_range[1]:
print("错误X范围的最小值必须小于最大值")
return
if args.y_range and args.y_range[0] >= args.y_range[1]:
print("错误Y范围的最小值必须小于最大值")
return
if args.z_range and args.z_range[0] >= args.z_range[1]:
print("错误Z范围的最小值必须小于最大值")
return
visualize_filtered_samples(
data_yaml_path=args.data,
output_dir=args.output,
target_class=args.target_class,
x_range=tuple(args.x_range) if args.x_range else None,
y_range=tuple(args.y_range) if args.y_range else None,
z_range=tuple(args.z_range) if args.z_range else None,
min_targets=args.min_targets,
max_samples=args.max_samples,
use_roi=args.use_roi,
split=args.split,
save_types=args.save_types,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,846 @@
"""
单帧图像真值可视化脚本
用途读取一帧图像和对应的真值标签文件实现2D和3D的可视化
使用方法:
python scripts_for_gt/visualize_single_frame.py \
--image path/to/image.jpg \
--label path/to/label.txt \
--output output_dir \
--roi 704 352 \
--virtual-fx 500
示例:
python scripts_for_gt/visualize_single_frame.py \
--image /path/to/images/frame_001.jpg \
--label /path/to/labels/frame_001.txt \
--output ./gt_visualization
"""
import argparse
import json
import os
import sys
from pathlib import Path
import cv2
import numpy as np
import torch
# Add the visualization directory to path so plots_3d can be imported directly
FILE = Path(__file__).resolve()
VIS_DIR = FILE.parent
if str(VIS_DIR) not in sys.path:
sys.path.insert(0, str(VIS_DIR))
from plots_3d import (
decode_and_reconstruct_3d_box_from_target,
plot_3d_boxes_from_decoded_targets,
draw_bev_blank,
drawbev,
Annotator,
colors,
)
JSON_TYPE_NAME_TO_CLASS = {
# Vehicles — face_3d_classes [0-8]
"vehicle": 0, # generic vehicle fallback -> car
"car": 0,
"suv": 1,
"pickup": 2,
"medium_car": 3,
"van": 4,
"bus": 5,
"truck": 6,
"tanker": 6,
"large_truck": 6,
"construction_vehicle": 6,
"special_vehicle": 7,
"unknown": 8,
# Pedestrian / cyclists — complete_3d_classes [9-12]
"pedestrian": 9,
"bicyclist": 10,
"motorcyclist": 10,
"bicycle": 11,
"motorcycle": 11,
"rider": 10, # generic rider fallback -> bicyclist
"tricycle": 12,
"tricyclist": 12,
# Other objects
"traffic_sign": 13,
"wheel": 14,
"plate": 15,
"face": 16,
}
DEFAULT_CLASS_NAMES = {
0: "car", 1: "suv", 2: "pickup", 3: "medium_car", 4: "van",
5: "bus", 6: "truck", 7: "special_vehicle", 8: "unknown",
9: "pedestrian", 10: "bicyclist", 11: "bicycle", 12: "tricycle",
13: "traffic_sign", 14: "wheel", 15: "plate", 16: "face",
}
CLASS_FILTER_GROUPS = {
"vehicle": set(range(0, 9)),
"vehicles": set(range(0, 9)),
"vru": {9, 10, 11, 12},
"cyclist": {10, 11},
"cyclists": {10, 11},
"two_wheeler": {10, 11},
"two_wheelers": {10, 11},
}
def _safe_float(value):
try:
return float(value)
except (TypeError, ValueError):
return np.nan
def _map_json_label_class(obj):
type_name = str(obj.get("type_name", "")).strip().lower()
if type_name in JSON_TYPE_NAME_TO_CLASS:
return JSON_TYPE_NAME_TO_CLASS[type_name]
type_value = obj.get("type", None)
try:
type_id = int(type_value)
except (TypeError, ValueError):
return None
# Legacy coarse ids written directly into JSON (old 5-class scheme).
# 0=vehicle->car(0), 1=pedestrian->9, 2=bicycle->11, 3=rider->10, 13=tricycle->12
legacy_type_map = {0: 0, 1: 9, 2: 11, 3: 10, 13: 12}
if type_name in legacy_type_map:
return JSON_TYPE_NAME_TO_CLASS.get(type_name, legacy_type_map.get(type_id))
if type_id in legacy_type_map:
return legacy_type_map[type_id]
# Fine-grained ids from convert_json_to_json.py / convert_txt_to_json.py.
# 0=car,1=suv,2=pickup,3=medium_car,4=van,5=bus,6=truck,7=special_vehicle,8=unknown
if 0 <= type_id <= 8:
return type_id
if type_id == 9: return 9 # pedestrian
if type_id == 10: return 10 # bicyclist / motorcyclist
if type_id == 11: return 11 # bicycle / motorcycle
if type_id == 12: return 12 # tricycle
return None
def _xyxy_to_xywhn(box2d, img_width, img_height):
x1, y1, x2, y2 = [_safe_float(v) for v in box2d[:4]]
return np.array([
(x1 + x2) * 0.5 / img_width,
(y1 + y2) * 0.5 / img_height,
(x2 - x1) / img_width,
(y2 - y1) / img_height,
], dtype=np.float32)
def _normalize_uv(x_px, y_px, img_width, img_height):
return x_px / img_width, y_px / img_height
def _iter_json_objects(payload):
if isinstance(payload, dict):
if all(isinstance(value, dict) for value in payload.values()):
return payload.values()
if isinstance(payload.get("objects"), list):
return payload["objects"]
return []
if isinstance(payload, list):
return payload
return []
def _load_json_label_file(label_path, image_size=None):
"""读取 labels_json 文件并转换为可视化脚本使用的47维标签格式。"""
if image_size is None:
raise ValueError("JSON 标签需要提供 image_size=(width, height) 以便将 box2d 像素坐标转换为归一化坐标")
img_width, img_height = image_size
if img_width <= 0 or img_height <= 0:
raise ValueError(f"无效的 image_size: {image_size}")
with open(label_path, "r", encoding="utf-8") as f:
payload = json.load(f)
labels = []
for obj in _iter_json_objects(payload):
cls = _map_json_label_class(obj)
if cls is None:
continue
box2d = obj.get("box2d") or []
if len(box2d) < 4:
continue
temp = np.full(47, np.nan, dtype=np.float32)
temp[0] = cls
temp[1:5] = _xyxy_to_xywhn(box2d, img_width, img_height)
ori_3d = obj.get("3d_ori") or []
ori_values = [_safe_float(v) for v in ori_3d]
if len(ori_values) >= 12 and ori_values[0] != -1.0:
temp[5:12] = np.array(ori_values[:7], dtype=np.float32)
temp[12], temp[13] = _normalize_uv(ori_values[7], ori_values[8], img_width, img_height)
temp[14] = ori_values[11]
for face_name, offset in (("3d_front", 15), ("3d_back", 23), ("3d_left", 31), ("3d_right", 39)):
face_values = [_safe_float(v) for v in (obj.get(face_name) or [])]
if len(face_values) < 8:
continue
temp[offset:offset + 4] = np.array(face_values[:4], dtype=np.float32)
if face_values[0] == -1.0:
temp[offset + 4] = -1.0
temp[offset + 5] = -1.0
else:
temp[offset + 4], temp[offset + 5] = _normalize_uv(face_values[4], face_values[5], img_width, img_height)
temp[offset + 6] = face_values[6]
temp[offset + 7] = face_values[7]
labels.append(temp)
if len(labels) > 0:
return np.stack(labels, axis=0)
return np.empty((0, 47), dtype=np.float32)
def load_label_file(label_path, image_size=None):
"""读取标签文件返回47维的标签数组。
标签格式参考:
- txt: 6/18/50列格式
- json: labels_json 目录下的逐帧 JSON 标签
Returns:
np.ndarray: (N, 47) 标签数组
"""
label_path = Path(label_path)
if label_path.suffix.lower() == ".json":
return _load_json_label_file(label_path, image_size=image_size)
labels = []
with open(label_path, 'r', encoding='utf-8') as f:
lines = f.read().strip().splitlines()
for line in lines:
line = line.strip().split(' ')
line = line[:-1] # 移除最后一列(通常是换行符)
if len(line) == 6: # 仅2D bbox
temp = np.full(47, np.nan, dtype=np.float32)
temp[:6] = np.array([float(x) for x in line[:6]], np.float32)
labels.append(temp)
elif len(line) == 18 and int(float(line[0])) in [1, 2, 3]: # 行人、自行车、骑手
temp = np.full(47, np.nan, dtype=np.float32)
useful_gt_info = [float(line[x]) for x in (list(range(14)) + [16])]
temp[0:15] = np.array(useful_gt_info, np.float32)
labels.append(temp)
elif len(line) == 50 and int(float(line[0])) in [0, 13]: # 车辆、三轮车(带面信息)
used_gt_ = [float(line[x]) for x in (list(range(14)) + [16])]
temp = used_gt_
temp.extend([float(x) for x in line[18:]])
labels.append(np.array(temp, np.float32))
else:
print(f"警告:跳过无效标签格式,长度={len(line)}")
if len(labels) > 0:
labels = np.stack(labels, axis=0)
assert labels.shape[1] == 47, f"标签应为47列实际为{labels.shape[1]}"
else:
labels = np.empty((0, 47), dtype=np.float32)
return labels
def resolve_target_class_ids(target_classes, names=None):
"""解析目标类别支持类别名、类别ID、逗号分隔和常见类别组。"""
if not target_classes:
return None
if isinstance(target_classes, str):
target_classes = [target_classes]
names = DEFAULT_CLASS_NAMES if names is None else names
name_to_id = {str(name).strip().lower(): cls for cls, name in names.items()}
alias_to_ids = {name: {cls} for name, cls in JSON_TYPE_NAME_TO_CLASS.items()}
alias_to_ids.update(CLASS_FILTER_GROUPS)
resolved_ids = set()
unknown_classes = []
for raw_value in target_classes:
for class_token in str(raw_value).split(","):
class_name = class_token.strip().lower()
if not class_name:
continue
if class_name in name_to_id:
resolved_ids.add(name_to_id[class_name])
continue
if class_name in alias_to_ids:
resolved_ids.update(alias_to_ids[class_name])
continue
try:
resolved_ids.add(int(class_name))
except ValueError:
unknown_classes.append(class_name)
if unknown_classes:
available_classes = ", ".join(str(name) for name in names.values())
raise ValueError(
f"未知类别: {', '.join(sorted(set(unknown_classes)))}"
f"可用类别: {available_classes}"
)
return resolved_ids or None
def format_target_classes(target_class_ids, names=None):
"""将类别ID集合格式化为便于打印的名称列表。"""
if not target_class_ids:
return "all"
names = DEFAULT_CLASS_NAMES if names is None else names
return ", ".join(names.get(cls_id, str(cls_id)) for cls_id in sorted(target_class_ids))
def filter_labels_by_class(labels, target_class_ids):
"""按类别过滤标签。"""
if target_class_ids is None or len(labels) == 0:
return labels
valid_mask = ~np.isnan(labels[:, 0])
class_mask = np.zeros(len(labels), dtype=bool)
class_mask[valid_mask] = np.isin(labels[valid_mask, 0].astype(np.int32), sorted(target_class_ids))
return labels[class_mask]
def load_calibration(calib_path):
"""读取相机标定文件
Args:
calib_path: camera4.json 路径
Returns:
dict: 标定参数字典
"""
with open(calib_path, 'r') as f:
calib_params = json.load(f)
return calib_params
def apply_roi_transform(img, labels, calib_params, roi_size, virtual_fx, ori_img_size):
"""应用ROI变换裁剪图像和调整标签
Args:
img: 原始图像 (H, W, 3) BGR
labels: 标签数组 (N, 47)
calib_params: 标定参数字典
roi_size: ROI 尺寸 (width, height)
virtual_fx: 虚拟焦距
ori_img_size: 原始图像尺寸 (width, height)
Returns:
img_roi: 裁剪后的图像
labels_roi: 调整后的标签
calib_roi: ROI 标定参数
"""
oriW, oriH = ori_img_size
roi_w, roi_h = roi_size
# 获取相机参数
fx = calib_params['focal_u']
fy = calib_params['focal_v']
cx = calib_params['cu']
cy = calib_params['cv']
c_pitch = calib_params['pitch']
# 计算消失点和裁剪中心
vanish_y = cy - fy * np.tan(c_pitch * np.pi / 180)
crop_center_x = oriW // 2
crop_center_y = vanish_y
# 计算ROI边界
roi_x1 = int(crop_center_x - roi_w / 2.0)
roi_y1 = int(crop_center_y - roi_h / 2.0)
roi_x2 = roi_x1 + roi_w
roi_y2 = roi_y1 + roi_h
# 裁剪图像
img_roi = img[roi_y1:roi_y2, roi_x1:roi_x2, :]
# 调整标签
labels_roi = post_process_labels_to_roi(
labels.copy(), oriW, oriH, roi_x1, roi_y1, roi_x2, roi_y2
)
# 计算缩放比例如果需要resize到特定大小
# 这里假设不需要额外resizeROI大小即为目标大小
scale_x = 1.0
scale_y = 1.0
# 计算ROI标定参数
calib_roi = {
'fx': fx * scale_x,
'fy': fy * scale_y,
'cx': (cx - roi_x1) * scale_x,
'cy': (cy - roi_y1) * scale_y,
'distort_coeffs': calib_params.get('distort_coeffs', []),
'target_fx': virtual_fx,
'depth_scale': fx / virtual_fx, # 用于将归一化深度转换回原始尺度
}
# 调整z3d深度值深度归一化
if len(labels_roi) > 0:
scale = virtual_fx / calib_roi['fx']
labels_roi = scale_z3d(labels_roi, scale)
return img_roi, labels_roi, calib_roi
def post_process_labels_to_roi(labels, oriW, oriH, roi_x1, roi_y1, roi_x2, roi_y2):
"""将标签从原始图像坐标转换到ROI坐标"""
if len(labels) == 0:
return labels
roi_width = roi_x2 - roi_x1
roi_height = roi_y2 - roi_y1
# 将归一化的xywh转换为绝对坐标
boxes = xywhn2xyxy(labels[:, 1:5], oriW, oriH, padw=0, padh=0)
# 转换到ROI相对坐标
new_x1 = boxes[:, 0] - roi_x1
new_y1 = boxes[:, 1] - roi_y1
new_x2 = boxes[:, 2] - roi_x1
new_y2 = boxes[:, 3] - roi_y1
# 检查是否完全在ROI外
curr_outside = ((new_x1 < 0) & (new_x2 < 0)) | \
((new_x1 >= roi_width) & (new_x2 >= roi_width)) | \
((new_y1 < 0) & (new_y2 < 0)) | \
((new_y1 >= roi_height) & (new_y2 >= roi_height))
# 裁剪到ROI边界
new_x1 = np.clip(new_x1, 0, roi_width - 1)
new_y1 = np.clip(new_y1, 0, roi_height - 1)
new_x2 = np.clip(new_x2, 0, roi_width - 1)
new_y2 = np.clip(new_y2, 0, roi_height - 1)
# 更新归一化坐标
labels[:, 1] = (new_x1 + new_x2) * 0.5 / roi_width
labels[:, 2] = (new_y1 + new_y2) * 0.5 / roi_height
labels[:, 3] = (new_x2 - new_x1) / roi_width
labels[:, 4] = (new_y2 - new_y1) / roi_height
# 调整UV坐标中心点投影
# 列索引: (12,13), (19,20), (27,28), (35,36), (43,44)
for xi, yi in [(12, 13), (19, 20), (27, 28), (35, 36), (43, 44)]:
mask = ~np.isnan(labels[:, xi]) & ~np.isnan(labels[:, yi])
if mask.any():
labels[mask, xi] = (labels[mask, xi] * oriW - roi_x1) / roi_width
labels[mask, yi] = (labels[mask, yi] * oriH - roi_y1) / roi_height
# 移除完全在ROI外的框
labels = labels[~curr_outside]
return labels
def xywhn2xyxy(x, w=640, h=640, padw=0, padh=0):
"""将归一化的xywh转换为xyxy"""
y = np.copy(x)
y[:, 0] = w * (x[:, 0] - x[:, 2] / 2) + padw # x1
y[:, 1] = h * (x[:, 1] - x[:, 3] / 2) + padh # y1
y[:, 2] = w * (x[:, 0] + x[:, 2] / 2) + padw # x2
y[:, 3] = h * (x[:, 1] + x[:, 3] / 2) + padh # y2
return y
def scale_z3d(labels, scale):
"""缩放z3d深度坐标
只缩放Z坐标因为
- 投影公式u = fx * X / Z + cx
- 同时缩放fx和Z可以保持投影不变
- X和Y不需要缩放因为它们是从UV和Z恢复的
"""
if len(labels) == 0 or scale == 1.0:
return labels
labels_scaled = labels.copy()
# 整体中心z3d列7
labels_scaled[:, 7] *= scale
# 前面中心z3d列17
mask = ~np.isnan(labels_scaled[:, 17])
labels_scaled[mask, 17] *= scale
# 后面中心z3d列25
mask = ~np.isnan(labels_scaled[:, 25])
labels_scaled[mask, 25] *= scale
# 左面中心z3d列33
mask = ~np.isnan(labels_scaled[:, 33])
labels_scaled[mask, 33] *= scale
# 右面中心z3d列41
mask = ~np.isnan(labels_scaled[:, 41])
labels_scaled[mask, 41] *= scale
return labels_scaled
def plot_2d_boxes(img, labels, names=None, label_text=None, scale_factor=2):
"""绘制2D边界框
Args:
img: 图像 (H, W, 3) BGR
labels: 标签数组 (N, 47)
names: 类别名称字典
label_text: 标签文字(如"2D GT"
scale_factor: 放大倍数
Returns:
img_annotated: 带标注的图像 BGR
"""
h, w = img.shape[:2]
# 放大图像以获得更清晰的可视化
h_new = h * scale_factor
w_new = w * scale_factor
img_scaled = cv2.resize(img, (w_new, h_new), interpolation=cv2.INTER_LINEAR)
# 转换为RGB用于Annotator
img_rgb = cv2.cvtColor(img_scaled, cv2.COLOR_BGR2RGB)
# 创建标注器
fs = int((h_new + w_new) * 0.01)
annotator = Annotator(img_rgb, line_width=max(2, round(fs / 8)), font_size=fs, pil=True)
# 绘制边界
annotator.rectangle([0, 0, w_new, h_new], None, (255, 255, 255), width=2)
# 绘制每个框
for label in labels:
if np.isnan(label[0]):
continue
cls = int(label[0])
x_center, y_center, box_w, box_h = label[1:5]
# 转换为像素坐标
x1 = (x_center - box_w / 2) * w_new
y1 = (y_center - box_h / 2) * h_new
x2 = (x_center + box_w / 2) * w_new
y2 = (y_center + box_h / 2) * h_new
box = [x1, y1, x2, y2]
color = colors(cls)
# 类别名称
if names:
cls_name = names.get(cls, str(cls))
else:
cls_name = str(cls)
annotator.box_label(box, cls_name, color=color)
# 转换回BGR并添加标签文字
result = np.array(annotator.im)
result = cv2.cvtColor(result, cv2.COLOR_RGB2BGR)
if label_text:
cv2.putText(result, label_text, (10, 50), cv2.FONT_HERSHEY_SIMPLEX,
1.5, (0, 255, 255), 3, cv2.LINE_AA)
return result
def visualize_single_frame(
image_path,
label_path,
output_dir,
calib_path=None,
roi_size=None,
virtual_fx=None,
ori_img_size=None,
names=None,
save_types=None,
target_class_ids=None,
):
"""可视化单帧图像的2D和3D真值
Args:
image_path: 图像路径
label_path: 标签路径
output_dir: 输出目录
calib_path: 相机标定文件路径
roi_size: ROI尺寸 (width, height)如果为None则不应用ROI
virtual_fx: 虚拟焦距
ori_img_size: 原始图像尺寸 (width, height)
names: 类别名称字典
save_types: 保存类型列表 ['2d', '3d', 'bev', 'combined']None表示全部保存
target_class_ids: 目标类别ID集合None表示不过滤
"""
# 创建输出目录
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# 读取图像
img = cv2.imread(str(image_path))
if img is None:
raise FileNotFoundError(f"无法读取图像: {image_path}")
print(f"已读取图像: {image_path}, 尺寸: {img.shape[:2]}")
# 默认类别名称
if names is None:
names = DEFAULT_CLASS_NAMES.copy()
# 读取标签
labels = load_label_file(label_path, image_size=(img.shape[1], img.shape[0]))
print(f"已读取 {len(labels)} 个标签")
if target_class_ids is not None:
raw_label_count = len(labels)
labels = filter_labels_by_class(labels, target_class_ids)
print(
f"类别过滤后保留 {len(labels)}/{raw_label_count} 个标签: "
f"{format_target_classes(target_class_ids, names=names)}"
)
# 如果没有指定ori_img_size使用图像实际尺寸
if ori_img_size is None:
ori_img_size = (img.shape[1], img.shape[0])
# 检查是否需要应用ROI变换
apply_roi = roi_size is not None and calib_path is not None and virtual_fx is not None
if apply_roi:
# 读取标定文件
calib_params = load_calibration(calib_path)
print(f"已读取标定文件: {calib_path}")
# 应用ROI变换
img_processed, labels_processed, calib = apply_roi_transform(
img, labels, calib_params, roi_size, virtual_fx, ori_img_size
)
print(f"应用ROI变换ROI尺寸: {roi_size}")
else:
# 不应用ROI使用原始图像和标签
img_processed = img
labels_processed = labels
# 如果提供了calib_path读取标定参数
if calib_path is not None:
calib_params = load_calibration(calib_path)
calib = {
'fx': calib_params['focal_u'],
'fy': calib_params['focal_v'],
'cx': calib_params['cu'],
'cy': calib_params['cv'],
'distort_coeffs': calib_params.get('distort_coeffs', []),
'depth_scale': 1.0, # 不缩放深度
}
else:
calib = None
print("警告未提供标定文件无法进行3D可视化")
# 默认保存所有类型
if save_types is None:
save_types = ['2d', '3d', 'bev', 'combined']
output_filename = Path(image_path).stem
# ========== 2D可视化 ==========
img_2d = None
if '2d' in save_types or 'combined' in save_types:
print("\n生成2D可视化...")
img_2d = plot_2d_boxes(img_processed, labels_processed, names=names, label_text="2D GT", scale_factor=1)
# 保存2D可视化
if '2d' in save_types:
save_path_2d = output_dir / f"{output_filename}_2d_gt.jpg"
cv2.imwrite(str(save_path_2d), img_2d)
print(f"已保存2D可视化: {save_path_2d}")
# ========== 3D可视化 ==========
img_3d_bgr = None
bev_image = None
decoded_results = []
need_3d = '3d' in save_types or 'combined' in save_types
need_bev = 'bev' in save_types or 'combined' in save_types
if calib is not None and len(labels_processed) > 0 and (need_3d or need_bev):
print("\n生成3D可视化...")
# 准备图像张量BGR格式归一化到[0,1]
img_tensor = torch.from_numpy(img_processed.transpose(2, 0, 1)).float() / 255.0
img_tensor = img_tensor.unsqueeze(0) # (1, 3, H, W)
h_img, w_img = img_processed.shape[:2]
# 解码3D框
decoded_results = []
for label in labels_processed:
# 在decode函数中需要添加batch_idx列列0
target = np.concatenate([[0], label]) # (48,)
decoded = decode_and_reconstruct_3d_box_from_target(
target, calib, w_img, h_img
)
if decoded is not None:
decoded_results.append(decoded)
print(f"成功解码 {len(decoded_results)} 个3D框")
if len(decoded_results) > 0:
# 3D投影可视化
if need_3d:
img_3d = plot_3d_boxes_from_decoded_targets(
img_tensor,
[decoded_results],
[str(image_path)],
calib=[calib],
names=names,
label_text="3D GT",
scale_factor=2
)
if img_3d is not None:
# 转换为BGR
img_3d_bgr = cv2.cvtColor(img_3d, cv2.COLOR_RGB2BGR)
# 保存3D可视化
if '3d' in save_types:
save_path_3d = output_dir / f"{output_filename}_3d_gt.jpg"
cv2.imwrite(str(save_path_3d), img_3d_bgr)
print(f"已保存3D可视化: {save_path_3d}")
# BEV可视化
if need_bev:
print("\n生成BEV可视化...")
bev_image = draw_bev_blank()
for decoded in decoded_results:
if decoded and decoded.get('object_3d') is not None:
bev_image = drawbev(bev_image, decoded['object_3d'], is_pred=False)
# 保存BEV可视化
if 'bev' in save_types:
save_path_bev = output_dir / f"{output_filename}_bev_gt.jpg"
cv2.imwrite(str(save_path_bev), bev_image)
print(f"已保存BEV可视化: {save_path_bev}")
# 生成组合图像2D + 3D 上下组合,然后与 BEV 左右组合)
if 'combined' in save_types and img_2d is not None and img_3d_bgr is not None and bev_image is not None:
print("\n生成组合可视化...")
# 步骤1: 确保2D和3D宽度一致
h_2d, w_2d = img_2d.shape[:2]
h_3d, w_3d = img_3d_bgr.shape[:2]
# 以2D的宽度为准调整3D图像
if w_3d != w_2d:
h_3d_resized = int(h_3d * w_2d / w_3d)
img_3d_bgr_resized = cv2.resize(img_3d_bgr, (w_2d, h_3d_resized))
else:
img_3d_bgr_resized = img_3d_bgr
h_3d_resized = h_3d
# 步骤2: 添加垂直间隔并上下拼接2D和3D
padding_height = 20
padding_vertical = np.zeros((padding_height, w_2d, 3), dtype=np.uint8)
left_side = np.vstack([img_2d, padding_vertical, img_3d_bgr_resized])
# 步骤3: 调整BEV高度以匹配左侧总高度
h_left, w_left = left_side.shape[:2]
h_bev, w_bev = bev_image.shape[:2]
# 保持BEV的宽高比调整高度匹配左侧
w_bev_resized = int(w_bev * h_left / h_bev)
bev_image_resized = cv2.resize(bev_image, (w_bev_resized, h_left))
# 步骤4: 添加水平间隔并左右拼接
padding_width = 20
padding_horizontal = np.zeros((h_left, padding_width, 3), dtype=np.uint8)
combined = np.hstack([left_side, padding_horizontal, bev_image_resized])
save_path_combined = output_dir / f"{output_filename}_combined.jpg"
cv2.imwrite(str(save_path_combined), combined)
print(f"已保存组合可视化: {save_path_combined}")
else:
print("警告没有成功解码的3D框")
else:
if calib is None:
print("跳过3D可视化未提供标定文件")
else:
print("跳过3D可视化没有标签")
print(f"\n可视化完成!输出目录: {output_dir}")
def parse_args():
parser = argparse.ArgumentParser(description="单帧图像真值可视化脚本")
parser.add_argument("--image", type=str, required=True, help="图像路径")
parser.add_argument("--label", type=str, required=True, help="标签文件路径")
parser.add_argument("--output", type=str, default="./gt_visualization", help="输出目录")
parser.add_argument("--calib", type=str, default=None,
help="相机标定文件路径camera4.json如不提供则从图像路径推断")
parser.add_argument("--roi", type=int, nargs=2, default=None, metavar=("WIDTH", "HEIGHT"),
help="ROI尺寸宽 高),如: --roi 704 352")
parser.add_argument("--virtual-fx", type=float, default=None,
help="虚拟焦距,用于深度归一化")
parser.add_argument("--ori-img-size", type=int, nargs=2, default=None, metavar=("WIDTH", "HEIGHT"),
help="原始图像尺寸(宽 高),如不提供则从图像自动获取")
parser.add_argument("--classes", type=str, nargs="+", default=None,
help="仅可视化指定类别,支持类别名/类别ID可传多个值或逗号分隔")
args = parser.parse_args()
# 如果没有提供calib路径尝试从图像路径推断
if args.calib is None:
image_path = Path(args.image)
base = image_path.parent.parent # dataset root
# 兼容 calib/L2_calib/camera4.json 和 calib/camera4.json 两种目录结构
for calib_path in [
base / 'calib' / 'L2_calib' / 'camera4.json',
base / 'calib' / 'camera4.json',
]:
if calib_path.exists():
args.calib = str(calib_path)
print(f"自动找到标定文件: {args.calib}")
break
else:
print(f"警告:未找到标定文件(已检查 calib/L2_calib/camera4.json 和 calib/camera4.json将仅进行2D可视化")
return args
def main():
args = parse_args()
names = DEFAULT_CLASS_NAMES.copy()
target_class_ids = resolve_target_class_ids(args.classes, names=names)
visualize_single_frame(
image_path=args.image,
label_path=args.label,
output_dir=args.output,
calib_path=args.calib,
roi_size=tuple(args.roi) if args.roi else None,
virtual_fx=args.virtual_fx,
ori_img_size=tuple(args.ori_img_size) if args.ori_img_size else None,
names=names,
target_class_ids=target_class_ids,
)
if __name__ == "__main__":
main()