课程定位: 围棋打谱辅助系统系列课程的第一部分。本章完成摄像头采集、棋盘检测、透视矫正三大基础模块,为后续棋子识别和 AI 分析打下视觉地基。

前置知识: C++17、Qt6 信号槽、OpenCV 基础

预计阅读: 45 分钟 | 代码行数: ~1500 行


1. 系统架构总览

1.1 物理部署

graph TB
    subgraph 硬件层
        CAM["📷 ESP32-CAM<br/>OV2640 摄像头<br/>WiFi 模块"]
        BOARD["🎯 围棋棋盘<br/>19×19 标准棋盘<br/>9 个星位点"]
    end

    subgraph 网络层
        WIFI["📡 WiFi 局域网<br/>192.168.1.x"]
    end

    subgraph PC主机
        STREAM["MjpegStream<br/>HTTP MJPEG 解码"]
        DETECT["GoBoardDetector<br/>HoughCircles 检测"]
        RECTIFY["BoardRectifier<br/>单应/仿射 矫正"]
        GUI["MainWindow<br/>Qt6 双视图界面"]
    end

    CAM -->|"/stream" 长连接| WIFI
    WIFI -->|MJPEG 数据流| STREAM
    STREAM -->|"frameReady(QImage)"| GUI
    GUI -->|QImage 帧| DETECT
    DETECT -->|"DetectionResult"| RECTIFY
    RECTIFY -->|"矫正图 600×600"| GUI

1.2 软件模块

模块 类名 职责 文件
MJPEG 解码 MjpegStream HTTP → multipart 解析 → QImage src/MjpegStream.h/cpp
棋盘检测 GoBoardDetector HoughCircles → 星位识别 → 单应矩阵 src/GoBoardDetector.h/cpp
透视矫正 BoardRectifier 星位→单应/仿射→ warpPerspective src/BoardRectifier.h/cpp
实时显示 LiveView QImage 显示 + 检测叠加 src/LiveView.h/cpp
矫正视图 DewarpView 俯视棋盘渲染 src/DewarpView.h/cpp
人工标记 MarkWidget 点击/拖拽 标记星位点 src/MarkWidget.h/cpp
主窗口 MainWindow 工具栏 + 双视图 + 模式切换 src/MainWindow.h/cpp

2. ESP32-CAM 固件:视频源

2.1 硬件选型

ESP32-CAM 是一块基于 ESP32-S 的摄像头开发板,集成 OV2640 (200万像素) 摄像头模块,板载 WiFi + 蓝牙。

为什么选它?

  • 💰 成本 < ¥30
  • 📷 OV2640 支持 JPEG 硬件编码,无需 CPU 编码
  • 📡 WiFi 直连,无需 USB 线
  • 🔌 5V 供电,USB 或电池皆可

2.2 固件架构

flowchart TD
    subgraph 启动流程
        NVS["nvs_flash_init()<br/>初始化非易失存储"]
        CAM_INIT["camera_init()<br/>配置 OV2640 引脚<br/>设置 JPEG 800×600"]
        WIFI["wifi_init_sta()<br/>连接 AP"]
    end

    subgraph HTTP服务器
        SERVER["httpd_start()<br/>ESP-IDF HTTP Server"]
        R_CAP["GET /capture<br/>单帧 JPEG"]
        R_STREAM["GET /stream<br/>MJPEG 视频流"]
        R_INDEX["GET /<br/>HTML 预览页"]
    end

    NVS --> CAM_INIT --> WIFI
    WIFI -->|"IP_EVENT_STA_GOT_IP"| SERVER
    SERVER --> R_CAP & R_STREAM & R_INDEX

2.3 关键代码:摄像头初始化

