From f6ee4b452a0e1749eb0a62ff61ee3fa24a982c63 Mon Sep 17 00:00:00 2001 From: ila Date: Wed, 24 Jun 2026 09:51:34 +0800 Subject: [PATCH] feat: support nonuniform stamp matching --- match_stamp.py | 66 ++++++++++++++++++++++++++++++++++++++------------ tasks.md | 20 +++++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/match_stamp.py b/match_stamp.py index c8d3d12..6a4c01d 100644 --- a/match_stamp.py +++ b/match_stamp.py @@ -34,6 +34,7 @@ TOP_N = 5 SCALE_MIN = 0.10 SCALE_MAX = 2.00 SCALE_STEP = 0.05 +ASPECT_Y_FACTORS = (0.60, 0.70, 0.80, 0.90, 1.00, 1.10, 1.20) ALPHA_THRESHOLD = 20 LOW_SCORE_WARNING_THRESHOLD = 0.55 @@ -96,6 +97,27 @@ def build_scales(): return scales +def build_scale_variants(): + variants = [] + seen = set() + for scale in build_scales(): + for aspect_y in ASPECT_Y_FACTORS: + scale_x = scale + scale_y = round(scale * aspect_y, 4) + key = (scale_x, scale_y, aspect_y) + if key in seen: + continue + seen.add(key) + variants.append( + { + "scale_x": scale_x, + "scale_y": scale_y, + "aspect_y": aspect_y, + } + ) + return variants + + def safe_match_template(source, template, mask=None): if template.shape[0] > source.shape[0] or template.shape[1] > source.shape[1]: return None @@ -119,25 +141,28 @@ def safe_match_template(source, template, mask=None): return np.clip(result, 0.0, 1.0) -def resize_for_scale(image, scale, interpolation): +def resize_for_scale(image, scale_x, scale_y, interpolation): height, width = image.shape[:2] - new_width = max(1, int(round(width * scale))) - new_height = max(1, int(round(height * scale))) + new_width = max(1, int(round(width * scale_x))) + new_height = max(1, int(round(height * scale_y))) return cv2.resize(image, (new_width, new_height), interpolation=interpolation) -def score_stamp(merged_rgb, merged_edges, stamp_path, scales): +def score_stamp(merged_rgb, merged_edges, stamp_path, scale_variants): stamp_rgba = load_rgba(stamp_path) stamp_rgb, stamp_mask = crop_transparent_border(stamp_rgba) stamp_gray = cv2.cvtColor(stamp_rgb, cv2.COLOR_RGB2GRAY) stamp_edges = cv2.Canny(stamp_gray, 80, 160) best = None - for scale in scales: - scaled_rgb = resize_for_scale(stamp_rgb, scale, cv2.INTER_AREA) - scaled_gray = resize_for_scale(stamp_gray, scale, cv2.INTER_AREA) - scaled_edges = resize_for_scale(stamp_edges, scale, cv2.INTER_NEAREST) - scaled_mask = resize_for_scale(stamp_mask, scale, cv2.INTER_NEAREST) + for variant in scale_variants: + scale_x = variant["scale_x"] + scale_y = variant["scale_y"] + aspect_y = variant["aspect_y"] + scaled_rgb = resize_for_scale(stamp_rgb, scale_x, scale_y, cv2.INTER_AREA) + scaled_gray = resize_for_scale(stamp_gray, scale_x, scale_y, cv2.INTER_AREA) + scaled_edges = resize_for_scale(stamp_edges, scale_x, scale_y, cv2.INTER_NEAREST) + scaled_mask = resize_for_scale(stamp_mask, scale_x, scale_y, cv2.INTER_NEAREST) template_result = safe_match_template(merged_rgb, scaled_rgb, scaled_mask) if template_result is None: @@ -165,7 +190,9 @@ def score_stamp(merged_rgb, merged_edges, stamp_path, scales): "stamp_path": stamp_path, "x": int(x), "y": int(y), - "scale": float(scale), + "scale_x": float(scale_x), + "scale_y": float(scale_y), + "aspect_y": float(aspect_y), "width": int(scaled_gray.shape[1]), "height": int(scaled_gray.shape[0]), } @@ -178,12 +205,12 @@ def find_best_matches(merged_image, stamp_dir, top_n): merged_rgb = rgba_to_rgb(merged_rgba) merged_gray = rgba_to_gray(merged_rgba) merged_edges = cv2.Canny(merged_gray, 80, 160) - scales = build_scales() + scale_variants = build_scale_variants() results = [] for stamp_path in iter_images(stamp_dir): try: - result = score_stamp(merged_rgb, merged_edges, stamp_path, scales) + result = score_stamp(merged_rgb, merged_edges, stamp_path, scale_variants) except Exception as exc: print("Skipped: {} ({})".format(stamp_path, exc)) continue @@ -211,7 +238,9 @@ def print_result(results, matched_count, merged_image, stamp_dir): print("edge_score: {:.4f}".format(best["edge_score"])) print("stamp: {}".format(Path(best["stamp_path"]).resolve())) print("location: x={}, y={}".format(best["x"], best["y"])) - print("scale: {:.4f}".format(best["scale"])) + print("scale_x: {:.4f}".format(best["scale_x"])) + print("scale_y: {:.4f}".format(best["scale_y"])) + print("aspect_y: {:.4f}".format(best["aspect_y"])) print("size: {}x{}".format(best["width"], best["height"])) if best["score"] < LOW_SCORE_WARNING_THRESHOLD: print("warning: best score is low; please review manually.") @@ -219,14 +248,19 @@ def print_result(results, matched_count, merged_image, stamp_dir): print("") print("Top {}:".format(len(results))) for index, item in enumerate(results, 1): + line = ( + "{}. {:.4f} template={:.4f} edge={:.4f} " + "scale_x={:.4f} scale_y={:.4f} aspect_y={:.4f} {}" + ) print( - "{}. {:.4f} template={:.4f} edge={:.4f} scale={:.4f} {}" - .format( + line.format( index, item["score"], item["template_score"], item["edge_score"], - item["scale"], + item["scale_x"], + item["scale_y"], + item["aspect_y"], Path(item["stamp_path"]).name, ) ) diff --git a/tasks.md b/tasks.md index 37c097c..fac7c6f 100644 --- a/tasks.md +++ b/tasks.md @@ -1606,3 +1606,23 @@ - [x] 明确 `1000x1000 -> 1000x800` 可由 `scale_x=1.00`、`scale_y=0.80`、`aspect_y=0.80` 覆盖 - [x] 更新输出字段建议:从单一 `scale` 改为 `scale_x`、`scale_y`、`aspect_y` - [x] 验证:文档路径、标题、示例和验收点检查通过 + +### 19.34 独立脚本:支持宽高不等比例印花匹配 — `match_stamp.py` + +前置阅读: + +- `docs/12-stamp-template-matching.md` +- `match_stamp.py` + +背景: + +§19.33 已明确实际合成时印花可能被非等比例缩放,例如原始 `1000x1000` 合成后变成 `1000x800`。`match_stamp.py` 需要按文档把单一 `scale` 搜索扩展为基础 `scale` 加有限 `aspect_y` 候选,提升这类场景的匹配稳定性。 + +任务: + +- [x] 新增 `ASPECT_Y_FACTORS = 0.60 / 0.70 / 0.80 / 0.90 / 1.00 / 1.10 / 1.20` +- [x] 将缩放搜索从单一 `scale` 改为 `scale_x=scale`、`scale_y=scale*aspect_y` +- [x] RGB 模板、边缘模板和 alpha mask 使用同一组 `scale_x / scale_y` 缩放 +- [x] 最佳结果保存并打印 `scale_x`、`scale_y`、`aspect_y`,不再只打印单一 `scale` +- [x] 保持独立脚本约束:不引用项目代码、不写 CSV、不修改图片 +- [x] 验证:`python -m py_compile match_stamp.py` 通过;临时构造 `100x100 -> 100x80` 非等比例样本,Top 1 命中正确印花且输出 `aspect_y=0.8000`