Files
douyin-captcha/try/method_b_sift.py
T
杨豪 d15b3c5b43 feat: 拖动重叠验证码离线求解(方向感知倒角+alpha轮廓+旋转扫描)
- src/: 最终交付物(solve.py base64 API、method_l_shape.py 核心算法、verify_result.py 验证工具)
- docs/: 方案文档与实验演进记录
- try/: 历史实验脚本(A~K 方法)
- 10/10 样本求解成功,3 个独立真值锚点偏差 <=4px
2026-09-07 20:13:22 +08:00

194 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""方法BSIFT 特征匹配 + 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()