"""方法C:两阶段旋转感知模板匹配(纯 OpenCV,离线)。 阶段1(定位候选):rot=0 的尺度扫描 0.40~1.20,蒙版 CCOEFF_NORMED, 每尺度取显著度 top-3 峰,跨尺度 NMS 去重,按显著度保留 top-15。 阶段2(旋转消歧):对每个候选位置,把 mark 旋转 -180°~180°(步长 15°) 后与该位置 patch 做 NCC → 得到旋转响应曲线。真目标的最优旋转角应≈0° (AGENTS.md:目标无缩放无旋转差异;干扰项带旋转)。 在 |rot*| ≤ ZERO_TOL 的候选中取 NCC 最高者为目标。 输出:try/out/C/{*-match.png,*-rot.png,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" / "C" OUT.mkdir(parents=True, exist_ok=True) SCALES = np.arange(0.40, 1.21, 0.05) ANGLES = np.arange(-180.0, 180.0, 15.0) PEAKS_PER_SCALE = 3 CAND_KEEP = 15 ZERO_TOL = 10.0 def load_mark(path): """返回 (BGR float32, alpha float32 0~1) 或 (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: return m[..., :3].astype(np.float32), m[..., 3].astype(np.float32) / 255.0 return m.astype(np.float32), np.ones(m.shape[:2], np.float32) except Exception as e: print(f" mark 读取失败: {e}") return None, None def template_at(bgr, alpha, scale, angle): """缩放(+旋转)模板与蒙版。返回 (tpl, mask, w, h) 或 (None, None, 0, 0)。""" try: w = max(12, round(bgr.shape[1] * scale)) h = max(12, round(bgr.shape[0] * scale)) if w < 12 or h < 12: return None, None, 0, 0 tpl = cv2.resize(bgr, (w, h), interpolation=cv2.INTER_AREA).astype(np.float32) mask = cv2.resize(alpha, (w, h), interpolation=cv2.INTER_AREA).astype(np.float32) if abs(angle) > 1e-6: M = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) cos, sin = abs(float(M[0, 0])), abs(float(M[0, 1])) nw, nh = int(h * sin + w * cos) + 1, int(h * cos + w * sin) + 1 M[0, 2] += nw / 2.0 - w / 2.0 M[1, 2] += nh / 2.0 - h / 2.0 tpl = cv2.warpAffine(tpl, M, (nw, nh), flags=cv2.INTER_LINEAR, borderValue=(0, 0, 0)).astype(np.float32) mask = cv2.warpAffine(mask, M, (nw, nh), flags=cv2.INTER_LINEAR, borderValue=0).astype(np.float32) return tpl, mask, int(tpl.shape[1]), int(tpl.shape[0]) except cv2.error as e: print(f" 模板变换失败: {e}") return None, None, 0, 0 def full_match(big_f, tpl, mask): """整图蒙版 CCOEFF_NORMED。返回响应图或 None。""" try: res = cv2.matchTemplate(big_f, tpl, cv2.TM_CCOEFF_NORMED, mask=mask) return np.nan_to_num(res, nan=-1.0, posinf=-1.0, neginf=-1.0).astype(np.float32) except cv2.error as e: print(f" 整图匹配失败: {e}") return None def topk_peaks(res, tw, th, k): """NMS 取前 k 峰,返回 [(score, x, y)]。""" peaks = [] try: r = res.copy() for _ in range(k): _, score, _, loc = cv2.minMaxLoc(r) if score <= -1.0: break peaks.append((float(score), int(loc[0]), int(loc[1]))) x0, y0 = max(0, loc[0] - int(tw * 0.7)), max(0, loc[1] - int(th * 0.7)) x1, y1 = min(r.shape[1], loc[0] + int(tw * 0.7)), min(r.shape[0], loc[1] + int(th * 0.7)) r[y0:y1, x0:x1] = -1.0 except Exception as e: print(f" 峰提取失败: {e}") return peaks def patch_ncc(big_f, cx, cy, tpl, mask): """在以 (cx,cy) 为中心、与旋转后模板同尺寸的 patch 上算 NCC。失败返回 -1。""" try: h, w = tpl.shape[:2] x0 = int(round(cx - w / 2.0)) y0 = int(round(cy - h / 2.0)) if x0 < 0 or y0 < 0 or x0 + w > big_f.shape[1] or y0 + h > big_f.shape[0]: return -1.0 patch = big_f[y0:y0 + h, x0:x0 + w] res = cv2.matchTemplate(patch, tpl, cv2.TM_CCOEFF_NORMED, mask=mask) v = float(np.nan_to_num(res[0, 0], nan=-1.0)) return v except cv2.error: return -1.0 def rotation_curve(big_f, cx, cy, bgr, alpha, scale, angles): """候选位置的旋转响应曲线。返回 (best_angle, best_ncc, ncc_at_0, samples)。""" samples = [] try: for ang in angles: tpl, mask, _, _ = template_at(bgr, alpha, scale, float(ang)) if tpl is None: samples.append(-1.0) continue samples.append(patch_ncc(big_f, cx, cy, tpl, mask)) arr = np.array(samples) k = int(arr.argmax()) i0 = int(np.where(np.isclose(angles, 0.0))[0][0]) if any(np.isclose(angles, 0.0)) else -1 ncc0 = float(arr[i0]) if i0 >= 0 else -1.0 return float(angles[k]), float(arr[k]), ncc0, arr except Exception as e: print(f" 旋转曲线失败: {e}") return 0.0, -1.0, -1.0, np.array(samples) def process_pair(name, 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 big_f = big.astype(np.float32) bgr, alpha = load_mark(CAP / f"{name}-mark.png") if bgr is None or alpha is None: lines.append(f"{name[:12]}\tSKIP\tmark 读取失败") return # 阶段1:rot=0 尺度扫描 cands = [] # (prom, scale, w, h, x, y, score0) for sc in SCALES: tpl, mask, tw, th = template_at(bgr, alpha, float(sc), 0.0) if tpl is None or tw >= big.shape[1] or th >= big.shape[0]: continue res = full_match(big_f, tpl, mask) if res is None: continue mx, med, sd = float(res.max()), float(np.median(res)), float(res.std()) prom = (mx - med) / sd if sd > 1e-6 else 0.0 for score, x, y in topk_peaks(res, tw, th, PEAKS_PER_SCALE): cands.append((prom, float(sc), tw, th, x, y, score)) if not cands: lines.append(f"{name[:12]}\tSKIP\t阶段1无候选") return # 跨尺度 NMS:中心距离 < 0.6*模板尺寸 视为同点 cands.sort(key=lambda c: (-c[0], -c[6])) kept = [] for c in cands: cx, cy = c[4] + c[2] / 2.0, c[5] + c[3] / 2.0 dup = False for k in kept: kcx, kcy = k[4] + k[2] / 2.0, k[5] + k[3] / 2.0 if math.hypot(cx - kcx, cy - kcy) < 0.6 * max(c[2], k[2]): dup = True break if not dup: kept.append(c) if len(kept) >= CAND_KEEP: break # 阶段2:旋转消歧 results = [] # (rot*, ncc*, ncc0, prom, scale, w, h, x, y) for prom, sc, tw, th, x, y, score0 in kept: cx, cy = x + tw / 2.0, y + th / 2.0 rot_b, ncc_b, ncc0, _ = rotation_curve(big_f, cx, cy, bgr, alpha, sc, ANGLES) results.append((rot_b, ncc_b, ncc0, prom, sc, tw, th, x, y)) ok = [r for r in results if abs(r[0]) <= ZERO_TOL and r[1] > 0.0] ok.sort(key=lambda r: (-r[1], abs(r[0]))) results.sort(key=lambda r: -r[1]) if ok: t = ok[0] rot_b, ncc_b, ncc0, prom, sc, tw, th, x, y = t print(f"{name[:12]} 目标: scale={sc:.2f} rot*={rot_b:+.0f}° " f"ncc*={ncc_b:.3f} ncc(0°)={ncc0:.3f} offset=({x},{y}) " f"prom={prom:.2f} | 合规候选={len(ok)}") lines.append(f"{name[:12]}\t目标\tscale={sc:.2f}\trot*={rot_b:+.0f}\t" f"ncc*={ncc_b:.3f}\tncc0={ncc0:.3f}\toffset=({x},{y})\t" f"prom={prom:.2f}\t合规={len(ok)}") else: r = results[0] print(f"{name[:12]} 无零旋转候选,最优旋转响应: scale={r[4]:.2f} " f"rot*={r[0]:+.0f}° ncc*={r[1]:.3f}") lines.append(f"{name[:12]}\t无零旋转候选\tbest_rot*={r[0]:+.0f}\t" f"ncc*={r[1]:.3f}\tscale={r[4]:.2f}") # 可视化:目标框 + 旋转曲线图 vis = big.copy() for i, r in enumerate(results[:3]): rot_b, ncc_b, _, _, sc, tw, th, x, y = r color = (0, 0, 255) if (ok and r is ok[0]) else (255, 0, 0) cv2.rectangle(vis, (x, y), (x + tw, y + th), color, 2) cv2.putText(vis, f"s={sc:.2f} r={rot_b:.0f} {ncc_b:.2f}", (x, max(12, y - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.4, color, 1) cv2.imwrite(str(OUT / f"{name[:12]}-match.png"), vis) if ok: _, _, _, _, sc, _, _, _, _ = ok[0] cx, cy = ok[0][7] + ok[0][5] / 2.0, ok[0][8] + ok[0][6] / 2.0 _, _, _, curve = rotation_curve(big_f, cx, cy, bgr, alpha, sc, ANGLES) img = np.full((160, 480, 3), 255, np.uint8) cmax = max(float(curve.max()), 1e-3) cmin = min(float(curve.min()), 0.0) for j in range(1, len(curve)): x0 = int((j - 1) / (len(curve) - 1) * 460) + 10 x1 = int(j / (len(curve) - 1) * 460) + 10 y0 = int(150 - (curve[j - 1] - cmin) / (cmax - cmin) * 130) y1 = int(150 - (curve[j] - cmin) / (cmax - cmin) * 130) cv2.line(img, (x0, y0), (x1, y1), (255, 0, 0), 1) xi = int((0.0 - ANGLES[0]) / (ANGLES[-1] - ANGLES[0]) * 460) + 10 cv2.line(img, (xi, 10), (xi, 150), (0, 200, 0), 1) cv2.putText(img, f"{name[:12]} scale={sc:.2f}", (10, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 0, 0), 1) cv2.imwrite(str(OUT / f"{name[:12]}-rot.png"), img) except Exception as e: print(f"{name[:12]} 处理失败: {e}") lines.append(f"{name[:12]}\tERROR\t{e}") def main(): lines = [] pairs = sorted({p.name.replace("-mark.png", "") for p in CAP.glob("*-mark.png")}) for name in pairs: process_pair(name, lines) (OUT / "summary.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") n_ok = sum(1 for l in lines if "\t目标\t" in l) print(f"\n{n_ok}/{len(pairs)} 对定位到零旋转目标;结果已写入 {OUT}/") if __name__ == "__main__": main()