Phase: P1 升级 — 棋子识别方案演进 | 依赖: P0 棋盘矫正
目标: 用 YOLO 深度学习模型替代 ROI 双阈值方案,提升棋子检测准确率和光照鲁棒性。
0. 升级背景
当前的 ROI 双阈值方案(StoneDetector)在矫正后的标准棋盘图上准确率约 90%,但存在以下局限:
| 场景 | ROI 阈值表现 | 问题 |
|---|---|---|
| 光照均匀 + 标准棋子 | ~92% | 正常工作 |
| 侧光 / 强反光 | ~78% | stddev 误判 |
| 棋子部分遮挡(手指、落子瞬间) | ~65% | ROI 被污染 |
| 非标准棋子(云子薄、双面凸) | ~72% | 阈值参数偏移 |
| 脏污 / 磨损棋盘 | ~75% | 背景色不均 |
YOLO 的优势: 端到端学习棋子视觉特征(形状、纹理、相对位置),天然抗光照变化,能同时输出棋子位置和类别,不再依赖先验的交点网格。
1. 模型选型
1.1 YOLO 家族对比
| 模型 | 参数量 | mAP50 (COCO) | 推理速度 (CPU) | 推理速度 (GPU) | 适用场景 |
|---|---|---|---|---|---|
| YOLOv5n | 1.9M | 28.0% | ~35ms | ~2ms | 极致轻量 |
| YOLOv5s | 7.2M | 37.4% | ~50ms | ~3ms | 轻量通用 |
| YOLOv8n | 3.2M | 37.3% | ~28ms | ~1.8ms | 推荐轻量 |
| YOLOv8s | 11.2M | 44.9% | ~45ms | ~2.5ms | 推荐平衡 |
| YOLOv11n | 2.6M | 39.5% | ~25ms | ~1.5ms | 最新轻量 |
| YOLOv11s | 9.4M | 47.0% | ~40ms | ~2.2ms | 最新平衡 |
1.2 围棋棋子检测的选型考量
围棋棋子检测是一个 极简目标检测任务:
- 只有 2 个类别(黑子/白子)
- 目标外观高度一致(圆形、单色)
- 尺寸在图中占比稳定(棋盘矫正后 grid 已知)
- 无遮挡或极少遮挡
推荐 YOLOv8n:3.2M 参数,CPU 推理 ~28ms,对二分类棋子检测绰绰有余。训练收敛快,导出 ONNX 后 C++ 部署极简。
备选 YOLOv11n:如果想用最新模型,
yolo11n在同等速度下 mAP 略高,但生态和文档不如 v8 成熟。
2. 数据集构建
2.1 数据采集
数据来源:
├── 真实拍摄: ESP32-CAM / USB 摄像头俯拍棋盘
│ ├── 不同光照: 自然光、台灯、顶灯、侧光、暗光
│ ├── 不同棋子: 云子(单面凸)、蛤碁石、双面凸棋子
│ ├── 不同角度: 轻微偏转 ±10°
│ └── 不同阶段: 空盘、中盘、官子
├── 合成增强: 在空棋盘图上随机放置棋子 Circle
│ ├── 控制棋子位置 (交点坐标已知)
│ └── 添加噪声、模糊、亮度变化
└── 公开数据集: 如果有已标注的围棋图片可直接使用
目标规模: 2000-5000 张标注图片,训练/验证/测试 = 7:2:1
2.2 标注格式
使用 YOLO 格式(归一化坐标):
dataset/
├── images/
│ ├── train/
│ │ ├── board_001.jpg
│ │ ├── board_002.jpg
│ │ └── ...
│ └── val/
│ └── ...
├── labels/
│ ├── train/
│ │ ├── board_001.txt # 每行: class_id cx cy w h
│ │ └── ...
│ └── val/
│ └── ...
└── data.yaml
data.yaml:
path: ./dataset
train: images/train
val: images/val
nc: 2
names:
0: black
1: white
2.3 标注流程
标注是模型质量的天花板。围棋棋子检测标注相对简单(只有 2 类),但需要保证框的精度和一致性。以下是三种推荐标注方案:
方案 A:labelImg 手动标注(本地,免费)
安装:
pip install labelImg
labelImg # 启动 GUI
标注步骤:
1. labelImg → "Open Dir" → 选择 dataset/images/train/
2. "Change Save Dir" → 选择 dataset/labels/train/
3. 切换到 YOLO 格式(左侧栏选择 "YOLO")
4. 快捷键:
W → 创建矩形框
A / D → 上一张 / 下一张图片
Ctrl+S → 保存当前标注
Delete → 删除选中框
5. 每张图逐个框中所有棋子 → 选择类别 "black" / "white"
6. 完成后 labels/train/ 下自动生成同名 .txt 文件
技巧:
- 框要紧贴棋子轮廓,不要留过大边距
- 棋子被手指/阴影部分遮挡时,框完整棋子(包含被遮挡部分)
- 每 50 张图用
labelImg的预览功能复查框质量
方案 B:Roboflow 云端标注(协作,有免费额度)
适合团队协作或需要版本管理:
1. roboflow.com 注册 → Create Workspace "go-stone-detection"
2. Upload Dataset → 拖入图片文件夹
3. 在 Web 界面标注(AI Assist 可自动预标注)
4. Label → Generate → 选择 YOLOv8 格式导出
5. 导出时可选预处理: Resize 640×640, Auto-Orient
6. 下载 zip → 解压到 dataset/
Roboflow 优势:
- 内置版本管理,每次修改都有记录
- AI 辅助标注(需付费)可大幅加速
- 自动划分 train/val/test
方案 C:ROI 检测器半自动标注(推荐,最快)
利用现有的 StoneDetector + 已知网格坐标,批量生成初始标注,人工只需修正错误:
Step 1 — 自动生成标注:
#!/usr/bin/env python3
# auto_label_pipeline.py
"""
半自动标注流水线:
1. 用现有 ROI 检测器跑一遍 → 生成 YOLO 标注
2. 低置信度样本标记为需人工复核
3. 人工微调后直接可用
"""
import cv2
import os
from pathlib import Path
def generate_yolo_labels(board_image, grid_points, stone_state,
conf_threshold=0.8):
"""
board_image: 矫正后的棋盘图
grid_points: 361 交点坐标 (由 BoardRectifier 输出)
stone_state: ROI 检测器输出的局面字符串
conf_threshold: 低于此值的 ROI 特征记为 low_confidence
返回: (labels_list, low_confidence_indices)
"""
h, w = board_image.shape[:2]
cell_size = w / 19
labels = []
low_conf = []
for i, (pt, stone) in enumerate(zip(grid_points, stone_state)):
if stone == '.':
continue
class_id = 0 if stone == 'B' else 1
radius_px = cell_size * 0.35
# 归一化
cx = pt[0] / w
cy = pt[1] / h
bw = (2 * radius_px) / w
bh = (2 * radius_px) / h
labels.append(f"{class_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}")
# 检查 ROI 置信度(需从 StoneDetector 获取)
# 这里简化:周围 8 邻域有冲突标记则低置信
pass
return labels, low_conf
def batch_auto_label(image_dir, grid_file, stone_state_file, output_dir):
"""
批量处理所有矫正后的棋盘图
grid_file: 每行一张图的 361 点坐标 (JSON/CSV)
stone_state_file: ROI 检测器输出
"""
import json
with open(grid_file) as f:
grids = json.load(f) # {filename: [[x,y], ...]}
with open(stone_state_file) as f:
states = json.load(f) # {filename: "BWB...W."}
os.makedirs(output_dir, exist_ok=True)
review_list = []
for fname, grid in grids.items():
img_path = os.path.join(image_dir, fname)
if not os.path.exists(img_path):
continue
img = cv2.imread(img_path)
state = states.get(fname, '.' * 361)
labels, low_conf = generate_yolo_labels(img, grid, state)
# 写入 YOLO 标注
label_path = os.path.join(output_dir,
Path(fname).stem + '.txt')
with open(label_path, 'w') as f:
f.write('\n'.join(labels))
if low_conf:
review_list.append(fname)
# 输出需人工复核的图片列表
print(f"Auto-labeled: {len(grids)} images")
print(f"Needs review: {len(review_list)} images")
with open('review_list.txt', 'w') as f:
f.write('\n'.join(review_list))
if __name__ == '__main__':
batch_auto_label(
image_dir='dataset/images/train/',
grid_file='grid_coords.json',
stone_state_file='roi_states.json',
output_dir='dataset/labels/train/'
)
Step 2 — 人工复核:
# 仅打开需复核的图片
labelImg dataset/images/train/ --labels dataset/labels/train/ \
--review-list review_list.txt
Step 3 — 标注校验:
# validate_labels.py — 检查标注格式与一致性
import os
def validate_dataset(labels_dir, images_dir):
errors = []
for label_file in os.listdir(labels_dir):
if not label_file.endswith('.txt'):
continue
name = label_file.replace('.txt', '')
img_file = name + '.jpg'
if img_file not in os.listdir(images_dir):
errors.append(f"Missing image: {img_file}")
continue
with open(os.path.join(labels_dir, label_file)) as f:
lines = f.readlines()
for i, line in enumerate(lines):
parts = line.strip().split()
if len(parts) != 5:
errors.append(f"{label_file}:{i} bad format")
continue
cls_id = int(parts[0])
if cls_id not in (0, 1):
errors.append(f"{label_file}:{i} bad class {cls_id}")
cx, cy, bw, bh = map(float, parts[1:5])
if not (0 <= cx <= 1 and 0 <= cy <= 1 and
0 < bw <= 1 and 0 < bh <= 1):
errors.append(
f"{label_file}:{i} out of bounds: {cx:.3f},{cy:.3f}"
)
if errors:
print(f"⚠ Found {len(errors)} errors:")
for e in errors[:20]: # 最多显示 20 个
print(f" {e}")
else:
print("✅ All labels valid")
validate_dataset('dataset/labels/train/', 'dataset/images/train/')
标注流程总结
flowchart LR
RAW["原始棋盘图<br/>(矫正后)"] --> AUTO{"ROI 检测器<br/>自动标注 ?"}
AUTO -->|"矫正后 + grid 已知"| GEN["auto_label.py<br/>批量生成 YOLO 标注"]
AUTO -->|"手拍 / 未矫正"| MANUAL["labelImg / Roboflow<br/>手动标注"]
GEN --> REVIEW["低置信度样本<br/>人工复核"]
MANUAL --> VALID["validate_labels.py<br/>格式校验"]
REVIEW --> VALID
VALID --> SPLIT["7:2:1 划分<br/>train/val/test"]
SPLIT --> TRAIN["开始训练"]
推荐策略: 先用手动标注 300-500 张做种子数据集,训练一个初始 YOLO 模型。然后用这个模型对新图做预测,人工修正错误预测,迭代扩充数据集。这是工业界标准的 主动学习(Active Learning) 标注循环。
2.4 数据增强策略
# Ultralytics 内置增强,在 data.yaml 或训练参数中配置
augmentation:
hsv_h: 0.015 # 色调抖动(棋子颜色不变,不需要大 H 变化)
hsv_s: 0.3 # 饱和度
hsv_v: 0.3 # 亮度(关键!模拟光照变化)
degrees: 5.0 # 旋转 ±5°
translate: 0.05 # 平移
scale: 0.2 # 缩放(模拟棋子到摄像头距离变化)
shear: 2.0 # 错切
flipud: 0.0 # 不上下翻转(棋盘有方向)
fliplr: 0.5 # 左右翻转
mosaic: 0.0 # Mosaic 对棋子检测意义不大,关闭
erasing: 0.1 # 随机擦除(模拟遮挡)
3. Python 训练流水线
3.1 环境准备
# 创建虚拟环境
python3 -m venv yolo_env
source yolo_env/bin/activate
# 安装依赖
pip install ultralytics torch torchvision onnx onnxruntime
pip install opencv-python matplotlib seaborn
3.2 训练脚本
#!/usr/bin/env python3
# train_stone_detector.py
"""
围棋棋子 YOLO 检测器训练脚本
"""
from ultralytics import YOLO
import torch
def main():
# 检查设备
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"[INFO] Training on: {device}")
# ---- 方式 1: 从头训练 ----
model = YOLO('yolov8n.pt') # 预训练权重作为起点
results = model.train(
data='dataset/data.yaml',
epochs=100,
imgsz=640,
batch=16 if device == 'cuda' else 8,
device=device,
workers=4,
# 优化器
optimizer='AdamW',
lr0=0.001,
lrf=0.01,
momentum=0.937,
weight_decay=0.0005,
# 数据增强(围棋棋子场景定制)
hsv_h=0.0, # 棋子颜色固定(黑/白),不调色调
hsv_s=0.2, # 轻微饱和度变化
hsv_v=0.4, # 亮度变化加大(模拟光照)
degrees=5.0, # 小角度旋转
translate=0.05,
scale=0.3, # 缩放范围
flipud=0.0, # 不上下翻转
fliplr=0.5,
mosaic=0.0, # 围棋棋盘是规则网格,Mosaic 破坏空间结构
erasing=0.1,
# 训练策略
warmup_epochs=3,
cos_lr=True,
close_mosaic=0, # mosaic=0 不需要这个
# 保存
save=True,
save_period=10,
project='runs/stone_detector',
name='yolov8n_stone',
exist_ok=True,
# 验证
val=True,
# 早停
patience=20,
)
print(f"[INFO] Best model saved at: {results.save_dir}")
# 验证最佳模型
metrics = model.val()
print(f"[INFO] Validation metrics:")
print(f" mAP50: {metrics.box.map50:.4f}")
print(f" mAP50-95: {metrics.box.map:.4f}")
print(f" Precision: {metrics.box.p[0]:.4f}")
print(f" Recall: {metrics.box.r[0]:.4f}")
if __name__ == '__main__':
main()
3.3 训练监控
# 启动训练 + TensorBoard
tensorboard --logdir runs/stone_detector --port 6006
重点关注:
- mAP50:目标 > 0.95(围棋棋子是简单目标)
- Precision vs Recall:Precision 优先(不能把空交叉点识别为棋子)
- confusion_matrix:黑白之间是否有混淆(极少,灰度差足够大)
- val_batch 预览:检查预测框是否贴合棋子轮廓
3.4 模型评估与调试
# eval_model.py
from ultralytics import YOLO
model = YOLO('runs/stone_detector/yolov8n_stone/weights/best.pt')
# 在验证集上评估
results = model.val()
# 在单张图上预测并可视化
results = model.predict(
source='test_images/',
save=True,
conf=0.25, # 置信度阈值
iou=0.45, # NMS IoU 阈值
show_labels=True,
show_conf=True,
)
# 导出混淆矩阵
results = model.val(plots=True)
3.5 模型导出
训练完成后,导出为 ONNX 供 C++ 端使用:
# export_to_onnx.py
from ultralytics import YOLO
model = YOLO('runs/stone_detector/yolov8n_stone/weights/best.pt')
# 导出 ONNX(静态输入尺寸)
model.export(
format='onnx',
imgsz=640,
opset=12, # ONNX opset 版本,兼容 OpenCV 4.x
simplify=True, # onnx-simplifier 简化计算图
dynamic=False, # 静态尺寸,推理更快
half=False, # FP32,CPU 推理
)
print("[INFO] Exported to: runs/stone_detector/yolov8n_stone/weights/best.onnx")
# 可选:导出 TensorRT(如果有 Jetson 或 GPU 部署)
# model.export(format='engine', imgsz=640, half=True, device=0)
4. C++ 端推理方案
4.1 方案对比
| 方案 | 依赖 | CPU 速度 | GPU 速度 | 部署难度 | 推荐场景 |
|---|---|---|---|---|---|
| OpenCV DNN | 仅 OpenCV | ~35ms | ~5ms (CUDA) | ★☆☆ | 快速验证,无额外依赖 |
| ONNX Runtime | libonnxruntime | ~25ms | ~3ms (CUDA) | ★★☆ | 生产部署首选 |
| TensorRT | NVIDIA CUDA + TensorRT | N/A | ~1.5ms | ★★★ | Jetson / GPU 极致性能 |
| libtorch | PyTorch C++ | ~60ms | ~5ms | ★★☆ | 需要 Torch 生态时 |
本项目推荐 ONNX Runtime:跨平台、CPU 推理 25ms 内完成、不依赖 NVIDIA 驱动、API 简洁。
4.2 ONNX Runtime C++ 集成
4.2.1 安装准备
# Ubuntu/Debian
sudo apt install libonnxruntime-dev
# 或从源码/Microsoft 下载预编译包
# https://github.com/microsoft/onnxruntime/releases
wget https://github.com/microsoft/onnxruntime/releases/download/v1.18.0/onnxruntime-linux-x64-1.18.0.tgz
tar xzf onnxruntime-linux-x64-1.18.0.tgz -C /opt/
CMakeLists.txt 配置:
# ONNX Runtime
set(ONNXRUNTIME_ROOT /opt/onnxruntime-linux-x64-1.18.0)
find_library(ONNXRUNTIME_LIB onnxruntime
PATHS ${ONNXRUNTIME_ROOT}/lib
NO_DEFAULT_PATH)
add_library(onnxruntime SHARED IMPORTED)
set_target_properties(onnxruntime PROPERTIES
IMPORTED_LOCATION ${ONNXRUNTIME_LIB}
INTERFACE_INCLUDE_DIRECTORIES ${ONNXRUNTIME_ROOT}/include)
4.2.2 YOLO 推理器封装
// detect/YoloStoneDetector.h
#pragma once
#include <onnxruntime_cxx_api.h>
#include <opencv2/opencv.hpp>
#include <vector>
#include <string>
/// YOLO 检测到的单个棋子
struct YoloDetection {
int classId; // 0 = 黑子, 1 = 白子
float confidence; // 置信度 [0, 1]
cv::Rect2f box; // 检测框 (左上角, 宽, 高)
cv::Point2f center; // 框中心 = 棋子中心
};
/// YOLO ONNX 棋子检测器
class YoloStoneDetector {
public:
struct Config {
std::string modelPath; // ONNX 模型路径
float confThreshold = 0.25; // 置信度阈值
float iouThreshold = 0.45; // NMS IoU 阈值
int inputWidth = 640; // 模型输入宽度
int inputHeight = 640; // 模型输入高度
bool useCUDA = false; // ONNX CUDA EP
};
explicit YoloStoneDetector(const Config &cfg);
~YoloStoneDetector();
/// 检测棋子,返回所有检测结果
std::vector<YoloDetection> detect(const cv::Mat &bgrImage);
/// 与棋盘网格对齐:将检测结果映射到 19x19 局面
std::string alignToBoard(
const std::vector<YoloDetection> &detections,
const std::vector<cv::Point2f> &gridPoints,
float snapThreshold = 0.4 // 占 cell 比例阈值
);
private:
void preprocess(const cv::Mat &src, cv::Mat &dst);
std::vector<YoloDetection> postprocess(
const float *output, // [1, 84, 8400] or similar
const cv::Size &originalSize);
Ort::Env m_env;
Ort::Session m_session;
Ort::AllocatorWithDefaultOptions m_allocator;
// 输入/输出节点名(因模型而异,需要查询)
std::string m_inputName;
std::string m_outputName;
Config m_config;
int m_numClasses; // 类别数, 2
int m_numOutputs; // 每行输出 = 4 + numClasses (xywh + scores)
};
4.2.3 推理实现
// detect/YoloStoneDetector.cpp
#include "YoloStoneDetector.h"
#include <onnxruntime_cxx_api.h>
#include <algorithm>
#include <cmath>
YoloStoneDetector::YoloStoneDetector(const Config &cfg)
: m_env(ORT_LOGGING_LEVEL_WARNING, "YoloStone")
, m_session(nullptr)
, m_config(cfg)
, m_numClasses(2)
, m_numOutputs(4 + 2) // xywh + black, white
{
Ort::SessionOptions opts;
opts.SetIntraOpNumThreads(4);
opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
if (m_config.useCUDA) {
OrtCUDAProviderOptions cudaOpts;
cudaOpts.device_id = 0;
opts.AppendExecutionProvider_CUDA(cudaOpts);
}
m_session = Ort::Session(m_env, m_config.modelPath.c_str(), opts);
// 获取输入输出名称
auto inputName = m_session.GetInputNameAllocated(0, m_allocator);
m_inputName = inputName.get();
auto outputName = m_session.GetOutputNameAllocated(0, m_allocator);
m_outputName = outputName.get();
}
YoloStoneDetector::~YoloStoneDetector() = default;
void YoloStoneDetector::preprocess(const cv::Mat &src, cv::Mat &dst) {
// 1. Letterbox resize → 640x640
cv::Mat letterbox;
float scale = std::min(
(float)m_config.inputWidth / src.cols,
(float)m_config.inputHeight / src.rows
);
int newW = (int)(src.cols * scale);
int newH = (int)(src.rows * scale);
cv::resize(src, letterbox, cv::Size(newW, newH));
int padW = m_config.inputWidth - newW;
int padH = m_config.inputHeight - newH;
int top = padH / 2, bottom = padH - top;
int left = padW / 2, right = padW - left;
cv::copyMakeBorder(letterbox, letterbox,
top, bottom, left, right, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114));
// 2. BGR → RGB + HWC → CHW + normalize to [0, 1]
letterbox.convertTo(dst, CV_32FC3, 1.0 / 255.0);
}
std::vector<YoloDetection> YoloStoneDetector::detect(const cv::Mat &bgrImage) {
// ---- Preprocess ----
cv::Mat blob;
preprocess(bgrImage, blob);
int64_t inputShape[] = {1, 3, m_config.inputHeight, m_config.inputWidth};
auto memoryInfo = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
Ort::Value inputTensor = Ort::Value::CreateTensor<float>(
memoryInfo,
(float*)blob.data,
3 * m_config.inputHeight * m_config.inputWidth,
inputShape, 4
);
// ---- Inference ----
const char *inputNames[] = { m_inputName.c_str() };
const char *outputNames[] = { m_outputName.c_str() };
auto outputs = m_session.Run(
Ort::RunOptions{nullptr},
inputNames, &inputTensor, 1,
outputNames, 1
);
// ---- Postprocess ----
auto *rawOutput = outputs[0].GetTensorMutableData<float>();
auto outputShape = outputs[0].GetTensorTypeAndShapeInfo().GetShape();
return postprocess(rawOutput, bgrImage.size());
}
std::vector<YoloDetection> YoloStoneDetector::postprocess(
const float *output,
const cv::Size &originalSize)
{
// YOLOv8 ONNX 输出: [1, 6, 8400] or [1, 84, 8400] (取决于导出方式)
// 这里假设简化版输出 [num_dets, 6] → [x1, y1, x2, y2, conf, class]
// 参考 ultralytics 默认导出格式
std::vector<YoloDetection> detections;
// TODO: 根据实际 ONNX 输出格式调整解析逻辑
// 典型 YOLOv8 输出: [1, 84, 8400]
// rows 0-3: 中心点 xywh (归一化)
// rows 4-83: 80 类 score
// 围棋简化后: [1, 6, 8400]
// rows 0-3: xywh
// row 4: black score
// row 5: white score
int numDets = 8400; // 典型值,需要从实际输出 shape 获取
for (int i = 0; i < numDets; ++i) {
// 解析两类 score
float scoreBlack = output[4 * numDets + i];
float scoreWhite = output[5 * numDets + i];
int bestClass;
float maxScore;
if (scoreBlack > scoreWhite) {
bestClass = 0;
maxScore = scoreBlack;
} else {
bestClass = 1;
maxScore = scoreWhite;
}
if (maxScore < m_config.confThreshold) continue;
// xywh 归一化 → 像素坐标
float cx = output[0 * numDets + i];
float cy = output[1 * numDets + i];
float w = output[2 * numDets + i];
float h = output[3 * numDets + i];
float x1 = (cx - w / 2) * originalSize.width;
float y1 = (cy - h / 2) * originalSize.height;
float bw = w * originalSize.width;
float bh = h * originalSize.height;
detections.push_back({
bestClass,
maxScore,
cv::Rect2f(x1, y1, bw, bh),
cv::Point2f(cx * originalSize.width, cy * originalSize.height)
});
}
// NMS(可选,YOLOv8 内置 NMS 时不需要)
return detections;
}
std::string YoloStoneDetector::alignToBoard(
const std::vector<YoloDetection> &detections,
const std::vector<cv::Point2f> &gridPoints,
float snapThreshold)
{
// 361 个交叉点初始化为空
std::string board(361, '.');
// 计算 cell size
float cellSize = cv::norm(gridPoints[0] - gridPoints[1]); // 相邻交点距离
for (const auto &det : detections) {
// 找最近的网格交点
float minDist = FLT_MAX;
int nearestIdx = -1;
for (int i = 0; i < 361; ++i) {
float dist = cv::norm(det.center - gridPoints[i]);
if (dist < minDist) {
minDist = dist;
nearestIdx = i;
}
}
// 阈值检查:离网格交点的距离 < cell 的一半
if (minDist < cellSize * 0.5 && nearestIdx >= 0) {
board[nearestIdx] = (det.classId == 0) ? 'B' : 'W';
}
}
return board;
}
4.3 OpenCV DNN 方案(备选,零额外依赖)
如果不想引入 ONNX Runtime 依赖,可以用 OpenCV 的 DNN 模块直接加载 ONNX:
// detect/CvDnnYoloDetector.h
#pragma once
#include <opencv2/dnn.hpp>
#include <opencv2/opencv.hpp>
class CvDnnYoloDetector {
public:
explicit CvDnnYoloDetector(const std::string &onnxPath) {
m_net = cv::dnn::readNetFromONNX(onnxPath);
// 尝试 OpenCL / CUDA 后端
m_net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV);
m_net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU);
}
std::vector<YoloDetection> detect(const cv::Mat &bgr) {
// 构建 blob
cv::Mat blob = cv::dnn::blobFromImage(
bgr, 1.0/255.0, cv::Size(640, 640),
cv::Scalar(), true, false // swapRB=true (BGR→RGB)
);
m_net.setInput(blob);
cv::Mat output = m_net.forward();
return parseOutput(output, bgr.size());
}
private:
cv::dnn::Net m_net;
// parseOutput() 逻辑同 ONNX Runtime 版本
};
取舍: OpenCV DNN 方便调试,但 ONNX Runtime 推理快约 30%,且支持更多 Execution Provider(TensorRT、OpenVINO 等)。
5. 与现有系统集成
5.1 替换 GoBoardDetector 中的棋子识别
// 在 GoBoardDetector 中增加 YOLO 路径
class GoBoardDetector : public IBoardDetector {
public:
enum class StoneMode {
Threshold, // 原有 ROI 双阈值方案
YOLO // YOLO ONNX 推理
};
void setStoneMode(StoneMode mode, const std::string &modelPath = "");
// 原有接口不变
std::string recognizeStones(
const cv::Mat &dewarpedBgr,
const std::vector<cv::Point2f> &gridPixel) override
{
if (m_stoneMode == StoneMode::YOLO && m_yoloDetector) {
auto dets = m_yoloDetector->detect(dewarpedBgr);
return m_yoloDetector->alignToBoard(dets, gridPixel);
}
// fallback: 原有阈值方案
return m_stoneDetector.classify(dewarpedBgr, gridPixel);
}
private:
StoneMode m_stoneMode = StoneMode::Threshold;
std::unique_ptr<YoloStoneDetector> m_yoloDetector;
StoneDetector m_stoneDetector;
};
5.2 集成架构
flowchart TD
CAM["ESP32-CAM<br/>或 USB 摄像头"] --> FRAME["视频帧"]
FRAME --> RECT["BoardRectifier<br/>透视矫正 → 600×600"]
RECT --> MODE{"棋子检测模式"}
MODE -->|"ROI 阈值"| THRESH["StoneDetector<br/>361 交点 ROI 遍历"]
MODE -->|"YOLO"| YOLO["YoloStoneDetector<br/>ONNX 端到端推理"]
THRESH --> ALIGN["alignToBoard()<br/>检测结果 → 网格映射"]
YOLO --> ALIGN
ALIGN --> STATE["MoveDetector<br/>帧差法落子检测"]
STATE --> GAME["GoGame<br/>CommandInvoker<br/>规则引擎"]
5.3 性能对比与切换策略
| 指标 | ROI 阈值 | YOLO ONNX |
|---|---|---|
| 准确率(光照均匀) | 92% | 98% |
| 准确率(复杂光照) | 78% | 95% |
| CPU 推理耗时 | ~2ms | ~25ms |
| GPU 推理耗时 | N/A | ~2ms |
| 内存占用 | ~1MB | ~45MB |
| 模型文件 | 无 | ~6MB (.onnx) |
| 冷启动时间 | 即时 | ~200ms (加载模型) |
建议切换策略:
- 默认使用 ROI 阈值方案(快速启动、低资源)
- 检测到光照异常或连续多帧 ROI 置信度低 → 自动切换 YOLO
- 用户可手动切换(UI 开关)
6. 模型迭代与优化
6.1 知识蒸馏(大模型教小模型)
如果 YOLOv8n 在困难样本上表现不够好:
# 用 YOLOv8s 教师模型蒸馏 YOLOv8n
from ultralytics import YOLO
# 训练大模型
teacher = YOLO('yolov8s.pt')
teacher.train(data='dataset/data.yaml', epochs=100, imgsz=640)
# 蒸馏训练小模型
student = YOLO('yolov8n.pt')
student.train(
data='dataset/data.yaml',
epochs=100,
# 蒸馏参数(Ultralytics 目前需要手动实现蒸馏逻辑)
)
6.2 困难样本挖掘(Hard Negative Mining)
在实际使用中收集 ROI 阈值方案判错的帧,人工确认标注后加入训练集:
# 收集困难样本
# 1. 运行时检测:ROI 方案与 YOLO 方案结果不一致的帧 → 保存
# 2. 人工确认标注
# 3. 加入训练集重新训练
6.3 量化加速
# INT8 量化(CPU 推理提速 ~2x,模型缩小 ~4x)
from ultralytics import YOLO
model = YOLO('best.pt')
model.export(
format='onnx',
imgsz=640,
int8=True, # INT8 量化
data='dataset/data.yaml', # 校准数据集
)
7. 部署检查清单
- 数据集采集 ≥ 2000 张标注图片
- 标注质量验证(抽查 10%)
- 训练 + 验证 mAP50 > 0.95
- 导出 ONNX,验证推理结果与 PyTorch 一致
- C++ ONNX Runtime 集成,单元测试通过
- 与 ROI 阈值方案对比测试(100 张真实场景图)
- 多平台测试(Linux x86_64 / ARM64)
- 模型文件纳入版本管理(Git LFS 或独立存储)
- 降级策略:YOLO 加载失败自动回退 ROI 阈值
- 更新架构文档 ARCHITECTURE.md
8. 参考资源
下一篇: 如果前端推理性能不够,考虑 TensorRT 加速或 NPU 边缘计算方案(Jetson / RK3588)。