单目3D初始代码
This commit is contained in:
765
tools/scripts_for_gt/profiling/DATASET_ANALYSIS.md
Executable file
765
tools/scripts_for_gt/profiling/DATASET_ANALYSIS.md
Executable file
@@ -0,0 +1,765 @@
|
||||
# YOLOv5-3D 训练数据集分析报告
|
||||
|
||||
## 1. 数据集概览
|
||||
|
||||
### 基本信息
|
||||
- **数据集划分**: 训练集 (train)
|
||||
- **总图像数**: 2,638,689 张
|
||||
- **总标注对象数**: 127,878,960 个
|
||||
- **平均每张图像对象数**: 48.5 个
|
||||
- **标签完整性**: 100% (无缺失标签)
|
||||
|
||||
### 标注格式分布
|
||||
| 格式维度 | 对象数量 | 占比 |
|
||||
|---------|---------|------|
|
||||
| 6维格式 | 89,994,340 | 70.4% |
|
||||
| 50维格式 | 22,070,252 | 17.3% |
|
||||
| 18维格式 | 15,814,368 | 12.4% |
|
||||
|
||||
---
|
||||
|
||||
## 2. 类别分布与统计
|
||||
|
||||
### 2.1 整体类别分布
|
||||
|
||||
| 类别 | 对象数量 | 占比 | 3D标注数量 | 深度标注率 |
|
||||
|-----|---------|------|-----------|-----------|
|
||||
| vehicle (车辆) | 35,607,807 | 27.8% | 22,070,252 | 62.0% |
|
||||
| bicycle (自行车) | 10,658,936 | 8.3% | 6,368,673 | 59.8% |
|
||||
| pedestrian (行人) | 8,086,704 | 6.3% | 3,955,590 | 48.9% |
|
||||
| rider (骑行者) | 6,653,675 | 5.2% | 5,490,105 | 82.5% |
|
||||
| head (人头) | 14,610,038 | 11.4% | - | 0% |
|
||||
| wheel (车轮) | 12,895,577 | 10.1% | - | 0% |
|
||||
| plate (车牌) | 9,939,105 | 7.8% | - | 0% |
|
||||
| roadblock (路障) | 8,058,184 | 6.3% | - | 0% |
|
||||
| guideboard (指示牌) | 6,457,611 | 5.0% | - | 0% |
|
||||
| tl_border (交通灯边框) | 5,645,436 | 4.4% | - | 0% |
|
||||
| tsr (交通标志) | 4,957,683 | 3.9% | - | 0% |
|
||||
| tl_wick (交通灯灯芯) | 3,611,666 | 2.8% | - | 0% |
|
||||
| tl_num (交通灯数字) | 696,538 | 0.5% | - | 0% |
|
||||
|
||||
**关键发现**:
|
||||
- 数据集包含13个类别,覆盖自动驾驶场景的主要元素
|
||||
- 仅4个类别有3D标注(vehicle, bicycle, pedestrian, rider)
|
||||
- 车辆类别数据量最大,占比接近28%
|
||||
- 辅助类别(head, wheel, plate)占比约30%
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标密度分析
|
||||
|
||||
### 3.1 每张图像对象密度
|
||||
|
||||
| 统计量 | 对象数/图 | 类别数/图 |
|
||||
|-------|----------|----------|
|
||||
| 均值 | 48.5 | 7.9 |
|
||||
| 标准差 | 30.0 | 2.8 |
|
||||
| 最小值 | 1 | 1 |
|
||||
| 5%分位 | 8 | 3 |
|
||||
| 25%分位 | 25 | 6 |
|
||||
| 中位数 | 45 | 8 |
|
||||
| 75%分位 | 66 | 10 |
|
||||
| 95%分位 | 103 | 12 |
|
||||
| 最大值 | 252 | 13 |
|
||||
|
||||
**分析**:
|
||||
- 图像平均包含48.5个对象,显示场景复杂度较高
|
||||
- 中位数45个对象,说明数据分布较为均衡
|
||||
- 最密集图像包含252个对象,对检测算法提出挑战
|
||||
- 平均每张图像包含8个类别,场景多样性好
|
||||
|
||||
---
|
||||
|
||||
## 4. 2D边界框特征分析
|
||||
|
||||
### 4.1 主要交通参与者BBox统计
|
||||
|
||||
#### Vehicle (车辆)
|
||||
| 维度 | 宽度(px) | 高度(px) | 面积(px²) | 长宽比 |
|
||||
|-----|---------|---------|----------|-------|
|
||||
| 均值 | 85.4 | 73.2 | 16,238 | 1.41 |
|
||||
| 中位数 | 46 | 36 | 1,584 | 1.24 |
|
||||
| 5%-95%范围 | 10-318 | 8-295 | 98-90,168 | 0.56-2.83 |
|
||||
|
||||
#### Pedestrian (行人)
|
||||
| 维度 | 宽度(px) | 高度(px) | 面积(px²) | 长宽比 |
|
||||
|-----|---------|---------|----------|-------|
|
||||
| 均值 | 25.0 | 70.1 | 3,434 | 0.44 |
|
||||
| 中位数 | 18 | 45 | 799 | 0.38 |
|
||||
| 5%-95%范围 | 8-64 | 16-216 | 144-12,792 | 0.21-0.80 |
|
||||
|
||||
#### Bicycle (自行车)
|
||||
| 维度 | 宽度(px) | 高度(px) | 面积(px²) | 长宽比 |
|
||||
|-----|---------|---------|----------|-------|
|
||||
| 均值 | 58.4 | 77.3 | 7,135 | 0.82 |
|
||||
| 中位数 | 42 | 57 | 2,408 | 0.79 |
|
||||
| 5%-95%范围 | 16-161 | 20-222 | 357-33,600 | 0.39-1.36 |
|
||||
|
||||
#### Rider (骑行者)
|
||||
| 维度 | 宽度(px) | 高度(px) | 面积(px²) | 长宽比 |
|
||||
|-----|---------|---------|----------|-------|
|
||||
| 均值 | 45.5 | 87.2 | 7,560 | 0.55 |
|
||||
| 中位数 | 30 | 56 | 1,680 | 0.49 |
|
||||
| 5%-95%范围 | 9-135 | 18-276 | 162-36,140 | 0.34-0.94 |
|
||||
|
||||
**关键观察**:
|
||||
- 车辆BBox尺寸变化最大(标准差109.7px),反映远近距离差异
|
||||
- 行人长宽比约0.44,符合竖直站立特征
|
||||
- 自行车长宽比接近1,呈方形分布
|
||||
- 骑行者BBox比单独行人更大,符合"人+车"组合特征
|
||||
|
||||
### 4.2 辅助检测目标BBox统计
|
||||
|
||||
#### Head (人头)
|
||||
- 平均尺寸: 12×15 px
|
||||
- 中位数面积: 96 px²
|
||||
- 长宽比: 0.84 (接近圆形)
|
||||
|
||||
#### Wheel (车轮)
|
||||
- 平均尺寸: 20×52 px
|
||||
- 长宽比: 0.42 (竖直椭圆)
|
||||
- 特点: 高度明显大于宽度
|
||||
|
||||
#### Plate (车牌)
|
||||
- 平均尺寸: 28×13 px
|
||||
- 长宽比: 2.25 (横向矩形)
|
||||
- 特点: 典型的车牌横向特征
|
||||
|
||||
---
|
||||
|
||||
## 5. 3D标注深度分析
|
||||
|
||||
### 5.1 深度分布统计
|
||||
|
||||
#### Vehicle (车辆) - 22,070,252个3D标注
|
||||
| 深度范围 | 对象数量 | 占比 |
|
||||
|---------|---------|------|
|
||||
| 0-10m | 2,666,042 | 12.1% |
|
||||
| 10-20m | 3,437,999 | 15.6% |
|
||||
| 20-30m | 3,158,252 | 14.3% |
|
||||
| 30-50m | 5,195,185 | 23.5% |
|
||||
| 50-80m | 4,885,009 | 22.1% |
|
||||
| 80-120m | 2,720,248 | 12.3% |
|
||||
| >120m | 7,517 | 0.03% |
|
||||
|
||||
- **平均深度**: 41.8m
|
||||
- **中位深度**: 36.1m
|
||||
- **95%分位**: 97.6m
|
||||
- **深度范围**: -8.5m ~ 128.9m
|
||||
|
||||
#### Pedestrian (行人) - 3,955,590个3D标注
|
||||
| 深度范围 | 对象数量 | 占比 |
|
||||
|---------|---------|------|
|
||||
| 0-10m | 626,552 | 15.8% |
|
||||
| 10-20m | 1,024,844 | 25.9% |
|
||||
| 20-30m | 787,225 | 19.9% |
|
||||
| 30-50m | 955,993 | 24.2% |
|
||||
| 50-80m | 491,938 | 12.4% |
|
||||
| 80-120m | 69,038 | 1.7% |
|
||||
| >120m | 0 | 0% |
|
||||
|
||||
- **平均深度**: 28.3m
|
||||
- **中位深度**: 23.7m
|
||||
- **最大深度**: 119.9m
|
||||
|
||||
#### Bicycle (自行车) - 6,368,673个3D标注
|
||||
| 深度范围 | 对象数量 | 占比 |
|
||||
|---------|---------|------|
|
||||
| 0-10m | 1,343,418 | 21.1% |
|
||||
| 10-20m | 1,841,284 | 28.9% |
|
||||
| 20-30m | 1,363,130 | 21.4% |
|
||||
| 30-50m | 1,247,522 | 19.6% |
|
||||
| 50-80m | 521,347 | 8.2% |
|
||||
| 80-120m | 51,972 | 0.8% |
|
||||
|
||||
- **平均深度**: 24.1m
|
||||
- **中位深度**: 20.0m
|
||||
|
||||
#### Rider (骑行者) - 5,490,105个3D标注
|
||||
| 深度范围 | 对象数量 | 占比 |
|
||||
|---------|---------|------|
|
||||
| 0-10m | 776,346 | 14.1% |
|
||||
| 10-20m | 1,145,211 | 20.9% |
|
||||
| 20-30m | 896,225 | 16.3% |
|
||||
| 30-50m | 1,349,814 | 24.6% |
|
||||
| 50-80m | 990,871 | 18.0% |
|
||||
| 80-120m | 331,515 | 6.0% |
|
||||
| >120m | 123 | 0.002% |
|
||||
|
||||
- **平均深度**: 34.6m
|
||||
- **中位深度**: 29.1m
|
||||
|
||||
**深度分布特点**:
|
||||
- 车辆检测距离最远(均值41.8m),适合高速场景
|
||||
- 行人和自行车主要分布在近距离(<30m占比>60%)
|
||||
- 骑行者深度分布介于车辆和行人之间
|
||||
- 30-50m是所有类别的关键检测范围
|
||||
|
||||
---
|
||||
|
||||
## 6. 3D物体尺寸分析
|
||||
|
||||
### 6.1 真实世界物理尺寸(米)
|
||||
|
||||
#### Vehicle (车辆)
|
||||
| 维度 | 长度(m) | 高度(m) | 宽度(m) |
|
||||
|-----|--------|--------|--------|
|
||||
| 均值 | 4.89 | 1.82 | 1.99 |
|
||||
| 中位数 | 4.40 | 1.63 | 1.93 |
|
||||
| 5%-95%范围 | 4.11-8.52 | 1.50-3.29 | 1.81-2.75 |
|
||||
|
||||
**分析**:
|
||||
- 中位长度4.40m,符合轿车尺寸
|
||||
- 高度1.63m,典型轿车/SUV高度
|
||||
- 长度95%分位8.52m,包含货车/客车
|
||||
|
||||
#### Pedestrian (行人)
|
||||
| 维度 | 长度(m) | 高度(m) | 宽度(m) |
|
||||
|-----|--------|--------|--------|
|
||||
| 均值 | 0.63 | 1.67 | 0.66 |
|
||||
| 中位数 | 0.62 | 1.68 | 0.65 |
|
||||
| 标准差 | 0.03 | 0.05 | 0.04 |
|
||||
|
||||
**分析**:
|
||||
- 高度1.67m,符合成人平均身高
|
||||
- 尺寸标准差极小,标注一致性好
|
||||
|
||||
#### Bicycle (自行车)
|
||||
| 维度 | 长度(m) | 高度(m) | 宽度(m) |
|
||||
|-----|--------|--------|--------|
|
||||
| 均值 | 1.60 | 1.20 | 0.66 |
|
||||
| 中位数 | 1.59 | 1.18 | 0.65 |
|
||||
|
||||
**分析**:
|
||||
- 长度1.60m,典型自行车尺寸
|
||||
- 宽度0.66m,标准车把宽度
|
||||
|
||||
#### Rider (骑行者)
|
||||
| 维度 | 长度(m) | 高度(m) | 宽度(m) |
|
||||
|-----|--------|--------|--------|
|
||||
| 均值 | 1.74 | 1.57 | 0.83 |
|
||||
| 中位数 | 1.73 | 1.58 | 0.83 |
|
||||
|
||||
**分析**:
|
||||
- 比自行车更高(1.57m vs 1.20m)
|
||||
- 宽度更大(0.83m vs 0.66m),包含人体
|
||||
|
||||
---
|
||||
|
||||
## 7. 姿态与朝向分析
|
||||
|
||||
### 7.1 航向角(rot_y)分布
|
||||
|
||||
| 类别 | 均值(rad) | 标准差 | 中位数 |
|
||||
|-----|----------|-------|-------|
|
||||
| Vehicle | -0.34 | 1.69 | -1.49 |
|
||||
| Pedestrian | -0.07 | 1.79 | -0.11 |
|
||||
| Bicycle | -0.16 | 2.10 | -0.03 |
|
||||
| Rider | -0.34 | 1.69 | -1.39 |
|
||||
|
||||
**解读**:
|
||||
- 所有类别均值接近0,朝向分布较为均衡
|
||||
- 标准差较大(~1.7-2.1),覆盖各个方向
|
||||
- 车辆和骑行者中位数-1.4rad,可能反映主要交通流向
|
||||
|
||||
### 7.2 观测角(alpha)分布
|
||||
|
||||
| 类别 | 均值(rad) | 标准差 | 中位数 |
|
||||
|-----|----------|-------|-------|
|
||||
| Vehicle | -0.30 | 1.76 | -1.24 |
|
||||
| Pedestrian | -0.10 | 1.77 | -0.30 |
|
||||
| Bicycle | 0.37 | 1.96 | 0.44 |
|
||||
| Rider | -0.28 | 1.81 | -1.29 |
|
||||
|
||||
### 7.3 横向位置(lateral_x3d)分布
|
||||
|
||||
| 类别 | 均值(m) | 标准差(m) | 中位数(m) |
|
||||
|-----|--------|----------|----------|
|
||||
| Vehicle | -2.56 | 13.21 | -2.56 |
|
||||
| Pedestrian | 0.10 | 12.35 | 3.11 |
|
||||
| Bicycle | 0.08 | 11.66 | 2.80 |
|
||||
| Rider | -0.87 | 11.47 | 0.78 |
|
||||
|
||||
**分析**:
|
||||
- 车辆中位数-2.56m,偏向左侧(可能为对向车道)
|
||||
- 行人和自行车中位数为正,偏向右侧(路边)
|
||||
- 标准差10-13m,横向分布较广
|
||||
|
||||
---
|
||||
|
||||
## 8. 车辆面可见性分析
|
||||
|
||||
### 8.1 面可见性统计(仅车辆类别)
|
||||
|
||||
| 面向 | 可见率 | 平均分数 | 中位分数 |
|
||||
|-----|-------|---------|---------|
|
||||
| 前面 (front) | 93.2% | 0.318 | 0.000 |
|
||||
| 后面 (rear) | 90.8% | 0.510 | 0.698 |
|
||||
| 左侧 (left) | 68.1% | 0.241 | 0.145 |
|
||||
| 右侧 (right) | 68.1% | 0.148 | 0.000 |
|
||||
|
||||
**关键发现**:
|
||||
- 前后面可见率>90%,侧面可见率~68%
|
||||
- 后面平均得分最高(0.510),可能因尾灯、车牌更易识别
|
||||
- 右侧得分最低(0.148),可能受摄像头安装位置影响
|
||||
- 中位分数显示: 后面>左侧>前面≈右侧
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据质量评估
|
||||
|
||||
### 9.1 Cut类型分布
|
||||
|
||||
| 类型 | 对象数量 | 占比 |
|
||||
|-----|---------|------|
|
||||
| noncut (完整) | 20,252,105 | 91.6% |
|
||||
| cut_in (切入) | 1,175,262 | 5.3% |
|
||||
| cut_out (切出) | 642,885 | 2.9% |
|
||||
|
||||
**总计**: 22,070,252个有3D标注的对象
|
||||
|
||||
### 9.2 数据质量问题
|
||||
|
||||
| 问题类型 | 数量 | 占比 |
|
||||
|---------|-----|------|
|
||||
| 缺失3D标注 | 23,122,502 | 18.1% |
|
||||
| 深度≤0(无效) | 19,236 | 0.02% |
|
||||
| 深度>200m(异常) | 0 | 0% |
|
||||
| BBox<8px(过小) | 21,474,341 | 16.8% |
|
||||
|
||||
**质量评估**:
|
||||
- ✅ **优秀**: 无超远距离异常值(>200m)
|
||||
- ✅ **良好**: 负深度值极少(19,236个,0.02%)
|
||||
- ⚠️ **注意**: 18.1%对象缺失3D标注(主要为辅助类别)
|
||||
- ⚠️ **注意**: 16.8%对象BBox过小(<8px),可能影响训练
|
||||
|
||||
---
|
||||
|
||||
## 10. 数据集特点总结
|
||||
|
||||
### 10.1 优势
|
||||
1. **数据规模大**: 260万+图像,1.28亿+标注对象
|
||||
2. **场景复杂**: 平均每图48.5个对象,涵盖13个类别
|
||||
3. **标注完整**: 100%图像有标签,无缺失
|
||||
4. **深度覆盖广**: 车辆0-120m全范围覆盖
|
||||
5. **3D一致性好**: 物体尺寸标准差小,标注质量高
|
||||
6. **朝向分布均衡**: 各个方向角度覆盖完整
|
||||
|
||||
### 10.2 局限性
|
||||
1. **3D标注不全**: 仅4/13类别有3D标注
|
||||
2. **小目标多**: 16.8%的BBox<8px,可能影响检测精度
|
||||
3. **类别不平衡**: 车辆占28%,某些类别<1%
|
||||
4. **切边对象**: 8.2%的对象存在cut_in/cut_out情况
|
||||
|
||||
### 10.3 应用建议
|
||||
|
||||
#### 模型训练
|
||||
- **多尺度训练**: 应对10px-1900px的尺寸变化
|
||||
- **深度分段策略**:
|
||||
- 近距离(<30m): 重点优化精度
|
||||
- 中距离(30-80m): 平衡精度与召回
|
||||
- 远距离(>80m): 提升召回率
|
||||
- **数据增强**: 针对小目标(<8px)加强
|
||||
- **类别平衡**: 对低频类别采用重采样
|
||||
|
||||
#### 评估指标
|
||||
- 按深度分段评估AP
|
||||
- 按对象尺寸分段评估
|
||||
- 重点关注30-50m范围性能
|
||||
|
||||
#### 特殊处理
|
||||
- Cut对象(8.2%): 建立专门评估集
|
||||
- 遮挡处理: 利用面可见性分数
|
||||
- 小目标: 考虑FPN或多尺度融合
|
||||
|
||||
---
|
||||
|
||||
## 11. 数据集扩充建议
|
||||
|
||||
基于当前数据集的统计分析,针对模型性能提升和场景覆盖完整性,提出以下数据扩充建议:
|
||||
|
||||
### 11.1 类别均衡优化
|
||||
|
||||
#### 🎯 优先级1:低频类别数据增强
|
||||
|
||||
当前类别分布极不均衡,建议扩充以下低频类别:
|
||||
|
||||
| 类别 | 当前数量 | 占比 | 建议目标 | 扩充倍数 |
|
||||
|-----|---------|------|---------|---------|
|
||||
| tl_num (交通灯数字) | 696,538 | 0.5% | 2,000,000+ | 3x |
|
||||
| tl_wick (交通灯灯芯) | 3,611,666 | 2.8% | 6,000,000+ | 1.7x |
|
||||
| tsr (交通标志) | 4,957,683 | 3.9% | 8,000,000+ | 1.6x |
|
||||
| guideboard (指示牌) | 6,457,611 | 5.0% | 10,000,000+ | 1.5x |
|
||||
|
||||
**扩充策略**:
|
||||
- 专门收集城市路口、高速公路、园区等交通信号设施密集场景
|
||||
- 增加夜间、雨天等特殊光照条件下的交通灯数据
|
||||
- 补充不同类型交通标志(限速、禁令、警告等)
|
||||
- 采集不同语言、格式的指示牌样本
|
||||
|
||||
#### 🎯 优先级2:次要类别定向采集
|
||||
|
||||
| 类别 | 当前数量 | 建议扩充场景 |
|
||||
|-----|---------|------------|
|
||||
| tl_border | 5,645,436 | 复杂路口、多车道场景 |
|
||||
| roadblock | 8,058,184 | 施工区域、临时路障、交通管制 |
|
||||
| head | 14,610,038 | 密集人群、学校区域、商圈 |
|
||||
| wheel | 12,895,577 | 大型车辆(货车、客车)轮胎 |
|
||||
| plate | 9,939,105 | 不同国家/地区车牌样式 |
|
||||
|
||||
### 11.2 3D标注扩充计划
|
||||
|
||||
#### 🎯 优先级1:当前有3D标注类别的深度补充
|
||||
|
||||
**Vehicle (车辆)**
|
||||
```
|
||||
当前问题:
|
||||
- 超近距离(<5m): 仅占3.6% → 建议扩充到10%
|
||||
- 超远距离(>80m): 仅占12.3% → 建议特别关注80-120m范围
|
||||
|
||||
扩充场景:
|
||||
✓ 停车场近距离场景(1-5m)
|
||||
✓ 高速公路远距离场景(80-150m)
|
||||
✓ 大型车辆(货车、客车、工程车)
|
||||
✓ 特殊车辆(摩托车、三轮车、农用车)
|
||||
```
|
||||
|
||||
**Pedestrian (行人)**
|
||||
```
|
||||
当前问题:
|
||||
- 深度>80m的行人仅69,038个(1.7%)
|
||||
- 缺少远距离、小尺寸行人样本
|
||||
|
||||
扩充场景:
|
||||
✓ 远距离行人(50-100m)
|
||||
✓ 特殊姿态(蹲、躺、骑)
|
||||
✓ 不同年龄段(儿童、老人)
|
||||
✓ 携带大型物品的行人
|
||||
```
|
||||
|
||||
**Bicycle & Rider (自行车与骑行者)**
|
||||
```
|
||||
当前问题:
|
||||
- 深度>50m样本较少(bicycle: 8.2%, rider: 24.0%)
|
||||
- Rider的3D标注率82.5%,但Bicycle仅59.8%
|
||||
|
||||
扩充需求:
|
||||
✓ 平衡Bicycle和Rider的3D标注率
|
||||
✓ 增加电动车、滑板车等新型交通工具
|
||||
✓ 补充远距离骑行样本
|
||||
```
|
||||
|
||||
#### 🎯 优先级2:新类别3D标注添加
|
||||
|
||||
建议为以下类别补充3D标注(按优先级排序):
|
||||
|
||||
1. **roadblock (路障)** - 8,058,184个对象
|
||||
- 重要性:影响路径规划和避障决策
|
||||
- 3D信息需求:位置、高度、宽度
|
||||
- 建议标注率:50%以上
|
||||
|
||||
2. **guideboard (指示牌)** - 6,457,611个对象
|
||||
- 重要性:导航和车道保持
|
||||
- 3D信息需求:位置、朝向、高度
|
||||
- 建议标注率:30%以上
|
||||
|
||||
3. **tsr (交通标志)** - 4,957,683个对象
|
||||
- 重要性:合规驾驶决策
|
||||
- 3D信息需求:距离、位置
|
||||
- 建议标注率:40%以上
|
||||
|
||||
### 11.3 深度分布优化
|
||||
|
||||
#### 各类别深度扩充建议
|
||||
|
||||
```
|
||||
Vehicle:
|
||||
├─ 0-10m: 12.1% → 目标15% (+77.8万样本)
|
||||
├─ 10-20m: 15.6% → ✓ 已充足
|
||||
├─ 20-30m: 14.3% → ✓ 已充足
|
||||
├─ 30-50m: 23.5% → ✓ 已充足(主要区域)
|
||||
├─ 50-80m: 22.1% → ✓ 已充足
|
||||
└─ 80-120m: 12.3% → 目标15% (+63.1万样本)
|
||||
|
||||
Pedestrian:
|
||||
├─ 0-10m: 15.8% → ✓ 已充足
|
||||
├─ 10-20m: 25.9% → ✓ 已充足(主要区域)
|
||||
├─ 20-30m: 19.9% → ✓ 已充足
|
||||
├─ 30-50m: 24.2% → ✓ 已充足
|
||||
└─ 50-80m: 12.4% → 目标18% (+21.8万样本)
|
||||
80-120m: 1.7% → 目标5% (+13.1万样本)
|
||||
|
||||
Bicycle:
|
||||
├─ 0-10m: 21.1% → ✓ 已充足
|
||||
├─ 10-20m: 28.9% → ✓ 已充足(主要区域)
|
||||
└─ 50-120m: 9.0% → 目标15% (+38.2万样本)
|
||||
|
||||
Rider:
|
||||
├─ 各区域分布相对均衡 ✓
|
||||
└─ 建议维持当前分布比例
|
||||
```
|
||||
|
||||
### 11.4 小目标问题解决方案
|
||||
|
||||
**当前情况**:21,474,341个对象(16.8%)的BBox面积<8px
|
||||
|
||||
#### 策略A:高分辨率数据采集
|
||||
- 当前可能分辨率:1920×1080或更低
|
||||
- 建议扩充:2K (2560×1440) 或 4K (3840×2160)
|
||||
- 目标:将<8px占比降至10%以下
|
||||
|
||||
#### 策略B:近距离场景补充
|
||||
```
|
||||
重点采集场景:
|
||||
✓ 停车场近距离场景(1-5m)
|
||||
✓ 低速城区道路(<40km/h)
|
||||
✓ 拥堵路况(车辆密集,近距离)
|
||||
✓ 人行道近距离行人
|
||||
```
|
||||
|
||||
#### 策略C:数据增强策略
|
||||
```python
|
||||
针对小目标的训练增强:
|
||||
- Copy-Paste小目标增强
|
||||
- Mosaic数据增强倾斜(更多小目标)
|
||||
- 多尺度训练权重调整
|
||||
- FPN/PANET特征融合优化
|
||||
```
|
||||
|
||||
### 11.5 场景标签补充建议
|
||||
|
||||
#### 建议添加的场景元数据标签
|
||||
|
||||
**1. 天气条件** (当前缺失)
|
||||
```yaml
|
||||
weather:
|
||||
- sunny: 晴天
|
||||
- cloudy: 多云
|
||||
- rainy: 雨天 (小雨/中雨/大雨)
|
||||
- foggy: 雾天
|
||||
- snowy: 雪天
|
||||
|
||||
优先扩充:
|
||||
- 雨天数据(建议占比20-30%)
|
||||
- 雾天数据(建议占比5-10%)
|
||||
- 夜间降雨组合场景
|
||||
```
|
||||
|
||||
**2. 光照条件** (当前缺失)
|
||||
```yaml
|
||||
lighting:
|
||||
- daytime: 白天
|
||||
- morning: 早晨 (6:00-10:00)
|
||||
- noon: 中午 (10:00-16:00)
|
||||
- afternoon: 下午 (16:00-19:00)
|
||||
- nighttime: 夜间
|
||||
- dusk: 黄昏 (19:00-21:00)
|
||||
- night: 深夜 (21:00-6:00)
|
||||
- tunnel: 隧道
|
||||
|
||||
优先扩充:
|
||||
- 夜间数据(建议占比30-40%)
|
||||
- 黄昏/黎明过渡时段(建议占比10%)
|
||||
- 隧道进出场景(建议占比5%)
|
||||
```
|
||||
|
||||
**3. 道路类型** (当前缺失)
|
||||
```yaml
|
||||
road_type:
|
||||
- highway: 高速公路
|
||||
- urban_road: 城市道路
|
||||
- suburb_road: 郊区道路
|
||||
- parking_lot: 停车场
|
||||
- residential: 住宅区
|
||||
- industrial: 工业区
|
||||
|
||||
优先扩充:
|
||||
- 停车场场景(近距离、多角度)
|
||||
- 住宅区(复杂交通参与者)
|
||||
- 高速公路(高速、远距离)
|
||||
```
|
||||
|
||||
**4. 交通密度** (当前缺失)
|
||||
```yaml
|
||||
traffic_density:
|
||||
- sparse: 稀疏 (<10 objects/image)
|
||||
- medium: 中等 (10-50 objects/image)
|
||||
- dense: 密集 (50-100 objects/image)
|
||||
- jam: 拥堵 (>100 objects/image)
|
||||
|
||||
当前分布:
|
||||
- sparse: ~5%分位 (8 objects)
|
||||
- medium: 25-75分位 (25-66 objects)
|
||||
- dense: 75-95分位 (66-103 objects)
|
||||
- jam: >95分位 (>103 objects)
|
||||
|
||||
建议:增加极端场景
|
||||
- 空旷场景(目标<5)
|
||||
- 极度拥堵(目标>150)
|
||||
```
|
||||
|
||||
**5. 遮挡程度** (当前缺失)
|
||||
```yaml
|
||||
occlusion_level:
|
||||
- none: 无遮挡 (0-10%)
|
||||
- partial: 部分遮挡 (10-50%)
|
||||
- heavy: 严重遮挡 (50-80%)
|
||||
- extreme: 极度遮挡 (>80%)
|
||||
|
||||
建议采集:
|
||||
- 多车并行场景(侧面遮挡)
|
||||
- 大车遮挡小车/行人
|
||||
- 护栏、植被遮挡
|
||||
- 雨刷、车身部分遮挡
|
||||
```
|
||||
|
||||
**6. 特殊场景标签**
|
||||
```yaml
|
||||
special_scenarios:
|
||||
- construction_zone: 施工区域
|
||||
- accident_scene: 事故现场
|
||||
- school_zone: 学校区域
|
||||
- crosswalk: 人行横道
|
||||
- intersection: 十字路口
|
||||
- roundabout: 环岛
|
||||
- merge_lane: 汇入车道
|
||||
- toll_station: 收费站
|
||||
|
||||
优先扩充:
|
||||
- 施工区域(路障、锥桶)
|
||||
- 学校区域(儿童、家长、慢速)
|
||||
- 复杂路口(多方向、多目标)
|
||||
```
|
||||
|
||||
### 11.6 边缘情况与困难样本
|
||||
|
||||
#### Cut对象扩充(当前8.2%)
|
||||
|
||||
```
|
||||
当前分布:
|
||||
- noncut: 91.6% (20,252,105)
|
||||
- cut_in: 5.3% (1,175,262)
|
||||
- cut_out: 2.9% (642,885)
|
||||
|
||||
建议目标:
|
||||
- cut_in: 扩充到8-10% (+60万样本)
|
||||
- cut_out: 扩充到5-6% (+45万样本)
|
||||
|
||||
采集重点:
|
||||
✓ 边缘车辆突然切入
|
||||
✓ 部分在frame外的行人
|
||||
✓ 图像边缘的骑行者
|
||||
✓ 半身出现的大型车辆
|
||||
```
|
||||
|
||||
#### 困难样本专项采集
|
||||
|
||||
**1. 遮挡场景**
|
||||
- 车辆互相遮挡(并排行驶)
|
||||
- 大型车辆遮挡后方小车
|
||||
- 护栏、树木遮挡
|
||||
- 行人/骑行者被车辆遮挡
|
||||
|
||||
**2. 极端光照**
|
||||
- 强逆光(太阳直射镜头)
|
||||
- 夜间远光灯眩光
|
||||
- 隧道进出口光线突变
|
||||
- 玻璃反射/水面倒影
|
||||
|
||||
**3. 相似目标混淆**
|
||||
- 骑行者 vs 电动车骑手
|
||||
- 自行车 vs 摩托车
|
||||
- 小客车 vs SUV
|
||||
- 行人 vs 路边广告牌人像
|
||||
|
||||
**4. 特殊姿态与状态**
|
||||
- 侧翻/事故车辆
|
||||
- 蹲坐/倚靠的行人
|
||||
- 推行自行车的人
|
||||
- 多人拥挤场景
|
||||
|
||||
### 11.7 数据采集优先级矩阵
|
||||
|
||||
| 优先级 | 扩充内容 | 预期样本量 | 预期效果 |
|
||||
|-------|---------|-----------|---------|
|
||||
| ⭐⭐⭐⭐⭐ | 夜间数据 | +80万图像 | 提升夜间AP 15-20% |
|
||||
| ⭐⭐⭐⭐⭐ | 雨天/雾天数据 | +50万图像 | 提升恶劣天气鲁棒性 |
|
||||
| ⭐⭐⭐⭐⭐ | 交通灯/标志3D标注 | +500万3D标注 | 提升决策可靠性 |
|
||||
| ⭐⭐⭐⭐ | 远距离样本(>80m) | +100万对象 | 提升远距离召回率 |
|
||||
| ⭐⭐⭐⭐ | 近距离样本(<5m) | +80万对象 | 提升近距离精度 |
|
||||
| ⭐⭐⭐⭐ | 低频类别均衡 | +300万对象 | 平衡各类别性能 |
|
||||
| ⭐⭐⭐ | 遮挡场景 | +30万图像 | 提升遮挡处理能力 |
|
||||
| ⭐⭐⭐ | 边缘Cut场景 | +100万对象 | 提升边缘检测 |
|
||||
| ⭐⭐ | 高分辨率数据 | +20万图像 | 改善小目标检测 |
|
||||
| ⭐⭐ | 特殊场景标签 | 全量标注 | 支持场景自适应 |
|
||||
|
||||
### 11.8 数据采集实施计划
|
||||
|
||||
#### 阶段1:短期扩充(1-3个月)
|
||||
```
|
||||
目标:解决最紧迫问题
|
||||
1. 夜间数据采集(80万图像)
|
||||
2. 低频类别定向采集(200万对象)
|
||||
3. 远近距离补充(100万对象)
|
||||
4. 基础场景标签标注(100万图像)
|
||||
|
||||
预期提升:整体mAP +3-5%
|
||||
```
|
||||
|
||||
#### 阶段2:中期优化(3-6个月)
|
||||
```
|
||||
目标:全面质量提升
|
||||
1. 恶劣天气数据(50万图像)
|
||||
2. 3D标注扩展到新类别(500万标注)
|
||||
3. 困难样本专项采集(30万图像)
|
||||
4. 完整场景元数据标注(全量)
|
||||
|
||||
预期提升:整体mAP +5-8%
|
||||
```
|
||||
|
||||
#### 阶段3:长期完善(6-12个月)
|
||||
```
|
||||
目标:构建世界级数据集
|
||||
1. 边缘场景覆盖(corner cases)
|
||||
2. 多地域、多国家数据
|
||||
3. 高分辨率升级(4K)
|
||||
4. 时序数据采集(视频片段)
|
||||
|
||||
预期提升:整体mAP +8-12%
|
||||
```
|
||||
|
||||
### 11.9 数据质量控制建议
|
||||
|
||||
#### 采集质量标准
|
||||
```yaml
|
||||
硬件要求:
|
||||
- 分辨率: ≥1920×1080 (建议2K)
|
||||
- 帧率: ≥15 FPS
|
||||
- 动态范围: HDR优先
|
||||
- 镜头畸变: <5%
|
||||
|
||||
标注质量标准:
|
||||
- BBox IoU一致性: >0.85
|
||||
- 3D深度误差: <10%
|
||||
- 朝向角误差: <15°
|
||||
- 类别标注准确率: >98%
|
||||
```
|
||||
|
||||
#### 质量验证流程
|
||||
1. 自动化检查(异常值、格式错误)
|
||||
2. 随机抽样人工复核(5-10%)
|
||||
3. 交叉验证(不同标注员)
|
||||
4. 模型验证(训练效果验证)
|
||||
|
||||
---
|
||||
|
||||
## 12. 数据统计来源
|
||||
|
||||
- **分析模式**: original
|
||||
- **统计时间**: 基于完整训练集
|
||||
- **3D标注格式**: 包含深度、尺寸、朝向、位置等信息
|
||||
- **统计方法**: 基于百分位数的鲁棒统计
|
||||
|
||||
---
|
||||
|
||||
*本报告由自动化数据分析工具生成,统计数据来源于dataset_profiling_results_original/train/summary.json*
|
||||
372
tools/scripts_for_gt/profiling/DATASET_PROFILING_README.md
Executable file
372
tools/scripts_for_gt/profiling/DATASET_PROFILING_README.md
Executable file
@@ -0,0 +1,372 @@
|
||||
# Dataset Profiling — YOLOv5-3D 数据画像统计分析工具
|
||||
|
||||
## 概述
|
||||
|
||||
`dataset_profiling.py` 是一个针对 YOLOv5-3D 训练和评测数据集的综合统计分析工具,能够自动生成数据集的完整"数据画像"(Data Portrait),包含文本报告、JSON 结构化数据和可视化图表。
|
||||
|
||||
---
|
||||
|
||||
## 功能模块
|
||||
|
||||
| # | 分析模块 | 说明 |
|
||||
|---|---------|------|
|
||||
| 1 | **数据集概览** | 图片/标签数量、标签格式分布(50列/18列/6列) |
|
||||
| 2 | **类别统计** | 每类目标数量、占比、2D/3D 覆盖度 |
|
||||
| 3 | **2D 框分析** | 尺寸分布、宽高比、位置热力图 |
|
||||
| 4 | **3D 几何分析** | 深度、三维尺寸(L×H×W)、旋转角分布 |
|
||||
| 5 | **面可见性分析** | 四面可见率、置信度分布(车辆/三轮车) |
|
||||
| 6 | **截断类型分析** | cut_in / cut_out / noncut / partial_cut 分布 |
|
||||
| 7 | **图像密度分析** | 每张图像的目标数、类别共现统计 |
|
||||
| 8 | **数据质量检查** | NaN 字段、无效深度、异常深度、微小框检测 |
|
||||
| 9 | **训练/验证对比** | 类别比例、深度分布、尺寸分布的 Train vs Val 对比 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 基于原图的完整分析(默认模式)
|
||||
python dataset_profiling.py
|
||||
|
||||
# 基于 ROI 的分析(匹配训练流水线)
|
||||
python dataset_profiling.py --analysis-mode roi
|
||||
|
||||
# 仅分析验证集,限制 5000 张图
|
||||
python dataset_profiling.py --split val --max-files 5000
|
||||
|
||||
# 同时指定模式和输出目录
|
||||
python dataset_profiling.py --analysis-mode roi --split both --output-dir my_profiling
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 命令行参数
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `--data` | str | `data/mono3d.yaml` | 数据集配置 YAML 文件路径 |
|
||||
| `--split` | str | `both` | 分析哪个数据集划分:`train`、`val` 或 `both` |
|
||||
| `--max-files` | int | `0` | 每个 split 最多处理的图片数(0 = 全部) |
|
||||
| `--output-dir` | str | `dataset_profiling_results` | 输出目录(自动追加 `_original` 或 `_roi` 后缀) |
|
||||
| `--img-size` | int int | 自动 | 像素单位转换尺寸 `[宽 高]`;original 模式默认取 `ori_img_size`,roi 模式默认取 `roi` |
|
||||
| `--analysis-mode` | str | `original` | 分析模式:`original`(原图坐标系)或 `roi`(ROI 坐标系) |
|
||||
|
||||
---
|
||||
|
||||
## 分析模式详解
|
||||
|
||||
### `original` 模式(默认)
|
||||
|
||||
直接使用标签文件中的归一化坐标,基于原始图像尺寸(默认 1920×1080)转换为像素单位进行统计。不做任何 ROI 裁剪或过滤。
|
||||
|
||||
适用场景:
|
||||
- 了解原始标注数据的整体分布
|
||||
- 检查标注质量和覆盖范围
|
||||
- 不依赖标定参数的快速分析
|
||||
|
||||
### `roi` 模式
|
||||
|
||||
模拟训练流水线的 ROI 处理逻辑,从标定文件计算每个相机的实际 ROI 区域,对标签进行过滤和截断:
|
||||
|
||||
```
|
||||
1. 读取相机标定参数(camera4.json)
|
||||
2. 计算消失点:vanish_y = cv - fy × tan(pitch × π/180)
|
||||
3. 计算 ROI 边界:以 (oriW/2, vanish_y) 为中心裁剪 roi_size 区域
|
||||
4. 过滤完全在 ROI 外的目标
|
||||
5. 截断跨越 ROI 边界的目标(设置 cut_in/cut_out)
|
||||
6. 将坐标重新归一化到 ROI 空间
|
||||
```
|
||||
|
||||
适用场景:
|
||||
- 分析**模型实际训练时**看到的数据分布
|
||||
- 评估 ROI 裁剪对数据量的影响
|
||||
- 对比原图 vs ROI 的分布差异
|
||||
|
||||
#### ROI 计算流程图
|
||||
|
||||
```
|
||||
原始图像 (1920×1080)
|
||||
│
|
||||
▼
|
||||
读取 camera4.json
|
||||
(focal_v, cv, pitch)
|
||||
│
|
||||
▼
|
||||
vanish_y = cv - fy × tan(pitch × π/180)
|
||||
│
|
||||
▼
|
||||
ROI 中心 = (960, vanish_y)
|
||||
ROI 区域 = [roi_x1, roi_y1, roi_x2, roi_y2] (1920×960)
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 原始标签 │
|
||||
│ (归一化坐标) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────┴────┐
|
||||
▼ ▼
|
||||
ROI 内 ROI 外 → 过滤丢弃
|
||||
│
|
||||
├─ 完全在内 → 直接重归一化
|
||||
│
|
||||
└─ 部分跨界 → clip截断 + 设置cut类型
|
||||
│
|
||||
▼
|
||||
ROI 空间归一化坐标
|
||||
```
|
||||
|
||||
#### 标定文件路径推导
|
||||
|
||||
```
|
||||
图像路径: .../sequence_name/images/xxx.png
|
||||
标定路径: .../sequence_name/calib/L2_calib/camera4.json
|
||||
```
|
||||
|
||||
同一序列目录下所有图像共享同一份标定,脚本会自动按序列目录缓存,避免重复读取。
|
||||
|
||||
---
|
||||
|
||||
## 输出文件说明
|
||||
|
||||
运行后在输出目录下按 split 组织,结构如下:
|
||||
|
||||
```
|
||||
dataset_profiling_results_roi/
|
||||
├── train/
|
||||
│ ├── report.txt # 文本格式完整报告
|
||||
│ ├── summary.json # JSON 格式结构化数据
|
||||
│ ├── 01_overview.png # 数据集概览(类别分布 + 格式分布 + 密度统计)
|
||||
│ ├── 02_bbox2d_analysis.png # 2D 框分析(尺寸散点 + 面积分布 + 位置热力图)
|
||||
│ ├── 03_depth_analysis.png # 深度分布(直方图 + CDF)
|
||||
│ ├── 04_dimensions_3d.png # 3D 尺寸分布(L/H/W 直方图)
|
||||
│ ├── 05_rotation_analysis.png # 旋转角分布(rot_y + alpha)
|
||||
│ ├── 06_face_visibility_cut.png# 面可见性 + 截断类型分布
|
||||
│ ├── 07_bev_scatter.png # 鸟瞰图散点(X3D vs Z3D)
|
||||
│ └── 08_depth_vs_area.png # 深度-面积关系图
|
||||
├── val/
|
||||
│ └── ... (同上)
|
||||
└── 09_train_vs_val.png # Train vs Val 对比(类别比例 + 深度 + 尺寸)
|
||||
```
|
||||
|
||||
### 可视化图表详解
|
||||
|
||||
| 图表 | 内容 | 用途 |
|
||||
|------|------|------|
|
||||
| `01_overview` | 类别数量条形图、标签格式饼图、每图目标数统计 | 快速了解数据集规模和组成 |
|
||||
| `02_bbox2d_analysis` | Top6 类别的 W-H 散点图、面积直方图、中心位置热力图 | 分析目标大小和空间分布 |
|
||||
| `03_depth_analysis` | 每个 3D 类别的深度直方图(含均值/中位数标线)和 CDF 曲线 | 分析目标距离分布 |
|
||||
| `04_dimensions_3d` | 每个 3D 类别的 Length/Height/Width 直方图(含均值±标准差) | 分析目标物理尺寸 |
|
||||
| `05_rotation_analysis` | rot_y 和 alpha 的角度直方图(标注前/后/左/右方位) | 分析朝向分布 |
|
||||
| `06_face_visibility_cut` | 四面可见率条形图 + cut 类型饼图 | 分析面标注完整度和截断情况 |
|
||||
| `07_bev_scatter` | X-Z 鸟瞰散点图 | 直观展示目标空间分布 |
|
||||
| `08_depth_vs_area` | 深度 vs 2D 框面积散点图 | 验证深度-尺寸关系合理性 |
|
||||
| `09_train_vs_val` | 类别比例、深度、尺寸的 Train/Val 双柱对比 | 检查训练集和验证集分布一致性 |
|
||||
|
||||
---
|
||||
|
||||
## 标签格式说明
|
||||
|
||||
脚本支持三种原始标签格式,统一解析为 **47 维内部表示**:
|
||||
|
||||
### 50 列格式(vehicle / tricycle)
|
||||
|
||||
带四面 3D 标注的完整格式:
|
||||
|
||||
```
|
||||
cls x y w h x3d y3d z3d l h w rot_y xc yc ? alpha ? face_front(8)... face_rear(8)... face_left(8)... face_right(8)...
|
||||
```
|
||||
|
||||
每个 face 包含 8 个值:`[x3d, y3d, z3d, alpha, xc, yc, score, is_visible]`
|
||||
|
||||
### 18 列格式(pedestrian / bicycle / rider)
|
||||
|
||||
带 3D 信息但无面标注:
|
||||
|
||||
```
|
||||
cls x y w h x3d y3d z3d l h w rot_y xc yc ? alpha ? ...
|
||||
```
|
||||
|
||||
### 6 列格式(2D-only 类别)
|
||||
|
||||
仅有 2D 框信息:
|
||||
|
||||
```
|
||||
cls x y w h conf
|
||||
```
|
||||
|
||||
### 47 维内部表示索引表
|
||||
|
||||
| 索引 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| 0 | cls | 类别 ID |
|
||||
| 1-4 | x, y, w, h | 2D 框(归一化 xywh) |
|
||||
| 5-7 | x3d, y3d, z3d | 3D 中心坐标(米) |
|
||||
| 8-10 | length, height, width | 3D 尺寸(米) |
|
||||
| 11 | rot_y | 全局旋转角(弧度) |
|
||||
| 12-13 | xc, yc | 3D 投影中心(归一化) |
|
||||
| 14 | alpha | 观察角(弧度) |
|
||||
| 15-22 | face_front | 前面 [x3d, y3d, z3d, alpha, xc, yc, score, is_visible] |
|
||||
| 23-30 | face_rear | 后面 |
|
||||
| 31-38 | face_left | 左面 |
|
||||
| 39-46 | face_right | 右面 |
|
||||
|
||||
---
|
||||
|
||||
## 支持的类别
|
||||
|
||||
| ID | 类别名 | 标签格式 | 3D 信息 |
|
||||
|----|--------|----------|---------|
|
||||
| 0 | vehicle | 50 列 | 四面 3D |
|
||||
| 1 | pedestrian | 18 列 | 整体 3D |
|
||||
| 2 | bicycle | 18 列 | 整体 3D |
|
||||
| 3 | rider | 18 列 | 整体 3D |
|
||||
| 4 | roadblock | 6 列 | 无 |
|
||||
| 5 | head | 6 列 | 无 |
|
||||
| 6 | tsr | 6 列 | 无 |
|
||||
| 7 | guideboard | 6 列 | 无 |
|
||||
| 8 | plate | 6 列 | 无 |
|
||||
| 9 | wheel | 6 列 | 无 |
|
||||
| 10 | tl_border | 6 列 | 无 |
|
||||
| 11 | tl_wick | 6 列 | 无 |
|
||||
| 12 | tl_num | 6 列 | 无 |
|
||||
| 13 | tricycle | 50 列 | 四面 3D |
|
||||
|
||||
---
|
||||
|
||||
## 截断类型(Cut Type)
|
||||
|
||||
当 ROI 裁剪导致车辆目标被部分截断时,根据旋转角 `rot_y` 判断截断类型:
|
||||
|
||||
| 类型 | 判定条件 | 面标注处理 |
|
||||
|------|---------|-----------|
|
||||
| **noncut** | 目标完全在 ROI 内,四面标注均有效 | 保持不变 |
|
||||
| **cut_in** | `rot_y ∈ [-π, 0]`(驶入方向) | 保留前面,其余面置为 -1 |
|
||||
| **cut_out** | `rot_y ∈ (0, π]`(驶出方向) | 保留后面,其余面置为 -1 |
|
||||
| **partial_cut** | 其他部分截断情况 | 根据实际可见性处理 |
|
||||
|
||||
---
|
||||
|
||||
## 数据质量指标
|
||||
|
||||
| 指标 | 说明 | 关注阈值 |
|
||||
|------|------|---------|
|
||||
| `nan_3d_objects` | 3D 类别目标但 3D 字段为 NaN | 应为 0 |
|
||||
| `invalid_depth_le0` | 深度 ≤ 0m 的目标 | 应尽量少 |
|
||||
| `outlier_depth_gt200m` | 深度 > 200m 的目标 | 可能为标注错误 |
|
||||
| `tiny_box_lt8px` | 2D 框宽或高 < 8px 的目标 | 训练时可能无法学习 |
|
||||
|
||||
---
|
||||
|
||||
## 数据配置(YAML)
|
||||
|
||||
脚本从 YAML 配置文件中读取以下关键字段:
|
||||
|
||||
```yaml
|
||||
# data/mono3d.yaml 示例
|
||||
path: /mnt/mono3d_nfs/xdzhu_data/Mono3d/Mono3d_4face_2m_g1m3
|
||||
|
||||
train:
|
||||
- /path/to/train_full.txt
|
||||
- /path/to/train_CNCAP.txt
|
||||
val:
|
||||
- /path/to/val_3000.txt
|
||||
|
||||
ori_img_size: [1920, 1080] # 原始图像尺寸 [W, H]
|
||||
roi: [1920, 960] # ROI 裁剪尺寸 [W, H]
|
||||
virtual_fx: 537 # 虚拟焦距(ROI 模式下的参考值)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 典型用例
|
||||
|
||||
### 1. 全量训练集画像
|
||||
|
||||
```bash
|
||||
python dataset_profiling.py --split train --analysis-mode roi
|
||||
```
|
||||
|
||||
### 2. 快速预览验证集
|
||||
|
||||
```bash
|
||||
python dataset_profiling.py --split val --max-files 3000 --analysis-mode original
|
||||
```
|
||||
|
||||
### 3. 对比 original vs roi 的分布差异
|
||||
|
||||
```bash
|
||||
# 分别运行两种模式
|
||||
python dataset_profiling.py --split val --analysis-mode original
|
||||
python dataset_profiling.py --split val --analysis-mode roi
|
||||
|
||||
# 输出分别在:
|
||||
# dataset_profiling_results_original/val/
|
||||
# dataset_profiling_results_roi/val/
|
||||
```
|
||||
|
||||
### 4. 自定义图像尺寸(模型输入分辨率)
|
||||
|
||||
```bash
|
||||
# 按模型输入尺寸 704×352 做像素统计
|
||||
python dataset_profiling.py --analysis-mode roi --img-size 704 352
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 代码架构
|
||||
|
||||
```
|
||||
dataset_profiling.py
|
||||
│
|
||||
├── 常量定义
|
||||
│ ├── CLASS_NAMES # 14 类名称映射
|
||||
│ ├── FACE_BASED_CLASSES # 面标注类别 {vehicle, tricycle}
|
||||
│ ├── ALL_3D_CLASSES # 所有 3D 类别
|
||||
│ └── IDX # 47 维内部索引表
|
||||
│
|
||||
├── 标签解析
|
||||
│ ├── parse_label_file() # 解析单个标签文件 → 47 维数组列表
|
||||
│ ├── image_path_to_label_path() # 图片路径 → 标签路径
|
||||
│ └── resolve_image_paths() # 从 YAML 配置解析全部图片路径
|
||||
│
|
||||
├── ROI 处理(--analysis-mode roi 时启用)
|
||||
│ ├── read_calib_for_image() # 读取相机标定 camera4.json
|
||||
│ ├── compute_roi_bounds() # 从标定计算 ROI 边界
|
||||
│ ├── get_roi_for_image() # 按序列缓存的 ROI 查询
|
||||
│ └── filter_clip_labels_to_roi() # 过滤/截断/重归一化标签
|
||||
│
|
||||
├── 统计收集器
|
||||
│ └── DatasetProfiler # 核心分析类
|
||||
│ ├── process_image() # 处理单张图片
|
||||
│ └── summarize() # 汇总统计结果
|
||||
│
|
||||
├── 可视化(9 类图表)
|
||||
│ ├── plot_overview() # 01: 概览
|
||||
│ ├── plot_2d_bbox_analysis() # 02: 2D 框分析
|
||||
│ ├── plot_3d_depth_analysis() # 03: 深度分析
|
||||
│ ├── plot_3d_dimensions() # 04: 3D 尺寸
|
||||
│ ├── plot_rotation_analysis() # 05: 旋转角
|
||||
│ ├── plot_face_visibility() # 06: 面可见性
|
||||
│ ├── plot_lateral_depth_scatter() # 07: 鸟瞰图
|
||||
│ ├── plot_depth_vs_box_size() # 08: 深度-面积
|
||||
│ └── plot_train_vs_val_comparison() # 09: Train vs Val
|
||||
│
|
||||
├── 报告生成
|
||||
│ └── generate_text_report() # 生成文本报告
|
||||
│
|
||||
└── 主流程
|
||||
├── run_profiling() # 单 split 分析流程
|
||||
└── main() # 入口(参数解析 + 调度)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖
|
||||
|
||||
- Python 3.8+
|
||||
- NumPy
|
||||
- Matplotlib
|
||||
- PyYAML
|
||||
|
||||
均为 YOLOv5-3D 项目已有依赖,无需额外安装。
|
||||
1685
tools/scripts_for_gt/profiling/calib_profiling.py
Executable file
1685
tools/scripts_for_gt/profiling/calib_profiling.py
Executable file
File diff suppressed because it is too large
Load Diff
2315
tools/scripts_for_gt/profiling/dataset_profiling.py
Executable file
2315
tools/scripts_for_gt/profiling/dataset_profiling.py
Executable file
File diff suppressed because it is too large
Load Diff
77
tools/scripts_for_gt/profiling/run_calib_profiling.sh
Executable file
77
tools/scripts_for_gt/profiling/run_calib_profiling.sh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Calibration Parameter Profiling Launch Script
|
||||
#
|
||||
# Usage:
|
||||
# bash run_calib_profiling.sh [train|val|both]
|
||||
#
|
||||
# Positional arg (optional): split — defaults to "train"
|
||||
#
|
||||
# To also profile test-case directories and compare with training data, set
|
||||
# CASES_DIRS below (space-separated "name:path" pairs) or export the variable
|
||||
# before running this script:
|
||||
#
|
||||
# CASES_DIRS="cases_coding:/data1/dongying/Mono3d/G1M3/cases_coding cases_feishu:/data1/dongying/Mono3d/G1M3/cases_feishu" \
|
||||
# bash run_calib_profiling.sh train
|
||||
#
|
||||
# Outputs all plots and reports to $OUTPUT_DIR.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── User-configurable parameters ─────────────────────────────────────────────
|
||||
|
||||
# Path to dataset YAML config
|
||||
DATA="data/mono3d.yaml"
|
||||
|
||||
# Which split(s) to profile: train | val | both
|
||||
SPLIT="${1:-train}"
|
||||
|
||||
# Max image files to scan (0 = all — calib is deduplicated per sequence dir)
|
||||
MAX_FILES=0
|
||||
|
||||
# Output directory for plots and reports
|
||||
OUTPUT_DIR="calib_profiling_results"
|
||||
|
||||
# Number of parallel workers (0 = auto-detect CPU count, 1 = sequential)
|
||||
WORKERS=0
|
||||
|
||||
# Test-case directories to profile alongside the training/val split.
|
||||
# All directories are merged into a single combined profile named $CASES_NAME.
|
||||
# Format: space-separated "name:path" pairs. Set to "" to skip.
|
||||
# Example:
|
||||
# CASES_DIRS="cases_coding:/data1/dongying/Mono3d/G1M3/cases_coding cases_feishu:/data1/dongying/Mono3d/G1M3/cases_feishu"
|
||||
CASES_DIRS="${CASES_DIRS:-cases_coding:/data1/dongying/Mono3d/G1M3/cases_coding cases_feishu:/data1/dongying/Mono3d/G1M3/cases_feishu}"
|
||||
|
||||
# Label used for the merged test-cases profile in plots and output filenames
|
||||
CASES_NAME="${CASES_NAME:-cases}"
|
||||
|
||||
# ── Derived paths ─────────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Calibration Profiling"
|
||||
echo " data : $DATA"
|
||||
echo " split : $SPLIT"
|
||||
echo " cases_dirs: $CASES_DIRS"
|
||||
echo " cases_name: $CASES_NAME"
|
||||
echo " output : $OUTPUT_DIR"
|
||||
echo "=========================================="
|
||||
|
||||
# Build optional --cases-dirs / --cases-name arguments
|
||||
CASES_ARGS=()
|
||||
if [[ -n "${CASES_DIRS}" ]]; then
|
||||
# shellcheck disable=SC2206
|
||||
CASES_ARGS=(--cases-dirs ${CASES_DIRS} --cases-name "${CASES_NAME}")
|
||||
fi
|
||||
|
||||
python tools/scripts_for_gt/profiling/calib_profiling.py \
|
||||
--data "$DATA" \
|
||||
--split "$SPLIT" \
|
||||
--max-files "$MAX_FILES" \
|
||||
--output-dir "$OUTPUT_DIR" \
|
||||
--workers "$WORKERS" \
|
||||
"${CASES_ARGS[@]}"
|
||||
62
tools/scripts_for_gt/profiling/run_profiling.sh
Executable file
62
tools/scripts_for_gt/profiling/run_profiling.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Dataset Profiling Launch Script
|
||||
# Usage: bash run_profiling.sh [original|roi]
|
||||
# Positional arg (optional): analysis mode — "original" (default) or "roi"
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── User-configurable parameters ─────────────────────────────────────────────
|
||||
|
||||
# Path to dataset YAML config
|
||||
DATA="data/mono3d.yaml"
|
||||
|
||||
# Which split(s) to profile: train | val | both
|
||||
SPLIT="train"
|
||||
|
||||
# Max files to process per split (0 = all)
|
||||
MAX_FILES=0
|
||||
|
||||
# Output directory (analysis mode suffix is appended automatically)
|
||||
OUTPUT_DIR="dataset_profiling_cncap"
|
||||
|
||||
# Image size for pixel-unit conversion [width height]
|
||||
# Leave empty ("") to use the default from YAML (ori_img_size or roi_size)
|
||||
IMG_SIZE=""
|
||||
|
||||
# Analysis mode: "original" (full image) or "roi" (crop + calibration-based filtering)
|
||||
# Can also be overridden by the first positional argument: bash run_profiling.sh roi
|
||||
ANALYSIS_MODE="${1:-original}"
|
||||
|
||||
# Number of parallel workers (0 = auto-detect CPU count, 1 = sequential)
|
||||
WORKERS=32
|
||||
|
||||
# ── Build command ─────────────────────────────────────────────────────────────
|
||||
|
||||
CMD="python tools/scripts_for_gt/profiling/dataset_profiling.py \
|
||||
--data ${DATA} \
|
||||
--split ${SPLIT} \
|
||||
--max-files ${MAX_FILES} \
|
||||
--output-dir ${OUTPUT_DIR} \
|
||||
--analysis-mode ${ANALYSIS_MODE} \
|
||||
--workers ${WORKERS}"
|
||||
|
||||
if [ -n "${IMG_SIZE}" ]; then
|
||||
CMD="${CMD} --img-size ${IMG_SIZE}"
|
||||
fi
|
||||
|
||||
# ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
cd "$(dirname "$0")/../../.." || exit 1 # ensure cwd is project root
|
||||
|
||||
echo "========================================================================"
|
||||
echo " Dataset Profiling"
|
||||
echo " data: ${DATA}"
|
||||
echo " split: ${SPLIT}"
|
||||
echo " analysis-mode: ${ANALYSIS_MODE}"
|
||||
echo " max-files: ${MAX_FILES} (0 = all)"
|
||||
echo " workers: ${WORKERS}"
|
||||
echo " output-dir: ${OUTPUT_DIR}_${ANALYSIS_MODE}"
|
||||
[ -n "${IMG_SIZE}" ] && echo " img-size: ${IMG_SIZE}"
|
||||
echo "========================================================================"
|
||||
|
||||
eval "${CMD}"
|
||||
3
tools/scripts_for_gt/profiling/run_visualize_calib.sh
Executable file
3
tools/scripts_for_gt/profiling/run_visualize_calib.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
python tools/scripts_for_gt/profiling/visualize_calib_group_comparison.py \
|
||||
--input calib_profiling_results/train_calib_table.csv \
|
||||
--output-dir calib_profiling_results/group_comparison_v2
|
||||
714
tools/scripts_for_gt/profiling/visualize_calib_group_comparison.py
Executable file
714
tools/scripts_for_gt/profiling/visualize_calib_group_comparison.py
Executable file
@@ -0,0 +1,714 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Visualize calibration drift across grouped splits such as train / case / clip.
|
||||
|
||||
The script is designed for split-based calibration summary tables like
|
||||
`calib_profiling_results/train_calib_table.csv`, where each row belongs to a
|
||||
group in the `split` column and contains calibration parameters as numeric
|
||||
columns.
|
||||
|
||||
Outputs:
|
||||
1. `split_parameter_panels.png`
|
||||
Per-parameter strip plots with the baseline group's mean ± std overlay.
|
||||
2. `group_zscore_heatmap.png`
|
||||
Mean parameter drift of each non-baseline group relative to the baseline.
|
||||
3. `group_drift_ranking.png`
|
||||
Ranked horizontal bar charts of the strongest drifts per group.
|
||||
4. `parameter_pair_relationships.png`
|
||||
Selected 2D parameter relationships colored by split.
|
||||
5. `group_comparison_report.txt`
|
||||
Compact text summary with counts and top drifts.
|
||||
|
||||
Usage:
|
||||
python3 tools/scripts_for_gt/profiling/visualize_calib_group_comparison.py \
|
||||
--input calib_profiling_results/train_calib_table.csv \
|
||||
--output-dir calib_profiling_results/group_comparison
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Sequence
|
||||
|
||||
try:
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError as exc:
|
||||
raise SystemExit(
|
||||
"matplotlib is required for visualization. "
|
||||
"Install the project plotting dependencies first, for example:\n"
|
||||
" pip install -r requirements.txt"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
except ImportError as exc:
|
||||
raise SystemExit(
|
||||
"numpy is required for visualization. "
|
||||
"Install the project dependencies first, for example:\n"
|
||||
" pip install -r requirements.txt"
|
||||
) from exc
|
||||
|
||||
|
||||
DEFAULT_GROUP_ORDER = ("train", "case", "clip")
|
||||
DEFAULT_PAIR_SPECS = (
|
||||
("focal_u", "focal_v"),
|
||||
("cu", "cv"),
|
||||
("yaw", "vanish_x"),
|
||||
("pitch", "vanish_y"),
|
||||
("pos_x", "pos_z"),
|
||||
("roll", "pos_z"),
|
||||
)
|
||||
PARAMETER_GROUPS = {
|
||||
"Intrinsics": ("focal_u", "focal_v", "cu", "cv"),
|
||||
"Angles": ("pitch", "yaw", "roll"),
|
||||
"Position": ("pos_x", "pos_y", "pos_z"),
|
||||
"Vanishing Point": ("vanish_x", "vanish_y"),
|
||||
}
|
||||
PALETTE = {
|
||||
"train": "#1f77b4",
|
||||
"case": "#d62728",
|
||||
"clip": "#2ca02c",
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Visualize calibration drift across grouped splits."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
type=str,
|
||||
default="calib_profiling_results/train_calib_table.csv",
|
||||
help="Input CSV with split-based calibration rows.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=str,
|
||||
default="calib_profiling_results/group_comparison",
|
||||
help="Directory where plots and report will be written.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--split-column",
|
||||
type=str,
|
||||
default="split",
|
||||
help="Column containing group labels.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--id-column",
|
||||
type=str,
|
||||
default="vehicle/date",
|
||||
help="Column containing row identifiers for optional annotations.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline-split",
|
||||
type=str,
|
||||
default="train",
|
||||
help="Baseline split used for drift comparison.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top-k",
|
||||
type=int,
|
||||
default=8,
|
||||
help="How many parameters to keep in each drift ranking panel.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--figure-dpi",
|
||||
type=int,
|
||||
default=180,
|
||||
help="Saved figure DPI.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--annotate-pairs",
|
||||
action="store_true",
|
||||
help="Annotate pair plots with row identifiers when the table is small.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _to_float(value: str) -> float:
|
||||
if value is None:
|
||||
return math.nan
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return math.nan
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return math.nan
|
||||
|
||||
|
||||
def _mean(values: Sequence[float]) -> float:
|
||||
if not values:
|
||||
return math.nan
|
||||
return float(sum(values) / len(values))
|
||||
|
||||
|
||||
def _std(values: Sequence[float]) -> float:
|
||||
if not values:
|
||||
return math.nan
|
||||
mu = _mean(values)
|
||||
return float(math.sqrt(sum((v - mu) ** 2 for v in values) / len(values)))
|
||||
|
||||
|
||||
def ordered_splits(available: Iterable[str], baseline_split: str) -> List[str]:
|
||||
available_unique = list(dict.fromkeys(available))
|
||||
ordered = [s for s in DEFAULT_GROUP_ORDER if s in available_unique]
|
||||
if baseline_split in available_unique and baseline_split not in ordered:
|
||||
ordered.insert(0, baseline_split)
|
||||
ordered.extend(s for s in available_unique if s not in ordered)
|
||||
return ordered
|
||||
|
||||
|
||||
def ordered_parameters(columns: Sequence[str]) -> List[str]:
|
||||
seen = set()
|
||||
ordered: List[str] = []
|
||||
for group_cols in PARAMETER_GROUPS.values():
|
||||
for col in group_cols:
|
||||
if col in columns and col not in seen:
|
||||
ordered.append(col)
|
||||
seen.add(col)
|
||||
for col in columns:
|
||||
if col not in seen:
|
||||
ordered.append(col)
|
||||
seen.add(col)
|
||||
return ordered
|
||||
|
||||
|
||||
def load_table(
|
||||
csv_path: Path, split_col: str, id_col: str
|
||||
) -> tuple[List[dict], List[str], List[str]]:
|
||||
with csv_path.open("r", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
raw_rows = list(reader)
|
||||
if not raw_rows:
|
||||
raise ValueError(f"No rows found in {csv_path}")
|
||||
|
||||
columns = list(raw_rows[0].keys())
|
||||
for required in (split_col, id_col):
|
||||
if required not in columns:
|
||||
raise KeyError(
|
||||
f"Required column '{required}' not found in {csv_path}. "
|
||||
f"Available columns: {columns}"
|
||||
)
|
||||
|
||||
candidate_numeric = [c for c in columns if c not in (split_col, id_col)]
|
||||
numeric_cols = []
|
||||
for col in candidate_numeric:
|
||||
if any(not math.isnan(_to_float(row.get(col, ""))) for row in raw_rows):
|
||||
numeric_cols.append(col)
|
||||
|
||||
parsed_rows: List[dict] = []
|
||||
for row in raw_rows:
|
||||
parsed = {
|
||||
split_col: row.get(split_col, "").strip(),
|
||||
id_col: row.get(id_col, "").strip(),
|
||||
}
|
||||
for col in numeric_cols:
|
||||
parsed[col] = _to_float(row.get(col, ""))
|
||||
parsed_rows.append(parsed)
|
||||
|
||||
splits = ordered_splits(
|
||||
[row[split_col] for row in parsed_rows if row[split_col]], baseline_split=""
|
||||
)
|
||||
return parsed_rows, splits, ordered_parameters(numeric_cols)
|
||||
|
||||
|
||||
def build_group_values(
|
||||
rows: Sequence[dict], splits: Sequence[str], split_col: str, params: Sequence[str]
|
||||
) -> Dict[str, Dict[str, List[float]]]:
|
||||
grouped: Dict[str, Dict[str, List[float]]] = {
|
||||
split: {param: [] for param in params} for split in splits
|
||||
}
|
||||
for row in rows:
|
||||
split = row.get(split_col, "")
|
||||
if split not in grouped:
|
||||
continue
|
||||
for param in params:
|
||||
value = row.get(param, math.nan)
|
||||
if not math.isnan(value):
|
||||
grouped[split][param].append(value)
|
||||
return grouped
|
||||
|
||||
|
||||
def compute_baseline_stats(
|
||||
grouped_values: Dict[str, Dict[str, List[float]]],
|
||||
baseline_split: str,
|
||||
params: Sequence[str],
|
||||
) -> Dict[str, dict]:
|
||||
baseline = grouped_values.get(baseline_split)
|
||||
if baseline is None:
|
||||
raise KeyError(
|
||||
f"Baseline split '{baseline_split}' not found in input data. "
|
||||
f"Available splits: {list(grouped_values.keys())}"
|
||||
)
|
||||
|
||||
stats = {}
|
||||
for param in params:
|
||||
values = baseline[param]
|
||||
stats[param] = {
|
||||
"count": len(values),
|
||||
"mean": _mean(values),
|
||||
"std": _std(values),
|
||||
"min": min(values) if values else math.nan,
|
||||
"max": max(values) if values else math.nan,
|
||||
}
|
||||
return stats
|
||||
|
||||
|
||||
def compute_group_means(
|
||||
grouped_values: Dict[str, Dict[str, List[float]]], params: Sequence[str]
|
||||
) -> Dict[str, Dict[str, float]]:
|
||||
means: Dict[str, Dict[str, float]] = {}
|
||||
for split, param_map in grouped_values.items():
|
||||
means[split] = {param: _mean(param_map[param]) for param in params}
|
||||
return means
|
||||
|
||||
|
||||
def compute_group_zscores(
|
||||
group_means: Dict[str, Dict[str, float]],
|
||||
baseline_stats: Dict[str, dict],
|
||||
splits: Sequence[str],
|
||||
params: Sequence[str],
|
||||
) -> Dict[str, Dict[str, float]]:
|
||||
zscores: Dict[str, Dict[str, float]] = {}
|
||||
for split in splits:
|
||||
zscores[split] = {}
|
||||
for param in params:
|
||||
baseline_mean = baseline_stats[param]["mean"]
|
||||
baseline_std = baseline_stats[param]["std"]
|
||||
group_mean = group_means[split][param]
|
||||
if math.isnan(group_mean) or math.isnan(baseline_mean):
|
||||
zscores[split][param] = math.nan
|
||||
elif baseline_std == 0 or math.isnan(baseline_std):
|
||||
zscores[split][param] = 0.0 if group_mean == baseline_mean else math.nan
|
||||
else:
|
||||
zscores[split][param] = (group_mean - baseline_mean) / baseline_std
|
||||
return zscores
|
||||
|
||||
|
||||
def format_value(value: float) -> str:
|
||||
return "nan" if math.isnan(value) else f"{value:.3f}"
|
||||
|
||||
|
||||
def short_label(text: str, max_len: int = 28) -> str:
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def plot_split_parameter_panels(
|
||||
rows: Sequence[dict],
|
||||
splits: Sequence[str],
|
||||
params: Sequence[str],
|
||||
split_col: str,
|
||||
baseline_stats: Dict[str, dict],
|
||||
output_path: Path,
|
||||
dpi: int,
|
||||
) -> None:
|
||||
n_params = len(params)
|
||||
ncols = 4
|
||||
nrows = math.ceil(n_params / ncols)
|
||||
fig, axes = plt.subplots(
|
||||
nrows,
|
||||
ncols,
|
||||
figsize=(4.3 * ncols, 3.4 * nrows),
|
||||
constrained_layout=True,
|
||||
)
|
||||
axes = np.atleast_1d(axes).reshape(nrows, ncols)
|
||||
rng = np.random.default_rng(20260318)
|
||||
|
||||
for idx, param in enumerate(params):
|
||||
ax = axes[idx // ncols][idx % ncols]
|
||||
train_mean = baseline_stats[param]["mean"]
|
||||
train_std = baseline_stats[param]["std"]
|
||||
|
||||
if not math.isnan(train_mean):
|
||||
ax.axhline(train_mean, color="#444444", linewidth=1.2, linestyle="--")
|
||||
if not math.isnan(train_std) and train_std > 0:
|
||||
ax.axhspan(
|
||||
train_mean - train_std,
|
||||
train_mean + train_std,
|
||||
color="#444444",
|
||||
alpha=0.10,
|
||||
)
|
||||
|
||||
for x_idx, split in enumerate(splits):
|
||||
values = [
|
||||
row[param]
|
||||
for row in rows
|
||||
if row.get(split_col) == split and not math.isnan(row.get(param, math.nan))
|
||||
]
|
||||
if not values:
|
||||
continue
|
||||
x = x_idx + rng.uniform(-0.12, 0.12, size=len(values))
|
||||
color = PALETTE.get(split, f"C{x_idx}")
|
||||
ax.scatter(
|
||||
x,
|
||||
values,
|
||||
s=42,
|
||||
alpha=0.85,
|
||||
linewidth=0.5,
|
||||
edgecolors="white",
|
||||
color=color,
|
||||
zorder=3,
|
||||
)
|
||||
ax.scatter(
|
||||
[x_idx],
|
||||
[_mean(values)],
|
||||
marker="D",
|
||||
s=52,
|
||||
color=color,
|
||||
edgecolors="black",
|
||||
linewidth=0.7,
|
||||
zorder=4,
|
||||
)
|
||||
|
||||
ax.set_title(param, fontsize=11, pad=8)
|
||||
ax.set_xticks(range(len(splits)))
|
||||
ax.set_xticklabels(splits, rotation=20)
|
||||
ax.grid(axis="y", linestyle=":", alpha=0.35)
|
||||
ax.set_axisbelow(True)
|
||||
|
||||
for idx in range(n_params, nrows * ncols):
|
||||
axes[idx // ncols][idx % ncols].axis("off")
|
||||
|
||||
fig.suptitle(
|
||||
"Calibration Parameter Comparison by Split\n"
|
||||
"Dashed line and shaded band show baseline mean ± std",
|
||||
fontsize=14,
|
||||
)
|
||||
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def plot_group_zscore_heatmap(
|
||||
splits: Sequence[str],
|
||||
params: Sequence[str],
|
||||
zscores: Dict[str, Dict[str, float]],
|
||||
baseline_split: str,
|
||||
output_path: Path,
|
||||
dpi: int,
|
||||
) -> None:
|
||||
target_splits = [split for split in splits if split != baseline_split]
|
||||
if not target_splits:
|
||||
return
|
||||
|
||||
matrix = np.array(
|
||||
[[zscores[split][param] for param in params] for split in target_splits],
|
||||
dtype=float,
|
||||
)
|
||||
finite_vals = matrix[np.isfinite(matrix)]
|
||||
vmax = max(1.0, float(np.max(np.abs(finite_vals)))) if finite_vals.size else 1.0
|
||||
|
||||
fig_w = max(10.5, 0.9 * len(params))
|
||||
fig_h = max(3.6, 1.3 * len(target_splits))
|
||||
fig, ax = plt.subplots(figsize=(fig_w, fig_h), constrained_layout=True)
|
||||
|
||||
im = ax.imshow(matrix, cmap="coolwarm", vmin=-vmax, vmax=vmax, aspect="auto")
|
||||
ax.set_xticks(np.arange(len(params)))
|
||||
ax.set_xticklabels(params, rotation=35, ha="right")
|
||||
ax.set_yticks(np.arange(len(target_splits)))
|
||||
ax.set_yticklabels(target_splits)
|
||||
ax.set_title(
|
||||
f"Mean Parameter Drift vs {baseline_split} baseline (z-score units)", fontsize=13
|
||||
)
|
||||
|
||||
for i in range(len(target_splits)):
|
||||
for j in range(len(params)):
|
||||
value = matrix[i, j]
|
||||
text = "nan" if not np.isfinite(value) else f"{value:.2f}"
|
||||
color = "white" if np.isfinite(value) and abs(value) > vmax * 0.55 else "black"
|
||||
ax.text(j, i, text, ha="center", va="center", fontsize=8, color=color)
|
||||
|
||||
cbar = fig.colorbar(im, ax=ax, shrink=0.9)
|
||||
cbar.set_label("z-score of group mean relative to baseline", rotation=90)
|
||||
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def plot_group_drift_ranking(
|
||||
splits: Sequence[str],
|
||||
params: Sequence[str],
|
||||
zscores: Dict[str, Dict[str, float]],
|
||||
baseline_split: str,
|
||||
output_path: Path,
|
||||
dpi: int,
|
||||
top_k: int,
|
||||
) -> None:
|
||||
target_splits = [split for split in splits if split != baseline_split]
|
||||
if not target_splits:
|
||||
return
|
||||
|
||||
fig, axes = plt.subplots(
|
||||
len(target_splits),
|
||||
1,
|
||||
figsize=(10.5, 3.1 * len(target_splits)),
|
||||
constrained_layout=True,
|
||||
)
|
||||
axes = np.atleast_1d(axes)
|
||||
|
||||
for ax, split in zip(axes, target_splits):
|
||||
scored = []
|
||||
for param in params:
|
||||
value = zscores[split][param]
|
||||
if math.isnan(value):
|
||||
continue
|
||||
scored.append((param, value))
|
||||
scored.sort(key=lambda item: abs(item[1]), reverse=True)
|
||||
scored = scored[:top_k]
|
||||
|
||||
if not scored:
|
||||
ax.axis("off")
|
||||
continue
|
||||
|
||||
labels = [param for param, _ in scored][::-1]
|
||||
values = [value for _, value in scored][::-1]
|
||||
colors = ["#d62728" if value > 0 else "#1f77b4" for value in values]
|
||||
|
||||
ax.barh(labels, values, color=colors, alpha=0.88)
|
||||
ax.axvline(0.0, color="#333333", linewidth=1.0)
|
||||
ax.grid(axis="x", linestyle=":", alpha=0.35)
|
||||
ax.set_title(f"Top parameter drift for split: {split}", fontsize=12)
|
||||
ax.set_xlabel("Mean drift relative to baseline (z-score units)")
|
||||
|
||||
for y_idx, value in enumerate(values):
|
||||
text_x = value + 0.05 if value >= 0 else value - 0.05
|
||||
align = "left" if value >= 0 else "right"
|
||||
ax.text(text_x, y_idx, f"{value:.2f}", va="center", ha=align, fontsize=9)
|
||||
|
||||
fig.suptitle("Ranked group drift relative to baseline", fontsize=14)
|
||||
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def plot_parameter_pair_relationships(
|
||||
rows: Sequence[dict],
|
||||
splits: Sequence[str],
|
||||
split_col: str,
|
||||
id_col: str,
|
||||
output_path: Path,
|
||||
dpi: int,
|
||||
annotate_pairs: bool,
|
||||
) -> None:
|
||||
available_pairs = []
|
||||
for x_col, y_col in DEFAULT_PAIR_SPECS:
|
||||
if all(x_col in row and y_col in row for row in rows):
|
||||
if any(not math.isnan(row.get(x_col, math.nan)) for row in rows) and any(
|
||||
not math.isnan(row.get(y_col, math.nan)) for row in rows
|
||||
):
|
||||
available_pairs.append((x_col, y_col))
|
||||
|
||||
if not available_pairs:
|
||||
return
|
||||
|
||||
ncols = 2
|
||||
nrows = math.ceil(len(available_pairs) / ncols)
|
||||
fig, axes = plt.subplots(
|
||||
nrows,
|
||||
ncols,
|
||||
figsize=(6.0 * ncols, 4.8 * nrows),
|
||||
constrained_layout=True,
|
||||
)
|
||||
axes = np.atleast_1d(axes).reshape(nrows, ncols)
|
||||
|
||||
should_annotate = annotate_pairs or len(rows) <= 24
|
||||
|
||||
for idx, (x_col, y_col) in enumerate(available_pairs):
|
||||
ax = axes[idx // ncols][idx % ncols]
|
||||
for split_idx, split in enumerate(splits):
|
||||
split_rows = [
|
||||
row
|
||||
for row in rows
|
||||
if row.get(split_col) == split
|
||||
and not math.isnan(row.get(x_col, math.nan))
|
||||
and not math.isnan(row.get(y_col, math.nan))
|
||||
]
|
||||
if not split_rows:
|
||||
continue
|
||||
|
||||
xs = [row[x_col] for row in split_rows]
|
||||
ys = [row[y_col] for row in split_rows]
|
||||
ax.scatter(
|
||||
xs,
|
||||
ys,
|
||||
s=54,
|
||||
alpha=0.88,
|
||||
linewidth=0.6,
|
||||
edgecolors="white",
|
||||
color=PALETTE.get(split, f"C{split_idx}"),
|
||||
label=split,
|
||||
)
|
||||
|
||||
if should_annotate:
|
||||
for row in split_rows:
|
||||
label = short_label(row.get(id_col, ""), max_len=18)
|
||||
ax.annotate(
|
||||
label,
|
||||
(row[x_col], row[y_col]),
|
||||
textcoords="offset points",
|
||||
xytext=(4, 4),
|
||||
fontsize=7,
|
||||
alpha=0.80,
|
||||
)
|
||||
|
||||
ax.set_title(f"{x_col} vs {y_col}", fontsize=11)
|
||||
ax.set_xlabel(x_col)
|
||||
ax.set_ylabel(y_col)
|
||||
ax.grid(linestyle=":", alpha=0.35)
|
||||
|
||||
for idx in range(len(available_pairs), nrows * ncols):
|
||||
axes[idx // ncols][idx % ncols].axis("off")
|
||||
|
||||
handles, labels = axes[0][0].get_legend_handles_labels()
|
||||
if handles:
|
||||
fig.legend(handles, labels, loc="upper center", ncol=min(len(labels), 4), frameon=False)
|
||||
|
||||
fig.suptitle("Selected parameter relationships by split", fontsize=14)
|
||||
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def write_report(
|
||||
output_path: Path,
|
||||
csv_path: Path,
|
||||
splits: Sequence[str],
|
||||
rows: Sequence[dict],
|
||||
split_col: str,
|
||||
params: Sequence[str],
|
||||
baseline_split: str,
|
||||
grouped_values: Dict[str, Dict[str, List[float]]],
|
||||
baseline_stats: Dict[str, dict],
|
||||
group_means: Dict[str, Dict[str, float]],
|
||||
zscores: Dict[str, Dict[str, float]],
|
||||
top_k: int,
|
||||
) -> None:
|
||||
counts = defaultdict(int)
|
||||
for row in rows:
|
||||
counts[row.get(split_col, "")] += 1
|
||||
|
||||
lines = []
|
||||
lines.append("=" * 72)
|
||||
lines.append("CALIBRATION GROUP COMPARISON REPORT")
|
||||
lines.append("=" * 72)
|
||||
lines.append(f"Input CSV : {csv_path}")
|
||||
lines.append(f"Baseline split : {baseline_split}")
|
||||
lines.append("")
|
||||
lines.append("Split counts:")
|
||||
for split in splits:
|
||||
lines.append(f" {split:<24} {counts.get(split, 0)}")
|
||||
lines.append("")
|
||||
lines.append("Baseline summary:")
|
||||
for param in params:
|
||||
stat = baseline_stats[param]
|
||||
lines.append(
|
||||
f" {param:<24} n={stat['count']:<3d} "
|
||||
f"mean={format_value(stat['mean'])} std={format_value(stat['std'])} "
|
||||
f"range=[{format_value(stat['min'])}, {format_value(stat['max'])}]"
|
||||
)
|
||||
|
||||
for split in splits:
|
||||
if split == baseline_split:
|
||||
continue
|
||||
lines.append("")
|
||||
lines.append(f"Top drift vs baseline for split '{split}':")
|
||||
ranked = []
|
||||
for param in params:
|
||||
z_value = zscores[split][param]
|
||||
group_mean = group_means[split][param]
|
||||
base_mean = baseline_stats[param]["mean"]
|
||||
if math.isnan(z_value) or math.isnan(group_mean) or math.isnan(base_mean):
|
||||
continue
|
||||
ranked.append((param, z_value, group_mean - base_mean))
|
||||
ranked.sort(key=lambda item: abs(item[1]), reverse=True)
|
||||
for param, z_value, delta in ranked[:top_k]:
|
||||
lines.append(
|
||||
f" {param:<24} delta={delta:+.3f} z={z_value:+.2f}"
|
||||
)
|
||||
|
||||
output_path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
csv_path = Path(args.input)
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows, file_splits, params = load_table(
|
||||
csv_path=csv_path,
|
||||
split_col=args.split_column,
|
||||
id_col=args.id_column,
|
||||
)
|
||||
splits = ordered_splits(file_splits, args.baseline_split)
|
||||
grouped_values = build_group_values(rows, splits, args.split_column, params)
|
||||
baseline_stats = compute_baseline_stats(grouped_values, args.baseline_split, params)
|
||||
group_means = compute_group_means(grouped_values, params)
|
||||
zscores = compute_group_zscores(group_means, baseline_stats, splits, params)
|
||||
|
||||
plot_split_parameter_panels(
|
||||
rows=rows,
|
||||
splits=splits,
|
||||
params=params,
|
||||
split_col=args.split_column,
|
||||
baseline_stats=baseline_stats,
|
||||
output_path=output_dir / "split_parameter_panels.png",
|
||||
dpi=args.figure_dpi,
|
||||
)
|
||||
plot_group_zscore_heatmap(
|
||||
splits=splits,
|
||||
params=params,
|
||||
zscores=zscores,
|
||||
baseline_split=args.baseline_split,
|
||||
output_path=output_dir / "group_zscore_heatmap.png",
|
||||
dpi=args.figure_dpi,
|
||||
)
|
||||
plot_group_drift_ranking(
|
||||
splits=splits,
|
||||
params=params,
|
||||
zscores=zscores,
|
||||
baseline_split=args.baseline_split,
|
||||
output_path=output_dir / "group_drift_ranking.png",
|
||||
dpi=args.figure_dpi,
|
||||
top_k=args.top_k,
|
||||
)
|
||||
plot_parameter_pair_relationships(
|
||||
rows=rows,
|
||||
splits=splits,
|
||||
split_col=args.split_column,
|
||||
id_col=args.id_column,
|
||||
output_path=output_dir / "parameter_pair_relationships.png",
|
||||
dpi=args.figure_dpi,
|
||||
annotate_pairs=args.annotate_pairs,
|
||||
)
|
||||
write_report(
|
||||
output_path=output_dir / "group_comparison_report.txt",
|
||||
csv_path=csv_path,
|
||||
splits=splits,
|
||||
rows=rows,
|
||||
split_col=args.split_column,
|
||||
params=params,
|
||||
baseline_split=args.baseline_split,
|
||||
grouped_values=grouped_values,
|
||||
baseline_stats=baseline_stats,
|
||||
group_means=group_means,
|
||||
zscores=zscores,
|
||||
top_k=args.top_k,
|
||||
)
|
||||
|
||||
print("Saved outputs to:", output_dir)
|
||||
print(" - split_parameter_panels.png")
|
||||
print(" - group_zscore_heatmap.png")
|
||||
print(" - group_drift_ranking.png")
|
||||
print(" - parameter_pair_relationships.png")
|
||||
print(" - group_comparison_report.txt")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
375
tools/scripts_for_gt/visualization/EXAMPLES.md
Executable file
375
tools/scripts_for_gt/visualization/EXAMPLES.md
Executable 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}")
|
||||
```
|
||||
377
tools/scripts_for_gt/visualization/FILTER_GUIDE.md
Executable file
377
tools/scripts_for_gt/visualization/FILTER_GUIDE.md
Executable 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 | 快速测试 | 数据集配置 | 无(自动找第一个) |
|
||||
398
tools/scripts_for_gt/visualization/FLOW_ANALYSIS.md
Executable file
398
tools/scripts_for_gt/visualization/FLOW_ANALYSIS.md
Executable 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. **并行处理**: 可以使用多进程批量处理独立的图像
|
||||
273
tools/scripts_for_gt/visualization/INDEX.md
Executable file
273
tools/scripts_for_gt/visualization/INDEX.md
Executable 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)
|
||||
378
tools/scripts_for_gt/visualization/README.md
Executable file
378
tools/scripts_for_gt/visualization/README.md
Executable 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框参数到文件
|
||||
- 与预测结果对比
|
||||
- 统计分析(深度分布、尺寸分布等)
|
||||
103
tools/scripts_for_gt/visualization/SAVE_TYPES_EXAMPLES.md
Executable file
103
tools/scripts_for_gt/visualization/SAVE_TYPES_EXAMPLES.md
Executable 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
|
||||
2532
tools/scripts_for_gt/visualization/plots.py
Executable file
2532
tools/scripts_for_gt/visualization/plots.py
Executable file
File diff suppressed because it is too large
Load Diff
849
tools/scripts_for_gt/visualization/plots_3d.py
Executable file
849
tools/scripts_for_gt/visualization/plots_3d.py
Executable 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
|
||||
187
tools/scripts_for_gt/visualization/test_visualization.py
Executable file
187
tools/scripts_for_gt/visualization/test_visualization.py
Executable 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=== 测试2:ROI变换后可视化 ===")
|
||||
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()
|
||||
209
tools/scripts_for_gt/visualization/visualize_batch.py
Executable file
209
tools/scripts_for_gt/visualization/visualize_batch.py
Executable 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()
|
||||
54
tools/scripts_for_gt/visualization/visualize_batch.sh
Executable file
54
tools/scripts_for_gt/visualization/visualize_batch.sh
Executable 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}"
|
||||
|
||||
60
tools/scripts_for_gt/visualization/visualize_batch_case.sh
Executable file
60
tools/scripts_for_gt/visualization/visualize_batch_case.sh
Executable 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[@]}"
|
||||
474
tools/scripts_for_gt/visualization/visualize_filtered_train.py
Executable file
474
tools/scripts_for_gt/visualization/visualize_filtered_train.py
Executable 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: 目标类别ID(None表示不过滤类别)
|
||||
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()
|
||||
846
tools/scripts_for_gt/visualization/visualize_single_frame.py
Executable file
846
tools/scripts_for_gt/visualization/visualize_single_frame.py
Executable 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到特定大小)
|
||||
# 这里假设不需要额外resize,ROI大小即为目标大小
|
||||
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()
|
||||
Reference in New Issue
Block a user