1. 问题定义

输入: 任意角度拍摄包含围棋棋盘的彩色照片(倾斜、透视畸变) 输出: 正方形俯视校正图(19×19 标准棋盘,含网格线与星位标记)

2. 算法流程

flowchart TD
    A["📷 加载棋盘照片"] --> B["预处理: 灰度化 + Bilateral Filter + CLAHE"]
    B --> C{"自动星位检测<br/>HoughCircles + 聚类排序"}
    C -->|"✅ ≥4 个星位"| D["星位身份识别<br/>确定每个点是(3/9/15)行/列"]
    C -->|"❌ 检测失败"| E{"选择标记模式"}
    E -->|"Full"| F["🖱️ 人工标记 9 星位"]
    E -->|"⚡ Quick"| F2["🖱️ 只标记 2 斜对角星位<br/>左上(3,3) + 右下(15,15)"]
    D --> G["构建源点集 src: 像素坐标"]
    F --> G
    F2 --> G2["仿射预估(3点: 含隐式中心点)"]
    G2 --> G3["投影预测其余 7 星位"]
    G3 --> G4["Guided HoughCircles 局部精搜"]
    G4 --> G5{"搜到 ≥2 新星位?"}
    G5 -->|"✅ ≥4 总计"| G
    G5 -->|"❌ 不够"| G6["仿射兜底<br/>(2-DOF scale + 3-DOF translation)"]
    G --> H["RANSAC 单应矩阵估计<br/>cv::findHomography(src, dst)"]
    G6 --> I["透视变换 / 仿射变换<br/>warpPerspective → 正方形输出"]
    H --> I
    I --> J["后处理:网格线 + 星位标记"]
    J --> K["💾 输出校正结果"]

3. 核心步骤详解

3.1 预处理

BGR → Gray → Bilateral Filter(9, 75, 75) → CLAHE(clip=2, grid=8×8)
  • Bilateral Filter: 保持边缘的同时平滑噪声(棋盘线是关键边缘,不能模糊)
  • CLAHE: 自适应直方图均衡,应对不均匀光照

3.2 星位坐标系统

19 路棋盘 9 个星位(0-indexed 棋盘坐标):

星位 (row, col) 归一化坐标 (u, v)
右上 (3, 3) (3/18, 3/18)
右中 (3, 9) (3/18, 9/18)
右下 (3, 15) (3/18, 15/18)
中右 (9, 3) (9/18, 3/18)
天元 (9, 9) (9/18, 9/18)
中左 (9, 15) (9/18, 15/18)
左偏上 (15, 3) (15/18, 3/18)
左偏中 (15, 9) (15/18, 9/18)
左下 (15, 15) (15/18, 15/18)

归一化: u = col/18, v = row/18,将棋盘坐标映射到 [0, 1] 范围

3.3 自动检测(路径 A)

flowchart LR
    A["灰度图"] --> B["estimateSpacing()<br/>FFT 自相关估算网格间距"]
    B --> C["自适应 HoughCircles<br/>dp=1, minDist, param1=100/60, param2=18/8<br/>minR/maxR 根据 spacing 动态计算"]
    C --> D["聚类排序 identifyStarPoints()"]
    D --> E["按 y 排序 → 最大 gap 分割行 → 3 行"]
    E --> F["每行按 x 排序 → 最大 gap 分割列 → 3 列"]
    F --> G["9 个 StarPoint{px, py, boardCol, boardRow}"]

3.4 人工标记(路径 B)

Full 模式 (9 星位)

用户按固定顺序点击棋盘上的9个星位点:
  1 ─ 2 ─ 3     (上行: 左→右)
  4 ─ 5 ─ 6     (中行: 左→右)
  7 ─ 8 ─ 9     (下行: 左→右)

每个标记点自动分配对应的 (boardCol, boardRow):

index i → row = (i / 3) * 6 + 3   (i/3 ∈ {0,1,2} → row ∈ {3,9,15})
          col = (i % 3) * 6 + 3   (i%3 ∈ {0,1,2} → col ∈ {3,9,15})

⚡ Quick 模式 (2 斜对角星位)

只需标记 2 个点: 左上星位 (3,3) 和右下星位 (15,15)

