- src/: 最终交付物(solve.py base64 API、method_l_shape.py 核心算法、verify_result.py 验证工具) - docs/: 方案文档与实验演进记录 - try/: 历史实验脚本(A~K 方法) - 10/10 样本求解成功,3 个独立真值锚点偏差 <=4px
194 lines
7.3 KiB
Python
194 lines
7.3 KiB
Python
"""方法B:SIFT 特征匹配 + RANSAC 相似变换聚类。
|
||
|
||
思路:mark(带 alpha 蒙版)与大图各提 SIFT 特征,ratio test 后用
|
||
estimateAffinePartial2D(RANSAC) 迭代提取多个变换簇 —— 每簇对应大图中一个
|
||
相似区域(目标或干扰项)。按 AGENTS.md 断言:目标 = 旋转≈0 的簇。
|
||
|
||
输出:
|
||
- 每簇:scale / rotation / 平移(大图坐标系下 mark 左上角) / 内点数
|
||
- 判定:|rotation| 最小的簇为候选目标
|
||
- 可视化:变换后 mark 包围盒画到大图 → try/out/B/
|
||
- 汇总 → try/out/B/summary.txt
|
||
"""
|
||
from pathlib import Path
|
||
import math
|
||
import cv2
|
||
import numpy as np
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
CAP = ROOT / "captchas"
|
||
OUT = ROOT / "try" / "out" / "B"
|
||
OUT.mkdir(parents=True, exist_ok=True)
|
||
|
||
RATIO = 0.90
|
||
RANSAC_TH = 3.0
|
||
MAX_CLUSTERS = 3
|
||
MIN_MATCHES = 6
|
||
|
||
|
||
def load_mark(path):
|
||
"""返回 (灰度图, alpha蒙版uint8) 或 (None, None)。"""
|
||
try:
|
||
m = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
||
if m is None or m.ndim < 2:
|
||
return None, None
|
||
if m.ndim == 3 and m.shape[2] == 4:
|
||
gray = cv2.cvtColor(m[..., :3], cv2.COLOR_BGR2GRAY)
|
||
alpha = m[..., 3]
|
||
else:
|
||
gray = cv2.cvtColor(m, cv2.COLOR_BGR2GRAY)
|
||
alpha = np.full(m.shape[:2], 255, np.uint8)
|
||
return gray, alpha
|
||
except Exception as e:
|
||
print(f" mark 读取失败: {e}")
|
||
return None, None
|
||
|
||
|
||
def good_matches(de_m, de_b):
|
||
"""BF knn + ratio test。返回 (queryIdx, trainIdx) 对列表。"""
|
||
try:
|
||
matcher = cv2.BFMatcher(cv2.NORM_L2)
|
||
knn = matcher.knnMatch(de_m, de_b, k=2)
|
||
return [(m1.queryIdx, m1.trainIdx)
|
||
for m1, m2 in knn if m1.distance < RATIO * m2.distance]
|
||
except Exception as e:
|
||
print(f" 匹配失败: {e}")
|
||
return []
|
||
|
||
|
||
def extract_clusters(kp_m, kp_b, good):
|
||
"""迭代 RANSAC,最多 MAX_CLUSTERS 个变换簇。返回 [(M, 内点数), ...]。"""
|
||
clusters = []
|
||
try:
|
||
pts_m = np.array([kp.pt for kp in kp_m], dtype=np.float32).reshape(-1, 1, 2)
|
||
pts_b = np.array([kp.pt for kp in kp_b], dtype=np.float32).reshape(-1, 1, 2)
|
||
remaining = np.arange(len(good))
|
||
for _ in range(MAX_CLUSTERS):
|
||
if len(remaining) < MIN_MATCHES:
|
||
break
|
||
idx_pairs = np.array(good, dtype=np.int64)[remaining]
|
||
src = pts_m[idx_pairs[:, 0]]
|
||
dst = pts_b[idx_pairs[:, 1]]
|
||
M, inl = cv2.estimateAffinePartial2D(
|
||
src, dst, method=cv2.RANSAC, ransacReprojThreshold=RANSAC_TH,
|
||
maxIters=5000)
|
||
if M is None or inl is None:
|
||
break
|
||
n_inl = int(inl.sum())
|
||
if n_inl < 4:
|
||
break
|
||
clusters.append((M.copy(), n_inl))
|
||
inlier_pos = np.where(inl.ravel() > 0)[0]
|
||
used = remaining[inlier_pos]
|
||
keep = np.array([i for i, g in enumerate(remaining)
|
||
if not np.any(used == g)], dtype=np.int64)
|
||
remaining = remaining[keep]
|
||
except Exception as e:
|
||
print(f" 聚类失败: {e}")
|
||
return clusters
|
||
|
||
|
||
def describe(M):
|
||
"""2x3 相似变换 → (scale, rot_deg, tx, ty);失败返回 (0,0,0,0)。"""
|
||
try:
|
||
a, b = float(M[0, 0]), float(M[0, 1])
|
||
tx, ty = float(M[0, 2]), float(M[1, 2])
|
||
scale = math.hypot(a, b)
|
||
rot = math.degrees(math.atan2(b, a))
|
||
while rot <= -180:
|
||
rot += 360
|
||
while rot > 180:
|
||
rot -= 360
|
||
return scale, rot, tx, ty
|
||
except Exception as e:
|
||
print(f" 变换解析失败: {e}")
|
||
return 0.0, 0.0, 0.0, 0.0
|
||
|
||
|
||
def draw_cluster(big, M, mark_shape, color):
|
||
"""把 mark 四角经 M 映射后画到大图上。"""
|
||
try:
|
||
h, w = mark_shape[:2]
|
||
corners = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype=np.float32)
|
||
proj = cv2.transform(corners.reshape(-1, 1, 2), M)
|
||
pts = proj.reshape(-1, 2).astype(np.int32)
|
||
cv2.polylines(big, [pts], True, color, 2)
|
||
scale, rot, _, _ = describe(M)
|
||
cv2.putText(big, f"s={scale:.2f} r={rot:.0f}d",
|
||
(int(pts[:, 0].min()), max(12, int(pts[:, 1].min()) - 4)),
|
||
cv2.FONT_HERSHEY_SIMPLEX, 0.4, color, 1)
|
||
except cv2.error as e:
|
||
print(f" 绘制失败: {e}")
|
||
|
||
|
||
def process_pair(name, sift, lines):
|
||
try:
|
||
big = cv2.imread(str(CAP / f"{name}.jpeg"), cv2.IMREAD_COLOR)
|
||
if big is None:
|
||
lines.append(f"{name[:12]}\tSKIP\t大图读取失败")
|
||
return
|
||
gray_m, alpha_m = load_mark(CAP / f"{name}-mark.png")
|
||
if gray_m is None or alpha_m is None:
|
||
lines.append(f"{name[:12]}\tSKIP\tmark 读取失败")
|
||
return
|
||
gray_b = cv2.cvtColor(big, cv2.COLOR_BGR2GRAY)
|
||
|
||
kp_m, de_m = sift.detectAndCompute(gray_m, alpha_m)
|
||
kp_b, de_b = sift.detectAndCompute(gray_b, None)
|
||
n_kp_m = 0 if kp_m is None else len(kp_m)
|
||
if de_m is None or de_b is None or kp_m is None or kp_b is None or n_kp_m < MIN_MATCHES:
|
||
lines.append(f"{name[:12]}\tSKIP\t特征不足 mk={n_kp_m}")
|
||
return
|
||
|
||
good = good_matches(de_m, de_b)
|
||
if len(good) < MIN_MATCHES:
|
||
lines.append(f"{name[:12]}\tSKIP\tratio后匹配不足 {len(good)}")
|
||
return
|
||
|
||
clusters = extract_clusters(kp_m, kp_b, good)
|
||
if not clusters:
|
||
lines.append(f"{name[:12]}\tSKIP\t无变换簇")
|
||
return
|
||
|
||
# 目标候选:排除退化簇(scale 过小/过大),在余下中选 |rotation| 最小者
|
||
def cluster_scale(i):
|
||
return describe(clusters[i][0])[0]
|
||
|
||
valid = [i for i in range(len(clusters))
|
||
if 0.2 <= cluster_scale(i) <= 3.0]
|
||
if not valid:
|
||
lines.append(f"{name[:12]}\tSKIP\t仅退化簇")
|
||
return
|
||
best_idx = min(valid, key=lambda i: abs(describe(clusters[i][0])[1]))
|
||
vis = big.copy()
|
||
for i, (M, n_inl) in enumerate(clusters):
|
||
scale, rot, tx, ty = describe(M)
|
||
is_target = (i == best_idx)
|
||
color = (0, 0, 255) if is_target else (255, 0, 0)
|
||
draw_cluster(vis, M, gray_m.shape, color)
|
||
tag = "<==目标候选" if is_target else ""
|
||
print(f"{name[:12]} 簇{i}: s={scale:.3f} r={rot:+7.1f}° "
|
||
f"t=({tx:6.1f},{ty:6.1f}) 内点={n_inl} {tag}")
|
||
lines.append(f"{name[:12]}\t簇{i}\tscale={scale:.3f}\trot={rot:+.1f}\t"
|
||
f"t=({tx:.1f},{ty:.1f})\tinliers={n_inl}\t{tag}")
|
||
cv2.imwrite(str(OUT / f"{name[:12]}-clusters.png"), vis)
|
||
except Exception as e:
|
||
print(f"{name[:12]} 处理失败: {e}")
|
||
lines.append(f"{name[:12]}\tERROR\t{e}")
|
||
|
||
|
||
def main():
|
||
# cartoon 平面图形对比度低,放低对比度阈值、提高特征上限
|
||
sift = cv2.SIFT_create(nfeatures=2000, contrastThreshold=0.02)
|
||
lines = []
|
||
pairs = sorted({p.name.replace("-mark.png", "") for p in CAP.glob("*-mark.png")})
|
||
for name in pairs:
|
||
process_pair(name, sift, lines)
|
||
(OUT / "summary.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
n_ok = sum(1 for l in lines if "目标候选" in l)
|
||
print(f"\n{n_ok}/{len(pairs)} 对给出目标候选;结果已写入 {OUT}/")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|