// esp32-cam/main/esp32-cam.c — camera_init()
static esp_err_t camera_init(void)
{
    camera_config_t config = {
        .pin_pwdn    = 32,          // 电源控制
        .pin_reset   = -1,          // 不复用 reset
        .pin_xclk    = 0,           // 主时钟
        .pin_sccb_sda = 26,         // I²C 数据
        .pin_sccb_scl = 27,         // I²C 时钟
        // D0-D7 并行数据
        .pin_d7 = 35, .pin_d6 = 34, .pin_d5 = 39, .pin_d4 = 36,
        .pin_d3 = 21, .pin_d2 = 19, .pin_d1 = 18, .pin_d0 = 5,
        // 同步信号
        .pin_vsync = 25, .pin_href = 23, .pin_pclk = 22,

        .xclk_freq_hz = 20000000,   // 20MHz 主频
        .pixel_format  = PIXFORMAT_JPEG,  // 关键:硬件 JPEG 输出
        .frame_size    = FRAMESIZE_SVGA,   // 800×600
        .jpeg_quality  = 12,              // 0-63, 越小越好
        .fb_count      = 2,               // 双缓冲
        .fb_location   = CAMERA_FB_IN_PSRAM,
    };
    return esp_camera_init(&config);
}

要点: PIXFORMAT_JPEG 让 OV2640 内部 DSP 直接输出 JPEG 压缩数据,省去 MCU 编码成本。

2.4 HTTP 端点设计

