Files
杨豪 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

173 lines
6.4 KiB
Python

"""方法D:基于轮廓/边缘的缩放旋转搜索。
mark 的 RGB 颜色可能与大图中的目标渲染颜色不同,因此不再匹配填充颜色,
而只使用 mark alpha 的外轮廓;背景使用 Canny 边缘图,采用距离变换做
Chamfer matching。分数越高表示模板轮廓落在背景边缘上的平均距离越小。
输出:try/out/D/{*-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" / "D"
OUT.mkdir(parents=True, exist_ok=True)
SCALES = np.arange(0.30, 1.51, 0.05)
ANGLES = np.arange(-180.0, 180.0, 15.0)
TOP_K = 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_shapes(mark_path, big_path):
try:
mark = cv2.imread(str(mark_path), cv2.IMREAD_UNCHANGED)
big = cv2.imread(str(big_path), cv2.IMREAD_GRAYSCALE)
if mark is None or big 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) * 255
# 轮廓用细边界,避免大面积填充主导匹配
edge = cv2.morphologyEx(shape, cv2.MORPH_GRADIENT, np.ones((3, 3), np.uint8))
return big, shape, edge
except Exception as e:
print(f" 图像读取失败: {e}")
return None
def transformed_edge(edge, scale, angle):
try:
h0, w0 = edge.shape
w, h = max(12, round(w0 * scale)), max(12, round(h0 * scale))
small = cv2.resize(edge, (w, h), interpolation=cv2.INTER_NEAREST)
if abs(angle) < 1e-6:
return small
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
return cv2.warpAffine(small, m, (nw, nh), flags=cv2.INTER_NEAREST, borderValue=0)
except cv2.error:
return None
def chamfer_response(bg_edges, tpl_edge):
"""边缘模板的 Chamfer 响应图,值越大越好。"""
try:
h, w = tpl_edge.shape
if h >= bg_edges.shape[0] or w >= bg_edges.shape[1]:
return None
points = (tpl_edge > 0).astype(np.float32)
n_points = float(points.sum())
if n_points < 10:
return None
# 用模板边缘作为权重,计算背景边缘距离的局部平均值。
distance = cv2.distanceTransform((255 - bg_edges).astype(np.uint8), cv2.DIST_L2, 3)
cost = cv2.matchTemplate(distance.astype(np.float32), points,
cv2.TM_CCORR)
avg_distance = cost / n_points
return 1.0 / (1.0 + np.maximum(avg_distance, 0.0))
except cv2.error:
return None
def peaks(response, tw, th):
out = []
try:
r = response.copy()
for _ in range(TOP_K):
_, score, _, loc = cv2.minMaxLoc(r)
if score <= 0:
break
out.append((float(score), int(loc[0]), int(loc[1])))
x0 = max(0, loc[0] - int(tw * 0.65))
y0 = max(0, loc[1] - int(th * 0.65))
x1 = min(r.shape[1], loc[0] + int(tw * 0.65))
y1 = min(r.shape[0], loc[1] + int(th * 0.65))
r[y0:y1, x0:x1] = 0
except Exception as e:
print(f" 峰提取失败: {e}")
return out
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_shapes(mark_path, big_path)
if loaded is None:
lines.append(f"{prefix}\tSKIP\t读取失败")
return
big, _, edge = loaded
bg_edges = cv2.Canny(big, 50, 150)
candidates = []
for scale in SCALES:
for angle in ANGLES:
tpl = transformed_edge(edge, float(scale), float(angle))
if tpl is None:
continue
response = chamfer_response(bg_edges, tpl)
if response is None:
continue
for score, x, y in peaks(response, tpl.shape[1], tpl.shape[0]):
candidates.append((score, float(scale), float(angle), x, y,
tpl.shape[1], tpl.shape[0]))
if not candidates:
lines.append(f"{prefix}\tSKIP\t无候选")
return
# 跨尺度/角度 NMS
candidates.sort(reverse=True)
kept = []
for item in candidates:
score, scale, angle, x, y, w, h = item
cx, cy = x + w / 2, y + h / 2
if all(math.hypot(cx - (q[3] + q[5] / 2), cy - (q[4] + q[6] / 2)) > 0.6 * max(w, h)
for q in kept):
kept.append(item)
if len(kept) >= TOP_K:
break
best = kept[0]
score, scale, angle, x, y, w, h = best
print(f"{prefix} best score={score:.3f} scale={scale:.2f} angle={angle:+.0f} "
f"offset=({x},{y}) top={len(kept)}")
lines.append(f"{prefix}\tscore={score:.3f}\tscale={scale:.2f}\tangle={angle:+.0f}\t"
f"offset=({x},{y})\ttop={len(kept)}")
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()