feat: implement stamp content matching

This commit is contained in:
2026-06-24 09:42:05 +08:00
parent 7ea57e1ef8
commit 56e8907f28
2 changed files with 244 additions and 112 deletions
+220 -110
View File
@@ -1,50 +1,52 @@
"""Generate a CSV report matching merged images to source stamp images. """Find the source stamp image that best matches a merged clothing image.
This script is intentionally standalone and does not import cmbot project code. This script is intentionally standalone and does not import cmbot project code.
Edit the three paths below, then run: Edit the paths below, or pass them on the command line:
python match_stamp.py python match_stamp.py --image output\20260623_094529\TY037\1_TY037.png --stamp-dir D:\stamps
It does not copy, move, rename, or delete any image files. It does not copy, move, rename, delete, or write image files. Results are
printed directly.
""" """
from __future__ import print_function from __future__ import print_function
import csv import argparse
from datetime import datetime import sys
import time
from pathlib import Path from pathlib import Path
try:
import cv2
import numpy as np
from PIL import Image
except ImportError as exc:
print("Missing dependency: {}".format(exc))
print("Install required packages: pip install opencv-python numpy Pillow")
raise
OUTPUT_DIR = r"D:\chengma\cmbot\output\20260623_094529"
MERGED_IMAGE = r"D:\chengma\cmbot\output\20260623_094529\TY037\1_TY037.png"
STAMP_DIR = r"D:\chengma\印花和底图\已处理印花\卡通71(66大码200斤 KEKE已上)\横1" STAMP_DIR = r"D:\chengma\印花和底图\已处理印花\卡通71(66大码200斤 KEKE已上)\横1"
REPORT_PATH = r"D:\chengma\cmbot\match_stamp_report.csv"
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
FIELDNAMES = [ TOP_N = 5
"status",
"code", SCALE_MIN = 0.10
"merged_image", SCALE_MAX = 2.00
"stamp_image", SCALE_STEP = 0.05
"merged_folder",
"message", ALPHA_THRESHOLD = 20
] LOW_SCORE_WARNING_THRESHOLD = 0.55
TEMPLATE_SCORE_WEIGHT = 0.75
EDGE_SCORE_WEIGHT = 0.25
MIN_MATCH_WIDTH = 24
MIN_MATCH_HEIGHT = 24
def is_image(path): def is_image(path):
return path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS return path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS
def extract_code_from_merged_name(path):
"""Extract stamp code from the final underscore suffix.
Example: 1_TY037.png -> TY037.
"""
stem = path.stem
if "_" not in stem:
return ""
code = stem.rsplit("_", 1)[1].strip()
return code
def iter_images(root): def iter_images(root):
root = Path(root) root = Path(root)
if not root.exists(): if not root.exists():
@@ -54,103 +56,211 @@ def iter_images(root):
yield path yield path
def build_stamp_index(stamp_dir): def load_rgba(path):
index = {} """Load with Pillow so Windows Chinese paths are handled reliably."""
for path in iter_images(stamp_dir): with Image.open(str(path)) as image:
code = path.stem.strip() return np.array(image.convert("RGBA"))
if not code:
def rgba_to_gray(rgba):
rgb = rgba[:, :, :3]
return cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
def rgba_to_rgb(rgba):
return rgba[:, :, :3]
def crop_transparent_border(rgba):
alpha = rgba[:, :, 3]
ys, xs = np.where(alpha > ALPHA_THRESHOLD)
if len(xs) == 0 or len(ys) == 0:
mask = np.ones(alpha.shape, dtype=np.uint8) * 255
return rgba[:, :, :3], mask
left = int(xs.min())
right = int(xs.max()) + 1
top = int(ys.min())
bottom = int(ys.max()) + 1
cropped = rgba[top:bottom, left:right]
mask = (cropped[:, :, 3] > ALPHA_THRESHOLD).astype(np.uint8) * 255
return cropped[:, :, :3], mask
def build_scales():
scales = []
value = SCALE_MIN
while value <= SCALE_MAX + 0.0001:
scales.append(round(value, 4))
value += SCALE_STEP
return scales
def safe_match_template(source, template, mask=None):
if template.shape[0] > source.shape[0] or template.shape[1] > source.shape[1]:
return None
if template.shape[0] < MIN_MATCH_HEIGHT or template.shape[1] < MIN_MATCH_WIDTH:
return None
if mask is not None and int(np.count_nonzero(mask)) < 9:
return None
match_mask = mask
if match_mask is not None and template.ndim == 3 and match_mask.ndim == 2:
channels = template.shape[2]
match_mask = cv2.merge([match_mask] * channels)
try:
result = cv2.matchTemplate(source, template, cv2.TM_CCORR_NORMED, mask=match_mask)
except cv2.error:
return None
result = np.asarray(result, dtype=np.float32)
result[~np.isfinite(result)] = -1.0
return np.clip(result, 0.0, 1.0)
def resize_for_scale(image, scale, interpolation):
height, width = image.shape[:2]
new_width = max(1, int(round(width * scale)))
new_height = max(1, int(round(height * scale)))
return cv2.resize(image, (new_width, new_height), interpolation=interpolation)
def score_stamp(merged_rgb, merged_edges, stamp_path, scales):
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)
template_result = safe_match_template(merged_rgb, scaled_rgb, scaled_mask)
if template_result is None:
continue continue
index.setdefault(code, []).append(path.resolve())
return index
edge_result = safe_match_template(merged_edges, scaled_edges, scaled_mask)
if edge_result is None:
edge_result = np.zeros(template_result.shape, dtype=np.float32)
def match_merged_image(path, stamp_index): combined = (
code = extract_code_from_merged_name(path) TEMPLATE_SCORE_WEIGHT * template_result
if not code: + EDGE_SCORE_WEIGHT * edge_result
return { )
"status": "bad_name", _min_val, max_val, _min_loc, max_loc = cv2.minMaxLoc(combined)
"code": "", x, y = max_loc
"merged_image": str(path.resolve()), template_score = float(template_result[y, x])
"stamp_image": "", edge_score = float(edge_result[y, x])
"merged_folder": str(path.parent.resolve()), score = float(max_val)
"message": "File name has no underscore suffix code",
if best is None or score > best["score"]:
best = {
"score": score,
"template_score": template_score,
"edge_score": edge_score,
"stamp_path": stamp_path,
"x": int(x),
"y": int(y),
"scale": float(scale),
"width": int(scaled_gray.shape[1]),
"height": int(scaled_gray.shape[0]),
} }
candidates = stamp_index.get(code, []) return best
if not candidates:
return {
"status": "missing",
"code": code,
"merged_image": str(path.resolve()),
"stamp_image": "",
"merged_folder": str(path.parent.resolve()),
"message": "No stamp image found for code",
}
stamp_paths = " | ".join(str(p) for p in candidates)
if len(candidates) > 1:
return {
"status": "duplicate",
"code": code,
"merged_image": str(path.resolve()),
"stamp_image": stamp_paths,
"merged_folder": str(path.parent.resolve()),
"message": "Multiple stamp images found for code",
}
return {
"status": "matched",
"code": code,
"merged_image": str(path.resolve()),
"stamp_image": stamp_paths,
"merged_folder": str(path.parent.resolve()),
"message": "",
}
def write_csv(rows, report_path): def find_best_matches(merged_image, stamp_dir, top_n):
report_path = Path(report_path) merged_rgba = load_rgba(merged_image)
report_path.parent.mkdir(parents=True, exist_ok=True) merged_rgb = rgba_to_rgb(merged_rgba)
with report_path.open("w", encoding="utf-8-sig", newline="") as f: merged_gray = rgba_to_gray(merged_rgba)
writer = csv.DictWriter(f, fieldnames=FIELDNAMES) merged_edges = cv2.Canny(merged_gray, 80, 160)
writer.writeheader() scales = build_scales()
for row in rows:
writer.writerow(row) results = []
for stamp_path in iter_images(stamp_dir):
try:
result = score_stamp(merged_rgb, merged_edges, stamp_path, scales)
except Exception as exc:
print("Skipped: {} ({})".format(stamp_path, exc))
continue
if result is not None:
results.append(result)
results.sort(key=lambda item: item["score"], reverse=True)
return results[:top_n], len(results)
def generate_report(output_dir, stamp_dir, report_path): def print_result(results, matched_count, merged_image, stamp_dir):
output_dir = Path(output_dir) print("Merged image: {}".format(Path(merged_image).resolve()))
stamp_dir = Path(stamp_dir) print("Stamp dir: {}".format(Path(stamp_dir).resolve()))
print("Matched stamp candidates: {}".format(matched_count))
print("")
stamp_index = build_stamp_index(stamp_dir) if not results:
rows = [] print("No valid image match result.")
for path in iter_images(output_dir): return
rows.append(match_merged_image(path, stamp_index))
write_csv(rows, report_path) best = results[0]
return rows print("Best match:")
print("score: {:.4f}".format(best["score"]))
print("template_score: {:.4f}".format(best["template_score"]))
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("size: {}x{}".format(best["width"], best["height"]))
if best["score"] < LOW_SCORE_WARNING_THRESHOLD:
print("warning: best score is low; please review manually.")
print("")
print("Top {}:".format(len(results)))
for index, item in enumerate(results, 1):
print(
"{}. {:.4f} template={:.4f} edge={:.4f} scale={:.4f} {}"
.format(
index,
item["score"],
item["template_score"],
item["edge_score"],
item["scale"],
Path(item["stamp_path"]).name,
)
)
def summarize(rows): def parse_args(argv):
counts = {} parser = argparse.ArgumentParser(
for row in rows: description="Match one merged clothing image against a stamp directory."
status = row.get("status", "") )
counts[status] = counts.get(status, 0) + 1 parser.add_argument("--image", default=MERGED_IMAGE, help="Merged clothing image path")
return counts parser.add_argument("--stamp-dir", default=STAMP_DIR, help="Stamp image directory")
parser.add_argument("--top", default=TOP_N, type=int, help="Number of results to print")
return parser.parse_args(argv)
def main(): def main(argv=None):
started = datetime.now() args = parse_args(argv or sys.argv[1:])
rows = generate_report(OUTPUT_DIR, STAMP_DIR, REPORT_PATH) merged_image = Path(args.image)
counts = summarize(rows) stamp_dir = Path(args.stamp_dir)
print("Generated report: {}".format(Path(REPORT_PATH).resolve()))
print("Merged images: {}".format(len(rows))) if not merged_image.is_file():
print("Matched: {}".format(counts.get("matched", 0))) print("Merged image not found: {}".format(merged_image))
print("Missing: {}".format(counts.get("missing", 0))) return 1
print("Duplicate: {}".format(counts.get("duplicate", 0))) if not stamp_dir.is_dir():
print("Bad name: {}".format(counts.get("bad_name", 0))) print("Stamp directory not found: {}".format(stamp_dir))
print("Elapsed: {}".format(datetime.now() - started)) return 1
started = time.time()
results, matched_count = find_best_matches(merged_image, stamp_dir, max(1, args.top))
print_result(results, matched_count, merged_image, stamp_dir)
print("")
print("Elapsed: {:.2f}s".format(time.time() - started))
return 0
if __name__ == "__main__": if __name__ == "__main__":
main() raise SystemExit(main())
+22
View File
@@ -1569,3 +1569,25 @@
- [x] 明确第三方库:最小 `opencv-python` + `numpy`,建议用 `Pillow` 处理 Windows 中文路径 - [x] 明确第三方库:最小 `opencv-python` + `numpy`,建议用 `Pillow` 处理 Windows 中文路径
- [x] 明确约束:独立脚本、不引用项目代码、不修改图片、第一版不写 CSV/Excel、不接入 GUI - [x] 明确约束:独立脚本、不引用项目代码、不修改图片、第一版不写 CSV/Excel、不接入 GUI
- [x] 验证:文档格式检查通过 - [x] 验证:文档格式检查通过
### 19.32 独立脚本:按图片内容匹配合并图印花 — `match_stamp.py`
前置阅读:
- `docs/12-stamp-template-matching.md`
- `match_stamp.py`
背景:
§19.31 已明确真实需求不是按 `1_TY037.png -> TY037.png` 的文件名规则匹配,而是给定一张衣服 / 合并图和一个印花目录,通过图像内容匹配找出最相似的印花。第一版继续保持为独立脚本,不接入 GUI,不写 CSV,直接打印结果。
任务:
- [x] 将 `match_stamp.py` 从“递归扫描 output 并按文件名写 CSV”改为“单张合并图 + 印花目录内容匹配”
- [x] 使用 `Pillow` 读取图片以兼容 Windows 中文路径,再转为 `numpy` / OpenCV 数组
- [x] 遍历印花目录内 PNG/JPG/JPEG/WEBP 图片,对透明 PNG 使用 alpha mask 并裁剪透明边界
- [x] 使用多尺度模板匹配,主分数使用 RGB 彩色 `matchTemplate`,边缘图分数作为辅助
- [x] 输出最佳匹配和 Top N,包含综合分、模板分、边缘分、坐标、缩放比例和匹配尺寸
- [x] 找不到高可信结果时打印低分提示;不保存 CSV,不修改任何图片
- [x] 支持命令行覆盖 `--image`、`--stamp-dir`、`--top`
- [x] 验证:`python -m py_compile match_stamp.py` 通过;临时构造一张合并图和两个候选印花,Top 1 命中正确印花