Files
douyin-captcha/try/method_h_silhouette.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

192 lines
7.0 KiB
Python

"""方法H:受尺度约束的 alpha 轮廓 + 局部对比度匹配。
前面的 C/D/E 实验暴露出两个系统性误差:
- 小模板会被背景纹理误选;
- mark 的 RGB 颜色不是大图目标的真实颜色。
本方法只使用 mark 的 alpha 轮廓,并把尺度限制在 0.75~1.25(样本中目标
物体与 110px mark 近似同尺寸)。候选分数由两部分组成:
1. alpha 轮廓在大图边缘距离图上的贴合度;
2. alpha 内部与外部环带之间的局部对比度。
输出:try/out/H/{*-match.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" / "H"
OUT.mkdir(parents=True, exist_ok=True)
SCALES = np.arange(0.75, 1.26, 0.025)
TOP_K = 5
RING = 5
def files_for(prefix):
bigs = [p for p in CAP.glob(f"{prefix}*.jpeg") if "-mark" not in p.stem]
if not bigs:
return None, None
big = bigs[0]
return big, CAP / f"{big.stem}-mark.png"
def load_data(big_path, mark_path):
try:
big = cv2.imread(str(big_path), cv2.IMREAD_GRAYSCALE)
mark = cv2.imread(str(mark_path), cv2.IMREAD_UNCHANGED)
if big is None or mark is None:
return None
alpha = mark[..., 3] if mark.ndim == 3 and mark.shape[2] == 4 else np.full(mark.shape[:2], 255, np.uint8)
shape = (alpha > 128).astype(np.uint8)
edge = cv2.morphologyEx(shape * 255, cv2.MORPH_GRADIENT, np.ones((3, 3), np.uint8)) > 0
outer = cv2.dilate(shape, np.ones((RING * 2 + 1, RING * 2 + 1), np.uint8)) > 0
ring = outer & ~shape
return big, shape, edge, ring
except Exception as e:
print(f" 读取失败: {e}")
return None
def resize_binary(mask, scale):
try:
h, w = mask.shape
size = (max(12, round(w * scale)), max(12, round(h * scale)))
return cv2.resize(mask.astype(np.uint8), size, interpolation=cv2.INTER_NEAREST) > 0
except cv2.error:
return None
def distance_fit(distance, edge):
try:
pts = edge.astype(np.float32)
n = float(pts.sum())
h, w = edge.shape
if n < 10 or h >= distance.shape[0] or w >= distance.shape[1]:
return None
# 每个候选框内,模板边缘点到背景边缘的平均距离
cost = cv2.matchTemplate(distance.astype(np.float32), pts, cv2.TM_CCORR) / n
return 1.0 / (1.0 + cost)
except cv2.error:
return None
def contrast_map(gray, shape, ring):
try:
inside = shape.astype(np.float32)
outside = ring.astype(np.float32)
ni, no = float(inside.sum()), float(outside.sum())
if ni < 10 or no < 10:
return None
mean_i = cv2.matchTemplate(gray.astype(np.float32), inside, cv2.TM_CCORR) / ni
mean_o = cv2.matchTemplate(gray.astype(np.float32), outside, cv2.TM_CCORR) / no
return np.abs(mean_i - mean_o)
except cv2.error:
return None
def normalize_map(values):
try:
lo, hi = np.percentile(values, (10, 99))
if hi <= lo:
return np.zeros_like(values, dtype=np.float32)
return np.clip((values - lo) / (hi - lo), 0.0, 1.0).astype(np.float32)
except Exception:
return np.zeros_like(values, dtype=np.float32)
def peaks(score_map, w, h):
result = []
try:
r = score_map.copy()
for _ in range(TOP_K):
_, score, _, loc = cv2.minMaxLoc(r)
if score <= 0:
break
result.append((float(score), int(loc[0]), int(loc[1])))
x0 = max(0, loc[0] - int(w * 0.65))
y0 = max(0, loc[1] - int(h * 0.65))
x1 = min(r.shape[1], loc[0] + int(w * 0.65))
y1 = min(r.shape[0], loc[1] + int(h * 0.65))
r[y0:y1, x0:x1] = 0
except Exception as e:
print(f" 峰提取失败: {e}")
return result
def process(prefix, lines):
try:
big_path, mark_path = files_for(prefix)
if big_path is None:
lines.append(f"{prefix}\tSKIP\t文件缺失")
return
loaded = load_data(big_path, mark_path)
if loaded is None:
lines.append(f"{prefix}\tSKIP\t读取失败")
return
big, shape0, edge0, ring0 = loaded
bg_edges = cv2.Canny(big, 80, 180)
distance = cv2.distanceTransform((255 - bg_edges).astype(np.uint8), cv2.DIST_L2, 3)
candidates = []
for scale in SCALES:
shape = resize_binary(shape0, float(scale))
edge = resize_binary(edge0, float(scale))
ring = resize_binary(ring0, float(scale))
if shape is None or edge is None or ring is None:
continue
fit = distance_fit(distance, edge)
contrast = contrast_map(big, shape, ring)
if fit is None or contrast is None or fit.shape != contrast.shape:
continue
score_map = 0.55 * normalize_map(fit) + 0.45 * normalize_map(contrast)
for score, x, y in peaks(score_map, shape.shape[1], shape.shape[0]):
candidates.append((score, float(scale), x, y, shape.shape[1], shape.shape[0],
float(fit[y, x]), float(contrast[y, x])))
if not candidates:
lines.append(f"{prefix}\tSKIP\t无候选")
return
candidates.sort(key=lambda c: -c[0])
kept = []
for c in candidates:
_, _, x, y, w, h, _, _ = c
cx, cy = x + w / 2.0, y + h / 2.0
if all(math.hypot(cx - (q[2] + q[4] / 2), cy - (q[3] + q[5] / 2)) > 0.6 * max(w, h)
for q in kept):
kept.append(c)
if len(kept) >= TOP_K:
break
best = kept[0]
score, scale, x, y, w, h, fit_score, contrast_score = best
print(f"{prefix} score={score:.3f} scale={scale:.3f} offset=({x},{y}) "
f"edgeFit={fit_score:.2f} contrast={contrast_score:.2f}")
lines.append(f"{prefix}\tscore={score:.3f}\tscale={scale:.3f}\toffset=({x},{y})\t"
f"edgeFit={fit_score:.2f}\tcontrast={contrast_score:.2f}")
vis = cv2.cvtColor(big, cv2.COLOR_GRAY2BGR)
colors = [(0, 0, 255), (0, 165, 255), (255, 0, 0), (0, 255, 0), (255, 0, 255)]
for i, item in enumerate(kept):
_, _, px, py, pw, ph, _, _ = item
cv2.rectangle(vis, (px, py), (px + pw, py + ph), colors[i], 2)
cv2.putText(vis, str(i + 1), (px + 2, py + 16), cv2.FONT_HERSHEY_SIMPLEX,
0.55, colors[i], 2)
cv2.imwrite(str(OUT / f"{prefix}-match.png"), vis)
except Exception as e:
print(f"{prefix} 处理失败: {e}")
lines.append(f"{prefix}\tERROR\t{e}")
def main():
lines = []
prefixes = sorted({p.name.split("~", 1)[0] for p in CAP.glob("*.jpeg")
if "-mark" not in p.stem})
for prefix in prefixes:
process(prefix, lines)
(OUT / "summary.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"\n结果已写入 {OUT}/")
if __name__ == "__main__":
main()