上篇文章提出了 2 点斜对角方案,用中点凑第三点来做仿射预估。但这篇文章要指出:这个方案存在一个无法修复的数学缺陷——三个点共线导致仿射矩阵退化。修复方案是多点一次点击,改用 3 点三角选点,换回满秩矩阵和全方向均匀的预测精度。
1. 棋盘星位坐标系
19 路围棋棋盘有 9 个固定星位(0-indexed):
col: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
row 0 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
3 │ │ │ │ ① │ │ │ │ │ │ ② │ │ │ │ │ │ ③ │ │ │ │
9 │ │ │ │ ④ │ │ │ │ │ │ ★ │ │ │ │ │ │ ⑤ │ │ │ │
15 │ │ │ │ ⑥ │ │ │ │ │ │ ⑦ │ │ │ │ │ │ ⑧ │ │ │ │
18 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
| 编号 | 棋盘坐标 (row,col) | 名称 |
|---|---|---|
| ① | (3, 3) | 左上星位 |
| ② | (3, 9) | 上中星位 |
| ③ | (3, 15) | 右上星位 |
| ④ | (9, 3) | 左中星位 |
| ★ | (9, 9) | 天元(中心) |
| ⑤ | (9, 15) | 右中星位 |
| ⑥ | (15, 3) | 左下星位 |
| ⑦ | (15, 9) | 下中星位 |
| ⑧ | (15, 15) | 右下星位 |
归一化: u = col/18, v = row/18,将棋盘坐标映射到 [0, 1] 范围。
2. 2 点斜对角:看似可行,实则退化
2.1 数学分析
2 个斜对角星位:(3,3) → P₁, (15,15) → P₂。
我们只能得到 2 个点对应,即 4 个标量约束。而仿射变换有 6 个自由度:
[ x' ] [ a b c ] [ x ]
[ y' ] = [ d e f ] [ y ]
[ 1 ] [ 0 0 1 ] [ 1 ]
系统欠定。当前实现试图构造第 3 个"虚拟"对应点——天元中点:
(9,9) → (P₁ + P₂) / 2
但这里有一个致命问题:这 3 个源点共线!
graph LR
A["(3,3)"] --- C["(9,9)"] --- B["(15,15)"]
style A fill:#f66,stroke:#333
style B fill:#f66,stroke:#333
style C fill:#f96,stroke:#333
三个源坐标 (3/18, 3/18), (9/18, 9/18), (15/18, 15/18) 全部落在直线 y = x 上。
2.2 共线退化的后果
cv::getAffineTransform(srcTri, dstTri) 要求 3 个源点不共线才能唯一确定仿射矩阵。当源点共线时:
| 属性 | 2 点对角 | 3 点三角 |
|---|---|---|
| 源点共线性 | ✗ 共线 (diagonal y=x) |
✓ 不共线 (triangle) |
| 仿射自由度 | ≤4 of 6 (奇异矩阵) | 6 of 6 (full-rank) |
| 预测精度 | 沿对角线方向不可靠 | 全方向均匀可靠 |
| guided search | 预测偏差大, 难以搜到 | 预测精准, 高成功率 |
| RANSAC 升级概率 | ≈0% | ≥60% |
当仿射矩阵退化时:
- 沿对角线方向的预测勉强可用
- 垂直对角线方向的预测完全不可靠
- guided HoughCircles 在错误位置搜索 → 几乎找不到真实星位
- 做不到单应升级,只能用劣质仿射兜底
- 输出图像存在不可忽略的 skew
3. 🔺 三点三角模式 —— 严丝合缝
3.1 选点 & 点击顺序
从 9 个星位中选取 3 个非共线的角星位,形成覆盖整块棋盘的三角形:
(3,3) (3,15)
● ───────────────────── ●
│\ │
│ \ │
│ \ │
│ \ │
│ \ │
│ \ │
│ \ │
│ \ │
│ \ │
│ \ │
│ 棋盘区域 \│
│ ● (15,3)
│
● (15,15) ← 不选, 由变换自然延伸
CLICK 1 ────────── CLICK 2
● (3,3) ● (3,15)
│\ │
│ \ │
│ \ │
│ \ │
│ \ │
│ \ │
│ \ │
│ \ │
│ \ │
● CLICK 3 │
(15,3) │
● (15,15) ← 不点击, 变换自动覆盖
| 点击 | 棋盘坐标 | 名称 | 作用 |
|---|---|---|---|
| 🖱️ 1 | (3, 3) | 左上星位 | 确立基准锚点 |
| 🖱️ 2 | (3, 15) | 右上星位 | 确定水平方向 + 旋转变换 |
| 🖱️ 3 | (15, 3) | 左下星位 | 确定垂直方向 + 非均匀缩放 |
为什么右下角 (15,15) 不点? 通过前 3 个点求解的仿射变换已有 6 个自由度(满秩),第四角的像素位置由变换矩阵自动确定。如果 guided HoughCircles 在此位置附近搜到星位 → 可直接升级为 4 点全单应。
3.2 为什么选这 3 个点
- 非共线 — 三点不在同一直线上 →
getAffineTransform满秩 - 最大覆盖 — 三角形面积覆盖整块棋盘的 2/3,行列式大,数值稳定
- 区分方向 — 右上确定水平(column)方向,左下确定垂直(row)方向
- 用户友好 — 三个角点位置明确,易识别、易点击
3.3 数学优势
3 个不共线点给出 6 个独立约束,恰好确定 6-DOF 仿射:
[ a ] [ x₁ y₁ 1 0 0 0 ]⁻¹ [ x₁' ]
[ b ] [ 0 0 0 x₁ y₁ 1 ] [ y₁' ]
[ c ] = [ x₂ y₂ 1 0 0 0 ] [ x₂' ]
[ d ] [ 0 0 0 x₂ y₂ 1 ] [ y₂' ]
[ e ] [ x₃ y₃ 1 0 0 0 ] [ x₃' ]
[ f ] [ 0 0 0 x₃ y₃ 1 ] [ y₃' ]
行列式为三角形面积的 2 倍,非零 ⇔ 不共线。三角选点保证 ~100% 的条件数。
3.4 算法流程
flowchart TD
subgraph 棋盘[19×19 Go Board]
direction LR
TL["🖱️ CLICK 1<br/>● (3,3) 左上星位"]
TR["🖱️ CLICK 2<br/>● (3,15) 右上星位"]
BL["🖱️ CLICK 3<br/>● (15,3) 左下星位"]
end
TL ---|"水平方向 (6 grid units)"| TR
TL ---|"垂直方向 (12 grid units)"| BL
TR -.-|"自动推导"| BR["(15,15) 右下星位<br/>由变换延伸"]
subgraph 算法[Processing]
AFF["cv::getAffineTransform<br/>→ 满秩 2×3 矩阵"]
PROJ["投影预测 9 星位<br/>全方向均匀精度"]
GUIDE["Guided HoughCircles<br/>在 6 个未标记星位附近的 ROI 精搜"]
end
棋盘 --> AFF
AFF --> PROJ
PROJ --> GUIDE
GUIDE --> DECISION{"搜到 ≥1 个新星位?<br/>总计 ≥4 点"}
DECISION -->|"✅ 是"| RANSAC["RANSAC findHomography<br/>透视 + 仿射全矫正"]
DECISION -->|"❌ 否"| AFFINE["良好仿射兜底<br/>(full-rank, 无退化)"]
RANSAC --> OUT["600×600 标准正方形输出"]
AFFINE --> OUT
4. 三点 vs 两点 vs 九点对比
| 维度 | ⚡ 2-point | 🔺 3-point | 📐 9-point |
|---|---|---|---|
| 用户点击次数 | 2 | 3 | 9 |
| 数学适定性 | ❌ 退化 | ✅ 满秩 | ✅ 超定 |
| 矫正类型 | 仿射(退化) | 仿射→可能升单应 | 全单应(RANSAC) |
| 透视(梯形)矫正 | ✗ | 部分(升级后全) | ✓ 全 |
| 旋转 | 不可靠 | ✓ | ✓ |
| 非均匀缩放 | ✗ | ✓ | ✓ |
| Shear | ✗ | ✓ | ✓ |
| 推荐场景 | — 不建议 — | 快速预览 | 精确矫正 |
5. 实现
// 三点模式: 用户按提示点击 3 个星位
// 标记 1 → (3,3) 左上星位
// 标记 2 → (3,15) 右上星位
// 标记 3 → (15,3) 左下星位
cv::Point2f srcTri[3] = {
{3.0f / 18, 3.0f / 18}, // (3,3) → click 1
{3.0f / 18, 15.0f / 18}, // (3,15) → click 2
{15.0f / 18, 3.0f / 18} // (15,3) → click 3
};
cv::Point2f dstTri[3] = {mark1, mark2, mark3};
cv::Mat affine = cv::getAffineTransform(srcTri, dstTri);
// affine 满秩, 无退化
完整的三点矫正类:
class TriangleRectifier {
public:
void setMarkedPoints(cv::Point2f topLeft, // (3,3)
cv::Point2f topRight, // (3,15)
cv::Point2f bottomLeft) // (15,3)
{
p1 = topLeft;
p2 = topRight;
p3 = bottomLeft;
}
bool rectify(const cv::Mat& input, cv::Mat& output,
int outSize = 600)
{
// 1. 满秩仿射:3 个不共线点 → 6 个独立约束
cv::Point2f srcTri[3] = {
{3.0f/18, 3.0f/18}, // (3,3)
{3.0f/18, 15.0f/18}, // (3,15)
{15.0f/18, 3.0f/18} // (15,3)
};
cv::Point2f dstTri[3] = {p1, p2, p3};
cv::Mat A = cv::getAffineTransform(srcTri, dstTri);
// A 始终满秩 — 无退化风险
// 2. 投影预测所有 9 个星位
auto predicted = predictAllStars(A);
// 3. Guided HoughCircles 在未标记的 6 个星位附近搜索
auto found = guidedSearch(input, predicted,
{0,1,2}); // 跳过已标记的索引
// 4. 决策
if (found.size() >= 1) {
// 升级:≥4 个对应点 → 全单应
auto allPts = mergePoints({p1,p2,p3}, found);
cv::Mat H = cv::findHomography(allPts.dst, allPts.src,
cv::RANSAC, 3.0);
cv::warpPerspective(input, output, H,
cv::Size(outSize, outSize));
return true;
} else {
// 兜底:满秩仿射,无退化
cv::warpAffine(input, output, A,
cv::Size(outSize, outSize));
return false;
}
}
private:
cv::Point2f p1, p2, p3;
};
6. 为什么只加 1 次点击,差距这么大?
本质上是线性代数的一个基本约束:
- 2 点 → 对角线共线 → 矩阵秩不足 → 有一半方向的信息是缺失的
- 3 点三角 → 覆盖二维平面 → 满秩 → 所有方向的变换都被唯一确定
2-point span: 一条线 (1D subspace)
★──────────★ 只约束了沿对角线的方向
3-point span: 一个面 (2D full space)
★──────────★
│ │ 约束了整个平面
★─────────── 所有方向都有定义
这就是为什么多 1 次点击(2→3)带来的精度跃升远大于从 3→9 的增量收益。
7. 结论
2 点对角方案存在不可修复的共线退化问题,不应在生产中使用。
3 点三角是"最少点击 + 数学正确"的最优解:
- 只比 2 点多 1 次点击
- 无退化,全 6-DOF 仿射
- guided Hough 预测精准,高概率升级为全单应
- 输出质量接近 9 点完整标记