"""方法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()