课程定位: 围棋打谱辅助系统系列课程的 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::blobFromImagecrop=falseletterbox(等比缩放 + 居中填充):

// 错误的逆变换:
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=左下

问题:

  1. False positive 污染:只要模型多检出一个棋盘外的点,凸包就会包含它,极值选角选中错误顶点
  2. 棋盘旋转时失效:sum/diff 极值法假设棋盘近乎轴对齐;棋盘稍有倾斜即选错
  3. 浪费信息:检测到 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. 经验教训

  1. 不要信任框架默认行为的直觉理解blobFromImagecrop=false 不等于 letterbox,需查源码确认
  2. 双重激活是隐蔽 bug — sigmoid 两次不会被编译器/静态分析发现,只能通过数值诊断暴露
  3. 逐通道看模型输出比猜测高效得多diagnose_onnx2.py 花 10 分钟写出,但直接定位了"第 4 通道非零"这个关键事实
  4. 训练指标好 ≠ 推理管线正确 — mAP50=0.995 的模型产出的坐标在错误逆变换下依然全错