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)。