课程定位: 围棋打谱辅助系统系列课程的第一部分。本章完成摄像头采集、棋盘检测、透视矫正三大基础模块,为后续棋子识别和 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 棋谱读写
核心设计原则
- 自适应参数 — 动态计算间距、半径,不写死数值
- 多层兜底 — 自动检测 → 人工标记 → 多种模式降级
- 身份映射 — 星位点与棋盘坐标的身份对应,不是数组索引
- 信号驱动 — Qt 信号槽解耦,MJPEG 解码器只发
frameReady,不关心下游消费