课程定位: 围棋打谱辅助系统系列课程的 Part 1.2,深度记录 YOLOv8-nano 星位检测模型从 Python 训练到 C++ ONNX 推理的落地实战——4 个 bug 逐个排查、根因分析、修复验证。
前置知识: Part 1.1 的 YOLO 星位方案、C++ OpenCV DNN 推理基础
预计阅读: 25 分钟
棋盘自动标定与矫正 —— 问题排查与修复记录
日期:2026-05-14
项目:go-board-cpp
模块:StarPointDetector/PersistentCalibrator/onAutoCalibrate()
1. 问题现象
自动标定(Auto Calibrate)功能按下后,检测到的 9 个星位点坐标完全错误,导致后续透视矫正产出的棋盘图严重变形、不可用。
典型输出(修复前):
[AutoCalib] Detected 9 points:
[0] (399.5, 24)
[1] (343.5, 468.5)
...
[AutoCalib] Spatial match: 9 points in 3 rows
[0] star(3,3) → px(260.5, 40)
[1] star(3,9) → px(399.5, 24)
[2] star(3,15) → px(511, 14.5) ← 所有 Y 挤在 14~40
...
所有检测点的 Y 坐标全部挤在画面顶部 14~40 像素之间,而真实的棋盘星位应该均匀分布在画面高度上。9-pt RANSAC 仅收 4/9 inlier。
2. 排查过程
2.1 验证模型训练质量
训练数据:55 张原始标注图 + 275 张增强图,共 330 张。
训练指标(runs/detect/go-star/results.csv,YOLOv8-nano, epoch 97):
| 指标 | 值 |
|---|---|
| mAP50 | 0.995 |
| mAP50-95 | 0.844 |
| Precision | 1.0 |
| Recall | 1.0 |
结论:模型训练质量很高,问题不在模型本身。
2.2 编写诊断脚本对比 PyTorch vs ONNX
编写 scripts/diagnose_onnx.py,在同一张训练图上分别跑 PyTorch 和 ONNX 推理,逐通道打印输出。
PyTorch 结果(正确):
检出: 9 个框
[0] center=(312.9, 101.7) conf=0.910 ← 与标签 (309.6, 101.6) 差 3px
[1] center=(441.9, 147.5) conf=0.900 ← 与标签 (439.5, 148.4) 差 3px
...
ONNX 结果(异常):
输出 stats: min=-3.557 max=647.887 mean=148.014
高置信度检测:
[0] raw_conf=0.0000 conf(sigmoid)=0.5000 ← 第4通道全为 0
...
2.3 深入逐通道分析
编写 scripts/diagnose_onnx2.py,分别查看 ONNX 输出 5 个通道的统计:
ch[0] cx: min=3.32 max=641.19 mean=320.3 ← 正常
ch[1] cy: min=-5.08 max=647.20 mean=320.7 ← 正常
ch[2] w: min=0.01 max=336.31 mean=61.0 ← 正常
ch[3] h: min=5.58 max=308.53 mean=56.1 ← 正常
ch[4] conf: min=0.00 max=0.882 mean=0.0065 ← 有值!非全零
第 4 通道(置信度)并非全零,max=0.88,nonzero=8305/8400。问题不在 ONNX 模型本身。
3. 根因分析
Bug #1(🔴 致命):对已含 sigmoid 的置信度重复 sigmoid
YOLOv8 的 _inference() 在 ONNX 导出路径中执行:
y = torch.cat((dbox, cls.sigmoid()), 1)
ONNX 输出的第 4 通道已经是 [0, 1] 范围的置信度。但 C++ 代码又包了一层 sigmoid:
// 错误代码:
float rawConf = data[4 * NUM_DETECTIONS + i];
float conf = 1.0f / (1.0f + std::exp(-rawConf)); // ← 多余的 sigmoid!
后果:真实置信度为 0.0 的低质量检测,经 sigmoid 后变成 0.5,全部通过 confThresh=0.3。8400 个候选框中大量噪声被保留,NMS 随机选出 9 个位于画面外的垃圾框。
修复:直接取第 4 通道值作为置信度,不做额外变换。
// 修复后:
float conf = data[4 * NUM_DETECTIONS + i];
// YOLOv8 ONNX 输出第4通道已含 cls.sigmoid()
if (conf < confThresh) continue;
Bug #2(🔴 致命):blobFromImage 坐标空间理解错误
错误的假设
代码假设 cv::dnn::blobFromImage 的 crop=false 是 letterbox(等比缩放 + 居中填充):
// 错误的逆变换:
float scale = min(640.0/imgW, 640.0/imgH);
int padY = (640 - imgH*scale) / 2;
float y = (cy - padY) / scale; // ← letterbox 逆变换
实际行为
OpenCV 源码中,blobFromImage(crop=false) 调用的是 cv::resize(img, img, size, ...),传入目标尺寸 (640, 640)。cv::resize 指定目标尺寸时做的是直接拉伸(stretch),不保持宽高比。
实际行为 错误假设
┌──────────────┐ ┌──────────────┐
│ │ │██████████████│
│ 拉伸填充 │ │██ ██│
│ 640×640 │ │██ 等比缩放 ██│
│ │ │██ + 黑边 ██│
│ │ │██████████████│
└──────────────┘ └──────────────┘
direct stretch letterbox
数据验证
以 800×600 的测试图为例,标签坐标 (309.6, 101.6):
| 逆变换方式 | 公式 | 计算结果 | 误差 |
|---|---|---|---|
| letterbox(旧) | (cy - 80) / 0.8 |
35.5 | 66px ✗ |
| stretch(新) | cy × 600 / 640 |
101.9 | 0.3px ✓ |
拉伸逆变换与标签几乎完美吻合,证实根因。
修复
// 修复后:直接拉伸逆变换
float scaleX = (float)INPUT_SIZE / (float)imgW;
float scaleY = (float)INPUT_SIZE / (float)imgH;
float x = (cx - w * 0.5f) / scaleX;
float y = (cy - h * 0.5f) / scaleY;
float bw = w / scaleX;
float bh = h / scaleY;
Bug #3(🟡 鲁棒性):凸包 + sum/diff 选角策略脆弱
旧策略:
cv::convexHull(pts, hull, false);
// 从凸包顶点中选 4 个:minSum=左上, maxDiff=右上, maxSum=右下, minDiff=左下
问题:
- False positive 污染:只要模型多检出一个棋盘外的点,凸包就会包含它,极值选角选中错误顶点
- 棋盘旋转时失效:sum/diff 极值法假设棋盘近乎轴对齐;棋盘稍有倾斜即选错
- 浪费信息:检测到 9 个星位但只用 4 个
新策略:空间排序 + 身份匹配
1. 按 Y 坐标排序所有检测点
2. 分成 3 行(每行 ≤3 点)
3. 每行内按 X 排序 → 展平为行主序
4. 精确匹配到 9 个星位的棋盘身份:
row1: (3,3) (3,9) (3,15)
row2: (9,3) (9,9) (9,15)
row3: (15,3)(15,9)(15,15)
5. ≥6 个匹配点 → RANSAC 全单应(抗 outlier)
6. <6 个 → fallback 用 4 角星位精确解
优势:
- 不受棋盘旋转影响(只依赖相对空间关系)
- False positive 自动被 RANSAC 排除
- 充分利用全部 9 个星位信息
Bug #4(🟢 次要):推理输入颜色空间不匹配
- 训练:
cv2.imread()加载 → BGR;Ultralytics 参数bgr=0不做通道交换 - C++ 推理(旧):
cvtColor(BGR2RGB)→ 模型收到 RGB
虽然星位点是黑色(不受通道交换影响),但棋盘木色背景的色调完全改变,影响模型判断。
修复:去掉 cvtColor(BGR2RGB),保持 BGR 不变。
4. 其他改进
4.1 PersistentCalibrator::calibrate() 容错
// 旧:精确解,4 点中任一点有误差即全毁
m_H = cv::findHomography(dst, src, 0);
// 新:RANSAC 优先,失败回退精确解
m_H = cv::findHomography(dst, src, cv::RANSAC, 3.0, mask);
if (m_H.empty())
m_H = cv::findHomography(dst, src, 0);
4.2 诊断日志
在 StarPointDetector::detect() 增加首个高置信度检测的原始值打印:
[StarDetect] First high-conf detection:
conf=0.826 (already sigmoid'd by ONNX)
cx=250.9 cy=108.7 w=22.9 h=23.1
imgSize=800x600 scaleX=0.8 scaleY=1.06667
5. 验证
Python 验证
编写 scripts/verify_stretch.py,用拉伸逆变换在全部 55 张训练图上测试 ONNX 模型:
结果:53/55 正确(≥8/9 点匹配,误差 <20px)
第一张图详细误差:
[0] label(310,102) → dist=3.4px ✓
[1] label(440,148) → dist=2.1px ✓
[2] label(548,184) → dist=1.2px ✓
[3] label(295,265) → dist=1.1px ✓
[4] label(429,295) → dist=0.4px ✓
[5] label(540,318) → dist=2.1px ✓
[6] label(276,441) → dist=1.4px ✓
[7] label(417,452) → dist=1.2px ✓
[8] label(534,462) → dist=2.0px ✓
训练可视化
训练过程图像位于 runs/detect/go-star/:
labels.jpg— 验证集检测结果可视化train_batch*.jpg— 训练批次的框叠加图results.csv— 逐 epoch 指标
6. 修改文件清单
| 文件 | 修改内容 |
|---|---|
src/detect/StarPointDetector.cpp |
① 去掉多重 sigmoid ② 拉伸逆变换替代 letterbox ③ 保持 BGR ④ 增加诊断日志 |
src/main_calib.cpp |
凸包+sum/diff → 空间排序+身份匹配+RANSAC |
src/detect/PersistentCalibrator.cpp |
精确解 → RANSAC 优先回退精确解 |
scripts/diagnose_onnx.py |
新增:PyTorch vs ONNX 对比诊断 |
scripts/diagnose_onnx2.py |
新增:逐通道输出统计分析 |
scripts/debug_single.py |
新增:单图 ONNX/PyTorch/标签 三方对比 |
scripts/validate_onnx.py |
新增:全量训练图验证 |
scripts/verify_stretch.py |
新增:拉伸逆变换修复验证 |
7. 经验教训
- 不要信任框架默认行为的直觉理解 —
blobFromImage的crop=false不等于 letterbox,需查源码确认 - 双重激活是隐蔽 bug — sigmoid 两次不会被编译器/静态分析发现,只能通过数值诊断暴露
- 逐通道看模型输出比猜测高效得多 —
diagnose_onnx2.py花 10 分钟写出,但直接定位了"第 4 通道非零"这个关键事实 - 训练指标好 ≠ 推理管线正确 — mAP50=0.995 的模型产出的坐标在错误逆变换下依然全错