端点 方法 协议 用途
/ GET HTTP 200 调试用 HTML 页面、内嵌 <img src="/stream">
/capture GET HTTP 200 返回单帧 JPEG(Content-Type: image/jpeg
/stream GET HTTP 长连接 MJPEG 流(multipart/x-mixed-replace; boundary=frame

MJPEG 协议格式:

HTTP/1.1 200 OK
Content-Type: multipart/x-mixed-replace; boundary=frame

--frame
Content-Type: image/jpeg
Content-Length: 28945

< JPEG 二进制数据 >
--frame
Content-Type: image/jpeg
Content-Length: 29102

< JPEG 二进制数据 >
--frame--

3. MJPEG 流解码:从 HTTP 到 QImage

3.1 设计思路

Qt6 的 QNetworkAccessManager 支持异步 HTTP 请求。对 /stream 发起 GET 后,服务器不会断开连接,而是持续推送 multipart/x-mixed-replace 数据。

我们的 MjpegStream 类封装了这个过程:

sequenceDiagram
    participant App as MainWindow
    participant MS as MjpegStream
    participant NAM as QNetworkAccessManager
    participant ESP as ESP32-CAM

    App->>MS: connectTo("http://192.168.1.100/stream")
    MS->>NAM: GET /stream (Accept: multipart/x-mixed-replace)
    NAM->>ESP: HTTP GET
    ESP-->>NAM: HTTP 200 + Content-Type header
    loop 每帧
        ESP-->>NAM: --frame\r\nContent-Type: image/jpeg\r\n...\r\n\r\n<JPEG>
        NAM-->>MS: readyRead()
        MS->>MS: parseBuffer() → 提取 JPEG
        MS-->>App: frameReady(QImage)
    end

3.2 核心代码:Buffer 解析

// MjpegStream.cpp — parseBuffer()
void MjpegStream::parseBuffer()
{
    // boundary 分隔符: "--BOUNDARY\r\n"
    QByteArray delimiter = "--" + m_boundary + "\r\n";

    while (true) {
        // 找下一帧的起始边界
        int startIdx = m_buffer.indexOf(delimiter);
        if (startIdx < 0) {
            // 数据不完整,保留尾部等待更多数据
            int keep = qMax(0, m_buffer.size() - delimiter.size() - 2);
            if (keep < m_buffer.size())
                m_buffer = m_buffer.right(keep);
            break;
        }

        // 跳过 delimiter → 读 MIME headers
        int headersStart = startIdx + delimiter.size();
        int headersEnd = m_buffer.indexOf("\r\n\r\n", headersStart);
        if (headersEnd < 0) break; // headers 不完整

        int jpegStart = headersEnd + 4; // 跳过 \r\n\r\n

        // 找下一个 delimiter,确定 JPEG 数据结束位置
        int nextDelim = m_buffer.indexOf(delimiter, jpegStart);

        if (nextDelim < 0) {
            // 可能是最后一帧,找结束标记 "--BOUNDARY--"
            QByteArray endDelim = "--" + m_boundary + "--";
            int endIdx = m_buffer.indexOf(endDelim, jpegStart);
            if (endIdx >= 0) {
                // 最后一帧处理...
            }
            break; // 等更多数据
        }

        QByteArray jpegData = m_buffer.mid(jpegStart, nextDelim - jpegStart);
        // 去掉末尾 \r\n
        while (jpegData.endsWith('\n') || jpegData.endsWith('\r'))
            jpegData.chop(1);

        QImage frame;
        if (frame.loadFromData(jpegData, "JPEG"))
            emit frameReady(frame);  // 🔥 发射信号!

        m_buffer = m_buffer.mid(nextDelim); // 推进指针
    }
}

3.3 关键细节

1. 增量解析: 不等待完整帧到达才处理。每次 readyRead() 追加到 m_buffer,然后尝试解析所有已到达的完整帧。

2. Boundary 自动提取: 首次收到数据时从 HTTP Content-Type 头中提取 boundary= 参数。ESP32-CAM 默认使用 frame

3. 内存管理: 解析完一帧后立即 m_buffer = m_buffer.mid(nextDelim),剪去已处理的头部,只保留未解析的尾部。


4. 棋盘检测:HoughCircles + 聚类排序

4.1 检测原理

围棋棋盘有 9 个固定位置的星位点(座子 / hoshi),构成一个已知的 3×3 网格。检测这 9 个点 → 直接求解从标准棋盘到图像的几何映射。

flowchart TD
    GRAY["灰度图"] --> PRE["预处理<br/>Bilateral + CLAHE"]
    PRE --> EST["estimateSpacing()<br/>FFT 自相关估算格距"]
    EST --> H1["HoughCircles L1<br/>param1=100, param2=18<br/>(激进参数)"]
    H1 --> CHECK{"检测到 ≥6 个圆?"}
    CHECK -->|"✅"| SORT["identifyStarPoints()<br/>聚类排序"]
    CHECK -->|"❌ <6"| H2["HoughCircles L2<br/>param1=60, param2=8<br/>(宽松参数)"]
    H2 --> SORT
    SORT --> RESULT["9 个 StarPoint<br/>{px, py, boardCol, boardRow}"]

4.2 预处理

// GoBoardDetector.cpp — preprocess()
cv::Mat GoBoardDetector::preprocess(const cv::Mat &bgr)
{
    cv::Mat gray, temp;
    cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY);

    // Bilateral: 去噪保边缘(棋盘线是关键特征,不能模糊)
    cv::bilateralFilter(gray, temp, 9, 75, 75);
    gray = temp;

    // CLAHE: 自适应直方图均衡(应对不均匀光照)
    auto clahe = cv::createCLAHE(2.0, cv::Size(8, 8));
    clahe->apply(gray, gray);

    return gray;
}

为什么选 Bilateral + CLAHE?

滤波器 效果 为什么适合
Gaussian 均匀模糊 ❌ 棋盘线会被模糊
Median 去椒盐噪点 部分有效,但不如 Bilateral
Bilateral 去噪保边缘 ✅ 棋盘线边缘清晰保留
CLAHE 局部对比度增强 ✅ 角落星位不会被阴影淹没

4.3 自适应 HoughCircles

std::vector<cv::Point2f> GoBoardDetector::detectStarPoints(const cv::Mat &gray) const
{
    // 先用 FFT 自相关估算格子间距
    double spacing = estimateSpacing(gray);

    // 根据间距自适应参数 —— 不是写死数值!
    int minR = std::max(2, (int)(spacing * 0.04));    // 最小圆半径
    int maxR = std::max(minR + 2, (int)(spacing * 0.20)); // 最大圆半径
    int minDist = std::max(8, (int)(spacing * 0.3));  // 圆心最小间距

    // L1: 激进参数(要求高质量圆)
    std::vector<cv::Vec3f> circles;
    cv::HoughCircles(gray, circles, cv::HOUGH_GRADIENT,
                     1.0, minDist, 100, 18, minR, maxR);

    // L2: 如果 L1 结果不够, 降参数再试
    if (circles.size() < 6) {
        circles.clear();
        cv::HoughCircles(gray, circles, cv::HOUGH_GRADIENT,
                         1.0, minDist, 60, 8, minR, maxR);
    }

    std::vector<cv::Point2f> points;
    for (const auto &c : circles)
        points.emplace_back(c[0], c[1]);
    return points;
}

适配性设计: minR/maxR 不是写死的 3-12,而是根据 FFT 估算的格子间距动态计算。

4.4 星位身份识别

HoughCircles 返回一堆圆心坐标,但它是无序的。我们需要知道每个点对应棋盘上的哪一行哪一列。

聚类排序法:

1. 按 y 坐标排序所有圆心
2. 计算相邻 y 差值,取最大的 2 个 gap → 分割成 3 行
3. 每行内按 x 坐标排序
4. 每行内计算 x 差值 gap → 分割成 3 列
5. 行列索引 → 棋盘坐标 (3, 9, 15)
graph LR
    subgraph 输入["无序圆心"]
        P1["· · · · · · · · ·"]
    end
    subgraph 步骤1["按 y 排序"]
        P2["① ① ①<br/>② ② ② ②<br/>③ ③"]
    end
    subgraph 步骤2["最大 y-gap 分割行"]
        P3["行1: ① ① ①<hr/>行2: ② ② ② ②<hr/>行3: ③ ③"]
    end
    subgraph 步骤3["每行按 x 排序 + gap 分列"]
        P4["(3,3) (3,9) (3,15)<br/>(9,3) (9,9) (9,15)<br/>(15,3) (15,9) (15,15)"]
    end

    输入 --> 步骤1 --> 步骤2 --> 步骤3

5. 透视矫正:从倾斜到俯视

5.1 数学模型

围棋棋盘是一张平面,从任意角度拍摄产生的形变可以用单应矩阵 (Homography) 描述。

给定 4 对以上对应点 (src → dst)cv::findHomography 用 RANSAC 求解 3×3 齐次矩阵 H:

[ x' ]   [ h11  h12  h13 ] [ x ]
[ y' ] = [ h21  h22  h23 ] [ y ]
[ w' ]   [ h31  h32  h33 ] [ 1 ]

实际像素 = (x'/w', y'/w')

5.2 BoardRectifier 核心流程

flowchart TD
    SRC["源图像 (倾斜棋盘)"] --> STAR["获取星位像素坐标<br/>(人工标记 或 自动检测)"]
    STAR --> DST["构建目标坐标<br/>标准棋盘归一化"]
    DST --> H["cv::findHomography(dst, src)<br/>RANSAC, thresh=5.0"]
    H --> CORNERS["投影四角<br/>(0,0)→(18,0)→(18,18)→(0,18)"]
    CORNERS --> M["cv::getPerspectiveTransform<br/>(srcCorners, dstCorners)"]
    M --> WARP["cv::warpPerspective<br/>→ 600×600 正方形"]
    WARP --> GRID["绘制 19×19 网格线<br/>绘制 9 个星位"]

5.3 关键代码

// BoardRectifier.cpp — computeHomography()
bool BoardRectifier::computeHomography(double ransacThresh)
{
    if (m_srcPts.size() < 4) return false;

    std::vector<cv::Point2f> dst = standardStarNormCoords();
    // {(3/18,3/18), (3/18,9/18), ..., (15/18,15/18)}

    size_t n = std::min(m_srcPts.size(), dst.size());
    std::vector<cv::Point2f> useDst(dst.begin(), dst.begin() + n);
    std::vector<cv::Point2f> useSrc(m_srcPts.begin(), m_srcPts.begin() + n);

    cv::Mat mask;
    m_H = cv::findHomography(useDst, useSrc, cv::RANSAC, ransacThresh, mask);
    return !m_H.empty();
}

// BoardRectifier.cpp — rectify()
cv::Mat BoardRectifier::rectify(int outputSize, int margin, ...) const
{
    cv::Point2f srcCorners[4];
    getProjectedCorners(srcCorners);  // 通过 H 投影四角

    cv::Point2f dstCorners[4];
    getDstCorners(outputSize, m, dstCorners);

    cv::Mat M = cv::getPerspectiveTransform(srcCorners, dstCorners);
    cv::Mat warped;
    cv::warpPerspective(m_source, warped, M,
                        cv::Size(outputSize, outputSize),
                        cv::INTER_LINEAR, cv::BORDER_CONSTANT,
                        cv::Scalar(50, 50, 50));

    // 绘制网格线 + 星位
    double cell = (outputSize - 2.0 * m) / 18.0;
    for (int i = 0; i < 19; i++) {
        int pos = m + (int)(i * cell + 0.5);
        cv::line(warped, {m, pos}, {outputSize-m, pos}, {80,80,80});
        cv::line(warped, {pos, m}, {pos, outputSize-m}, {80,80,80});
    }
    return warped;
}

6. 人工标记:兜底交互

自动检测不总是可靠——反光、遮挡、棋盘材质差异都可能导致 HoughCircles 找不到星位。

6.1 MarkWidget 交互设计

stateDiagram-v2
    [*] --> Empty
    Empty --> Adding: 左键点击
    Adding --> Adding: 左键点击 (继续添加)
    Adding --> Dragging: 左键拖拽已有标记
    Dragging --> Adding: 鼠标释放
    Adding --> Undoing: 右键点击
    Undoing --> Adding: 继续添加
    Adding --> Complete: 达到 maxPoints
    Complete --> [*]

6.2 坐标转换

MarkWidget 的核心挑战是:widget 尺寸与图像尺寸不同(图像被等比缩放居中显示),需要精确的双向坐标映射。

// MarkWidget.cpp — 坐标转换
QPointF MarkWidget::widgetToImage(const QPointF &wp) const
{
    QRectF r = imageRect();  // 图像在 widget 内的缩放矩形
    return QPointF(
        (wp.x() - r.x()) * m_image.width()  / r.width(),
        (wp.y() - r.y()) * m_image.height() / r.height()
    );
}

QRectF MarkWidget::imageRect() const
{
    qreal iw = m_image.width(), ih = m_image.height();
    qreal ww = width(), wh = height();
    qreal scale = qMin(ww / iw, wh / ih);  // 等比缩放取较小值
    qreal dw = iw * scale, dh = ih * scale;
    qreal dx = (ww - dw) / 2.0, dy = (wh - dh) / 2.0; // 居中
    return QRectF(dx, dy, dw, dh);
}

6.3 绘制逻辑

// MarkWidget.cpp — paintEvent() 关键部分
for (int i = 0; i < m_points.size(); i++) {
    QPointF pw = imageToWidget(m_points[i]);

    // 十字准线 (红色)
    p.setPen(QPen(QColor(255, 50, 50, 180), 1));
    p.drawLine(cx - 14, cy, cx + 14, cy);
    p.drawLine(cx, cy - 14, cx, cy + 14);

    // 外圆 (绿色)
    p.setPen(QPen(QColor(0, 255, 100, 220), 2));
    p.drawEllipse(QPointF(cx, cy), 8, 8);

    // 编号标签 (白字黑底)
    p.setPen(Qt::white);
    p.setBrush(QColor(0, 0, 0, 160));
    p.drawRoundedRect(QRectF(cx + 10, cy - 22, 22, 18), 4, 4);
    p.drawText(QRectF(cx + 10, cy - 22, 22, 18), Qt::AlignCenter,
               QString::number(i + 1));
}

7. 多模态矫正:2点/3点/4点/9点

7.1 为什么需要多种模式?

模式 点数 变换类型 透视校正 适用场景
Full 9-point 9 RANSAC 全单应 ✅ 完全 精确矫正
🔲 Quad 4-point 4 直接单应 ✅ 完全 推荐 — 4角直达
🔺 Tri 3-point 3 仿射→升级单应 升级后有 快速预览
⚡ Quick 2-point 2 退化仿射 不推荐

7.2 数学对比

🔲 4-point (推荐):          🔺 3-point:               ⚡ 2-point:
  4个角星位                  3个非共线星位             2个对角星位
  ↓                         ↓                        ↓
  findHomography(精确解)     getAffineTransform(满秩)   getAffineTransform(退化)
  ↓                         ↓                        ↓
  全程 8-DOF 单应            guided Hough 补搜         共线三点, 行列式≈0
  透视畸变完全矫正           ↓                        无实用的仿射
                            搜到≥1 → findHomography
                            搜不到 → 满秩仿射兜底

7.3 四点模式实现

// BoardRectifier.cpp — computeFromFourCorners()
int BoardRectifier::computeFromFourCorners(
    cv::Point2f tl,  // (3,3)   左上
    cv::Point2f tr,  // (3,15)  右上
    cv::Point2f br,  // (15,15) 右下
    cv::Point2f bl,  // (15,3)  左下
    const cv::Mat &gray)
{
    // 1. 4 角 → 直接 4 点精确解单应
    std::vector<cv::Point2f> indexedPts(9);
    std::vector<bool> hasPt(9, false);
    indexedPts[0] = tl; hasPt[0] = true;  // (3,3)
    indexedPts[2] = tr; hasPt[2] = true;  // (3,15)
    indexedPts[8] = br; hasPt[8] = true;  // (15,15)
    indexedPts[6] = bl; hasPt[6] = true;  // (15,3)

    // 2. 用 4 点算出精确单应 H4
    std::vector<cv::Point2f> src4, dst4;
    for (int i : {0, 2, 6, 8}) {
        src4.push_back(indexedPts[i]);
        dst4.emplace_back(starPositions[i].boardCol / 18.0f,
                          starPositions[i].boardRow / 18.0f);
    }
    cv::Mat H4 = cv::findHomography(dst4, src4, 0);  // 0 = 精确解

    // 3. 用 H4 预测其余 5 星位 → guided HoughCircles 补搜
    // ...

    // 4. 按身份构建匹配点对 → RANSAC
    m_srcPts.clear();
    std::vector<cv::Point2f> dstOrdered;
    for (int i = 0; i < 9; i++) {
        if (hasPt[i]) {
            m_srcPts.push_back(indexedPts[i]);
            dstOrdered.emplace_back(starPositions[i].boardCol / 18.0f,
                                    starPositions[i].boardRow / 18.0f);
        }
    }

    cv::Mat mask;
    m_H = cv::findHomography(dstOrdered, m_srcPts, cv::RANSAC, 3.0, mask);
    if (m_H.empty()) m_H = H4;  // RANSAC 失败 → 回退精确解
    return totalStars;
}

8. 完整管线:端到端数据流

8.1 从摄像头到矫正图

flowchart LR
    subgraph 采集
        ESP["ESP32-CAM<br/>/stream"]
    end
    subgraph 解码
        MJPEG["MjpegStream<br/>parseBuffer()"]
    end
    subgraph UI
        MW["MainWindow<br/>onStreamFrame()"]
        LV["LiveView<br/>setFrame()"]
    end
    subgraph 检测
        GD["GoBoardDetector<br/>process()"]
    end
    subgraph 矫正
        BR["BoardRectifier<br/>computeHomography()"]
        DV["DewarpView<br/>setResult()"]
    end

    ESP -->|"HTTP MJPEG"| MJPEG
    MJPEG -->|"frameReady(QImage)"| MW
    MW -->|"setFrame()"| LV
    LV -->|"定时器 500ms"| GD
    GD -->|"DetectionResult"| MW
    MW -->|"setResult()"| BR
    BR -->|"矫正图"| DV

8.2 关键信号槽连接

// MainWindow 构造函数中的核心连接
m_stream = new MjpegStream(this);
connect(m_stream, &MjpegStream::frameReady,
        this, &MainWindow::onStreamFrame);

m_liveView = new LiveView;
connect(m_liveView, &LiveView::detectionUpdated,
        this, &MainWindow::onDetectionUpdated);

// 人工标记完成 → 自动矫正
m_markWidget = new MarkWidget;
connect(m_markWidget, &MarkWidget::allMarked,
        this, &MainWindow::onMarkingDone);

8.3 MainWindow 模式切换

// 左面板: QStackedWidget 在 LiveView 和 MarkWidget 之间切换
m_leftStack = new QStackedWidget;
m_leftStack->addWidget(m_liveView);   // [0] 实时流模式
m_leftStack->addWidget(m_markWidget); // [1] 人工标记模式

// 工具栏按钮触发切换
m_btnRectify   onRectifyImage()    m_leftStack->setCurrentIndex(1)
m_btnQuad      onQuadRectify()     m_leftStack->setCurrentIndex(1)
m_btnTriangle  onTriangleRectify() m_leftStack->setCurrentIndex(1)

// 流模式下自动保持在 [0]
m_btnConnect   m_leftStack->setCurrentIndex(0)

9. 构建与运行

9.1 依赖

组件 路径/版本 用途
Qt 6.5+ /opt/Qt/6.5.3/gcc_64 GUI + 网络
OpenCV 4.x 系统安装 图像处理
CMake 3.16+ 系统自带 构建系统
g++ 9.4.0 (C++17) 编译器

9.2 CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(GoBoardCalib LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)

find_package(Qt6 REQUIRED COMPONENTS Widgets Network)
find_package(OpenCV REQUIRED)

add_executable(go-board-calib
    src/main.cpp
    src/MainWindow.h       src/MainWindow.cpp
    src/MjpegStream.h      src/MjpegStream.cpp
    src/GoBoardDetector.h  src/GoBoardDetector.cpp
    src/BoardRectifier.h   src/BoardRectifier.cpp
    src/LiveView.h         src/LiveView.cpp
    src/DewarpView.h       src/DewarpView.cpp
    src/MarkWidget.h       src/MarkWidget.cpp
)

target_link_libraries(go-board-calib PRIVATE
    Qt6::Widgets Qt6::Network ${OpenCV_LIBS})
target_include_directories(go-board-calib PRIVATE
    ${OpenCV_INCLUDE_DIRS} ${CMAKE_CURRENT_SOURCE_DIR}/src)

9.3 构建命令

cd workspace/go-board-cpp
mkdir -p build && cd build
cmake -DCMAKE_PREFIX_PATH=/opt/Qt/6.5.3/gcc_64 ..
make -j$(nproc)

9.4 使用流程

1. ESP32-CAM 上电 → 连接 WiFi → 获取 IP (例如 192.168.1.10)
2. 启动 go-board-calib
3. 输入 http://192.168.1.10/stream → Connect
4. 观察左侧实时画面 + 检测叠加
5. 右侧显示矫正俯视图
6. 如自动检测失败:
   📐 Rectify → 选照片 → 自动检测 / 人工标记
   🔲 Quad   → 4 角顺时针标记
   🔺 Tri    → 3 角标记
7. 📸 Capture → 保存当前帧

10. 小结

Part 0 完成的功能

✅ ESP32-CAM 固件     — WiFi MJPEG 视频流 (/stream + /capture)
✅ MJPEG 解码器       — HTTP multipart → QImage 实时帧
✅ 棋盘自动检测       — HoughCircles + 聚类排序 → 9 星位识别
✅ 透视矫正           — RANSAC 单应矩阵 → 600×600 正方形俯视图
✅ 人工标记           — 交互式星位标记 (拖拽微调, 右键撤销)
✅ 四模态矫正         — 9点全/4角/3角/2对角 选择
✅ Qt6 双视图 GUI     — LiveView + DewarpView + 模式切换

下一部分预告

Part 1: 棋子识别 + 局面感知

  • 361 交点 ROI 采样
  • 黑白子分类器(阈值 + SVM)
  • 落子帧差检测
  • 19×19 局面状态机
  • SGF 棋谱读写

核心设计原则

  1. 自适应参数 — 动态计算间距、半径,不写死数值
  2. 多层兜底 — 自动检测 → 人工标记 → 多种模式降级
  3. 身份映射 — 星位点与棋盘坐标的身份对应,不是数组索引
  4. 信号驱动 — Qt 信号槽解耦,MJPEG 解码器只发 frameReady,不关心下游消费