flowchart LR
    A["用户点击 2 个斜对角星位"] --> B["构建 3 点对应:<br/>(3,3)→P₁ (9,9)→mid (9,9是P₁和P₂中点)(15,15)→P₂"]
    B --> C["getAffineTransform → 6-DOF 仿射"]
    C --> D["仿射投影预测全部 9 星位"]
    D --> E["在每个预测位置 ROI 内<br/>guided HoughCircles 精搜"]
    E --> F{"搜到额外 ≥2 星位?"}
    F -->|"✅ 总计 ≥4"| G["升级为全单应 findHomography"]
    F -->|"❌ 不足"| H["降级为仿射兜底<br/>处理旋转+缩放+平移<br/>不处理纯透视"]

关键设计:

  • 两个斜对角星位提供棋盘的方向向量尺度信息
  • 隐式中心点 (天元) = 两个标记点的中点,提供第 3 个约束 → 6-DOF 仿射
  • guided HoughCircles 在仿射预测位置的局部 ROI 内搜索,大幅减少误检
  • 兜底: 纯仿射对正面拍摄的照片效果足够好,仅丢失纯透视(梯形)校正

关于 Quick 模式的完整原理和实现细节,参见独立文章《两点星位透视矫正:用最少标记完成棋盘校正》。

3.5 单应矩阵计算

// src: 源图像中的星位像素坐标
// dst: 归一化目标坐标
vector<Point2f> src = { (px0,py0), (px1,py1), ..., (px8,py8) };
vector<Point2f> dst = { (3/18,3/18), (3/18,9/18), ..., (15/18,15/18) };

Mat H = findHomography(dst, src, RANSAC, 5.0);
// 注意: dst→src 映射 (findHomography 的默认语义)
// 后续 warpPerspective 用 H 直接把目标坐标投影到源图像

⚠️ 方向注意: findHomography(dst, src) 返回的是 dst→src 的映射。这意味着 warpPerspective 配合 WARP_INVERSE_MAP 使用,或生成四角映射时直接用 H 正投影。

3.6 透视矫正

flowchart LR
    A["源图像中的棋盘<br/>(透视变形)"] --> B["计算源四角坐标<br/>(0,0)→(18,0)→(18,18)→(0,18)<br/>通过 H 正投影到源图像"]
    B --> C["getPerspectiveTransform<br/>(srcCorners, dstCorners)"]
    C --> D["warpPerspective<br/>→ 正方形输出<br/>outputSize × outputSize"]

目标四角 (outputSize=600, margin=30):

dstCorners = [(30,30), (570,30), (30,570), (570,570)]
cell = (600 - 60) / 18 = 30px

3.7 后处理

1. 绘制 19×19 网格线 (灰色, 1px)
2. 绘制 9 个星位点 (深灰实心圆, r=cell/10)
3. 可选: 棋子识别 (交点 ROI 灰度均值)
4. 保存为 JPEG (95% 质量)

4. 类设计

BoardRectifier
├── setImage(cv::Mat bgr)           // 设置源图像
├── setStarPoints(vector<Point2f>)  // 设置人工标记的星位
├── computeHomography()             // 计算单应矩阵
├── rectify(int outputSize)         // 执行透视矫正
├── homography() → cv::Mat          // 获取 H 矩阵
└── result() → cv::Mat              // 获取校正结果

MarkWidget : QWidget
├── setImage(QImage)                // 显示图像
├── markedPoints() → vector<QPointF> // 获取标记点
├── reset()                         // 清除标记
└── signals: allMarked()            // 9个点标记完成

5. 人工标记交互协议

MarkWidget 状态机:
  [Idle] ──load image──▶ [Waiting] ──click 9 times──▶ [Complete]
     ▲                       │                            │
     └─── reset() ──────────┴─── right-click undo ───────┘

每次左键点击:
  1. 记录像素坐标
  2. 如果当前点已有标记,移动该标记
  3. 如果 < 9 个点,添加新标记
  4. 重绘:显示已标记点编号(1-9) + 十字准线
  
拖拽:
  - 已标记的点可以拖拽微调
  - mousePress → 检测是否命中已有标记
  - mouseMove → 更新标记位置并重绘
  - mouseRelease → 完成移动