- src/: 最终交付物(solve.py base64 API、method_l_shape.py 核心算法、verify_result.py 验证工具) - docs/: 方案文档与实验演进记录 - try/: 历史实验脚本(A~K 方法) - 10/10 样本求解成功,3 个独立真值锚点偏差 <=4px
173 lines
6.4 KiB
Python
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()
|