| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- 卡牌匹配 HTTP 服务(双区级联版 · 与入库框架同源)
- 用途:前端 curl 发一张图片 URL / 上传文件 → 算法端用「上半区召回 + 下半区重排」
- 返回命中的 card_id + cards_master_v2 有用字段。
- 流程(与 scripts/cascade_match.py / 入库完全体同源):
- 下载/读图 → CardDetector 裁卡
- → letterbox 392 → 上半区[0:196] DINOv2-large → 全库点积召回 Top-K
- → 下半区[196:392] Layer3 → fusion=α·upper+(1-α)·lower 重排
- → card_id → 查 card_master_fields.csv → 返回
- 返回字段(/match 后台接口 2026-08-28 二次定稿,契约见 指令文档/8000后台接口字段解释文档.md):
- 识别输入 = front_image_url(拍摄图正面地址,树莓派直传什么就是什么,不限定 cos——MinIO 内网
- 地址等只要 73 服务器可访问即可下载识别;back_image_url 不参与识别仅回显);
- 识别得出:card_type(YOLO card_seg_pn_v2——基于 v1 于2026-09-04微调(404张密集多卡人工标注并入), 多卡mAP50 0.826→0.986; 取值 'pokemon'/'nba') / card_id(dinov2-large 双区检索 Top-1) /
- attribute(YOLO yolo26s_det_attribute, 中文如'火',检不出 null;JSON 字段名 2026-09-02 由 element 改为 attribute)
- 透传回显:front_image_url / back_image_url
- matches:Top-K 候选(默认 5,top_k 可调,match_rate=round(fusion×100)),每项除 rank/card_id/
- match_rate 外均为 card_id 查 cards_master_v2 库字段:
- card_name_ch / pg_label / year / card_no(编号'042/222') / rarity_short(简称'RR',缺则回退
- 全称) / language / material_cn(库值优先如'精灵球闪',缺则"1") / image_url(库版cos图)
- /match_fields(小程序C端 2026-09-02):顶层裸数组,每项结构与 /match 完全一致;
- 一张拍照图可能有多张卡,按阅读顺序(上→下、同行左→右)依次输出;
- C 端拍摄图落到 249 MinIO grading/capp_img_data,front_image_url 回真地址,
- back_image_url 恒为 null(C 端无背面)。未检出卡返回 []。
- 启动:
- python serve_card_match.py --port 8000
- 调用(后台 /match:front_image_url 为识别输入源,任意 73 可访问地址均可,不限 cos;image_url 仍兼容):
- curl 'http://<服务器>:8000/match?front_image_url=http://192.168.77.80:9000/pokemon/xxx.jpg&back_image_url=http://192.168.77.80:9000/pokemon/yyy.jpg'
- curl -X POST http://<服务器>:8000/match -F "image=@C:/本机/某张卡.jpg"
- curl 'http://<服务器>:8000/match?image_url=/home/user/某张卡.jpg'
- """
- import os
- import sys
- import csv
- import argparse
- import traceback
- sys.stdout.reconfigure(encoding="utf-8")
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
- import numpy as np
- import config
- from modules import image_downloader
- # DualCascadeMatcher 放在 scripts/ 下,需要把 scripts 加进 path
- _SCRIPTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
- if _SCRIPTS not in sys.path:
- sys.path.insert(0, _SCRIPTS)
- from cascade_match import DualCascadeMatcher # noqa: E402
- from modules.attribute_detector import AttributeDetector # noqa: E402
- # ==================== 启动时一次性加载 ====================
- print("[启动] 加载双区级联匹配器(上半区召回 + 下半区重排)...", flush=True)
- MATCHER = DualCascadeMatcher(
- alpha=config.CASCADE_ALPHA,
- top_k_recall=config.CASCADE_TOP_K_RECALL,
- verbose=True,
- )
- print("[启动] 图库大小:", len(MATCHER.card_ids),
- "α=", MATCHER.alpha, "K_recall=", MATCHER.top_k_recall, flush=True)
- print("[启动] 加载元素属性检测模型...", flush=True)
- ELEM = AttributeDetector()
- # cards_master_v2(PG hs_sync_data.cards_master_v2) 导出的字段表(2026-08-28 v2 版):
- # rarity=全称(C端用) / rarity_short=简称(后台matches用) / material_cn=材质(空则接口定值1) /
- # img_url=库版cos地址(pokemon_masked_v2,后台matches[].image_url) /
- # img_url_legacy=旧ai_image地址(C端frontImageUrl沿用,保证C端数据不变)
- CARD_FIELDS = {}
- _FIELDS_CSV = os.path.join(config.DATA_DIR, "card_master_fields_v2.csv")
- if not os.path.exists(_FIELDS_CSV):
- _FIELDS_CSV = os.path.join(config.DATA_DIR, "card_master_fields.csv")
- if os.path.exists(_FIELDS_CSV):
- with open(_FIELDS_CSV, encoding="utf-8-sig") as _f:
- for _r in csv.DictReader(_f):
- CARD_FIELDS[_r["card_id"]] = {
- "card_name_ch": _r.get("card_name_ch"),
- "pg_label": _r.get("pg_label"),
- "year": _r.get("year"),
- "card_no": _r.get("card_no"),
- "rarity": _r.get("rarity"),
- "rarity_short": _r.get("rarity_short"),
- "language": _r.get("language"),
- "material_cn": _r.get("material_cn"),
- "img_url": _r.get("img_url"),
- "img_url_legacy": _r.get("img_url_legacy") or _r.get("img_url"),
- }
- print("[启动] 卡牌字段表: %d 张 (%s)" % (len(CARD_FIELDS), os.path.basename(_FIELDS_CSV)), flush=True)
- else:
- print("[启动] ⚠ 未找到 card_master_fields_v2.csv / card_master_fields.csv,将回退双区 gallery meta", flush=True)
- # 双区 meta 索引(fallback)
- _ID2META = {}
- for _m in MATCHER.metas:
- cid = _m.get("card_id")
- if cid and cid not in _ID2META:
- _ID2META[cid] = _m
- HISTORY_JSON_PATH = os.path.join(config.DATA_DIR, "match_results_history.json")
- print("[启动] 就绪,等待请求", flush=True)
- def _public_item(result, front_url=None, back_url=None):
- """把 match_image 内部结果组装成 /match 对外契约(单卡 dict)。"""
- full_match = result.get("match") or {}
- return {
- "card_type": result.get("card_type") or 1,
- "card_id": full_match.get("card_id"),
- "attribute": result.get("element"),
- "front_image_url": front_url,
- "back_image_url": back_url,
- "matches": [{
- "rank": it.get("rank"),
- "card_id": it.get("card_id"),
- "match_rate": int(max(0, min(100, round(float(it.get("fusion", 0)) * 100)))),
- "card_name_ch": it.get("card_name_ch"),
- "pg_label": it.get("pg_label"),
- "year": it.get("year"),
- "card_no": it.get("card_no"),
- "rarity_short": it.get("rarity_short") or it.get("rarity"),
- "language": it.get("language"),
- "material_cn": it.get("material_cn") or "1",
- "image_url": it.get("img_url"),
- } for it in (result.get("top_k") or [])],
- }
- def _upload_capp_to_minio(local_path):
- """C 端拍摄图落到 249 MinIO grading/capp_img_data,返回对外 URL;失败返回 None。"""
- if not local_path or not os.path.exists(local_path):
- return None
- cfg = getattr(config, "CAPP_MINIO", None)
- if not cfg:
- return None
- try:
- from minio import Minio
- from datetime import datetime
- client = Minio(
- cfg["endpoint"],
- access_key=cfg["access_key"],
- secret_key=cfg["secret_key"],
- secure=bool(cfg.get("secure")),
- )
- ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")
- ext = os.path.splitext(local_path)[1].lower() or ".jpg"
- if ext not in (".jpg", ".jpeg", ".png", ".webp", ".bmp"):
- ext = ".jpg"
- object_name = "%s/capp_front_%s%s" % (cfg["prefix"], ts, ext)
- ctype = "image/jpeg" if ext in (".jpg", ".jpeg") else "image/%s" % ext.lstrip(".")
- client.fput_object(cfg["bucket"], object_name, local_path, content_type=ctype)
- return "%s/%s/%s" % (cfg["public_base"].rstrip("/"), cfg["bucket"], object_name)
- except Exception:
- traceback.print_exc()
- return None
- def _card_fields(card_id):
- """优先 card_master_fields_v2.csv;否则回退双区 gallery meta。"""
- row = CARD_FIELDS.get(card_id)
- if row:
- return dict(row)
- m = _ID2META.get(card_id) or {}
- return {
- "card_name_ch": m.get("card_name_ch"),
- "pg_label": m.get("pg_label"),
- "year": m.get("year"),
- "card_no": m.get("card_no"),
- "rarity": None,
- "rarity_short": None,
- "language": m.get("language"),
- "material_cn": None,
- "img_url": None,
- "img_url_legacy": None,
- }
- def _append_history(result):
- import json
- from datetime import datetime
- history = []
- if os.path.exists(HISTORY_JSON_PATH):
- try:
- with open(HISTORY_JSON_PATH, "r", encoding="utf-8") as f:
- history = json.load(f)
- if not isinstance(history, list):
- history = []
- except Exception:
- history = []
- result = dict(result)
- result["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- history.append(result)
- with open(HISTORY_JSON_PATH, "w", encoding="utf-8") as f:
- json.dump(history, f, ensure_ascii=False, indent=2)
- def _resolve_local_path(src):
- """把 image_url / 上传路径解析为服务器本地文件路径。
- 返回 (path, cleanup_path):cleanup 非 None 表示本次临时文件,推理后删。"""
- if src.startswith(("http://", "https://")):
- img_path = image_downloader.download_card_image(src, config.QUERY_IMG_DIR)
- if not img_path:
- raise RuntimeError("图片下载失败(公网 URL 访问不到): " + src)
- return img_path, img_path
- path = src[7:] if src.startswith("file://") else src
- if not os.path.isabs(path):
- path = os.path.join(config.QUERY_IMG_DIR, path)
- if not os.path.exists(path):
- raise RuntimeError("本地文件不存在(注意是服务器上的路径,不是本机路径): " + path)
- return path, None
- def _internal_from_query(result_q, element):
- """query()/query_all() 单卡结果 + element → 内部 result(历史日志 + _public_item 共用)。"""
- predicted = result_q["predicted_card_id"]
- fusion = float(result_q["fusion_score"])
- upper = float(result_q["upper_sim"])
- lower = float(result_q["lower_sim"])
- topk = []
- for rank, item in enumerate(result_q.get("top_k") or [], 1):
- cid = item["card_id"]
- row = _card_fields(cid)
- row["card_id"] = cid
- row["rank"] = rank
- row["similarity"] = round(float(item.get("fusion", 0)), 4)
- row["fusion"] = round(float(item.get("fusion", 0)), 4)
- row["upper_sim"] = round(float(item.get("upper", 0)), 4)
- row["lower_sim"] = round(float(item.get("lower", 0)), 4)
- topk.append(row)
- match_row = _card_fields(predicted)
- match_row["card_id"] = predicted
- match_row["similarity"] = round(fusion, 4)
- match_row["fusion"] = round(fusion, 4)
- match_row["upper_sim"] = round(upper, 4)
- match_row["lower_sim"] = round(lower, 4)
- match_row["above_threshold"] = fusion >= config.SIMILARITY_THRESHOLD
- return {
- "predicted_card_id": predicted,
- "similarity": round(fusion, 4),
- "fusion": round(fusion, 4),
- "upper_sim": round(upper, 4),
- "lower_sim": round(lower, 4),
- "above_threshold": fusion >= config.SIMILARITY_THRESHOLD,
- "card_type": result_q.get("card_type"),
- "element": element,
- "match": match_row,
- "top_k": topk,
- "model": "card_seg_v0904",
- "alpha": MATCHER.alpha,
- "top_k_recall": MATCHER.top_k_recall,
- }
- def match_image_all(src, top_k=5):
- """多卡:YOLO 一次检出全部实例,按阅读顺序各自检索。未检出返回 []。
- 返回 (items, img_path, dl_cleanup);调用方负责上传 MinIO 后再删临时文件。"""
- img_path, dl_cleanup = _resolve_local_path(src)
- results_q = MATCHER.query_all(img_path, top_n=top_k)
- items = []
- for result_q in results_q:
- crop = result_q.get("crop")
- element = ELEM.predict(crop) if crop is not None else None
- result = _internal_from_query(result_q, element)
- try:
- _append_history(result)
- except Exception:
- pass
- items.append(result)
- return items, img_path, dl_cleanup
- def match_image(src, top_k=5, cleanup_path=None):
- """图片(URL/服务器路径/上传临时文件) → 双区级联预测 card_id + 字段。
- language/angle 参数在双区级联入库链路里不作为检索前置(入库用召回后语种消歧),
- 本 HTTP 服务保持与入库同源:直接双区检索。"""
- img_path, dl_cleanup = _resolve_local_path(src)
- to_delete = cleanup_path or dl_cleanup
- try:
- # DualCascadeMatcher 内部:裁卡 → letterbox → 上下半区特征 → 召回+重排
- # 它的 query() 读文件路径;我们已落到本地文件
- result_q = MATCHER.query(img_path, top_n=top_k, want_crop=True)
- if result_q is None:
- raise RuntimeError("未检出卡牌区域或特征提取失败")
- # 元素属性检测(用裁卡图跑 element 模型)
- crop = result_q.get("crop")
- element = ELEM.predict(crop) if crop is not None else None
- result = _internal_from_query(result_q, element)
- try:
- _append_history(result)
- except Exception:
- pass
- return result
- finally:
- if to_delete and os.path.exists(to_delete):
- try:
- os.remove(to_delete)
- except Exception:
- pass
- def create_app():
- from flask import Flask, request, jsonify
- app = Flask(__name__)
- # 按插入顺序输出字段(/match 的字段顺序即指令文档示例顺序)+ 中文直出
- app.json.sort_keys = False
- app.json.ensure_ascii = False
- @app.get("/health")
- def health():
- return jsonify({
- "status": "ok",
- "gallery_size": len(MATCHER.card_ids),
- "model": "card_seg_v0904",
- "feature_dim": 1024,
- "alpha": MATCHER.alpha,
- "top_k_recall": MATCHER.top_k_recall,
- })
- def _resolve_src():
- """解析请求里的图片来源。返回 (src, cleanup, top_k, err)。
- err 非 None 时为 ({...}, http_code)。"""
- data = request.get_json(silent=True) or {}
- try:
- top_k = int(data.get("top_k", request.args.get("top_k", request.form.get("top_k", config.TOP_K))))
- except (TypeError, ValueError):
- top_k = config.TOP_K
- src = None
- cleanup = None
- up = request.files.get("image")
- if up and up.filename:
- import hashlib
- import re as _re
- blob = up.read()
- if not blob:
- return None, None, top_k, ({"error": "上传文件为空"}, 400)
- config.ensure_dirs()
- # 保留上传方原始文件名(如树莓派的 raspi_front_<时间戳>.jpg,全链路命名统一);
- # 名字不合法(缺名/带路径/非法字符)时回退 md5 命名。请求图均为临时文件,推理后即删。
- raw = os.path.basename(up.filename.replace("\\", "/")).strip()
- if raw and _re.fullmatch(r"[A-Za-z0-9._\-()()一-鿿]+", raw) and raw.lower().endswith(
- (".jpg", ".jpeg", ".png", ".webp", ".bmp")):
- fname = raw
- else:
- fname = hashlib.md5(blob).hexdigest() + ".jpg"
- dst = os.path.join(config.QUERY_IMG_DIR, fname)
- with open(dst, "wb") as fo:
- fo.write(blob)
- src = dst
- cleanup = dst
- if src is None:
- # 后台识别源:拍摄图正面 cos 地址(树莓派直传 front_image_url);
- # 兼容旧参数 image_url(小程序 /match_fields 仍用它,不带 front_image_url 不受影响)
- src = (data.get("front_image_url") or request.args.get("front_image_url")
- or request.form.get("front_image_url")
- or data.get("image_url") or request.args.get("image_url")
- or request.form.get("image_url"))
- if not src:
- return None, None, top_k, ({"error": "缺少图片:请传 front_image_url(拍摄图正面)或 image_url,或上传 image 文件"}, 400)
- return src, cleanup, top_k, None
- def _resolve_and_run():
- src, cleanup, top_k, err = _resolve_src()
- if err:
- return None, err
- try:
- result = match_image(src, top_k=top_k, cleanup_path=cleanup)
- return result, None
- except Exception as e:
- traceback.print_exc()
- return None, ({"error": str(e)}, 500)
- @app.post("/match")
- @app.get("/match")
- def match():
- """后台接口(2026-08-28 二次定稿,字段契约见文件头 docstring;C 端 /match_fields 不在此列)。
- 识别输入 = front_image_url(拍摄图正面 cos 地址,树莓派直传);back_image_url 不参与
- 识别、仅回显。识别得出 card_type/card_id/attribute,其余字段经 card_id 从
- cards_master_v2 导出的字段表查得。"""
- # front/back 参数三种来源都收(GET query / POST form / JSON body)
- data = request.get_json(silent=True) or {}
- front_url = (data.get("front_image_url") or request.args.get("front_image_url")
- or request.form.get("front_image_url"))
- back_url = (data.get("back_image_url") or request.args.get("back_image_url")
- or request.form.get("back_image_url"))
- result, err = _resolve_and_run()
- if err:
- return jsonify(err[0]), err[1]
- return jsonify(_public_item(result, front_url=front_url, back_url=back_url))
- @app.post("/match_fields")
- @app.get("/match_fields")
- def match_fields():
- """小程序接口(2026-09-02):顶层裸数组,每项结构与 /match 完全一致。
- 一张拍照图可能有多张卡,按阅读顺序依次输出;未检出返回 []。
- C 端拍摄图落到 249 MinIO grading/capp_img_data,front_image_url 回真地址,
- back_image_url 恒为 null。传 URL 入参时 front_image_url 原样回显(不再二次上传)。"""
- src, cleanup, top_k, err = _resolve_src()
- if err:
- return jsonify(err[0]), err[1]
- data = request.get_json(silent=True) or {}
- passed_url = (data.get("front_image_url") or request.args.get("front_image_url")
- or request.form.get("front_image_url")
- or data.get("image_url") or request.args.get("image_url")
- or request.form.get("image_url"))
- is_http = isinstance(passed_url, str) and passed_url.startswith(("http://", "https://"))
- img_path = None
- dl_cleanup = None
- try:
- items, img_path, dl_cleanup = match_image_all(src, top_k=top_k)
- if is_http:
- front_url = passed_url
- else:
- front_url = _upload_capp_to_minio(img_path)
- resp = [_public_item(it, front_url=front_url, back_url=None) for it in items]
- return jsonify(resp)
- except Exception as e:
- traceback.print_exc()
- return jsonify({"error": str(e)}), 500
- finally:
- for p in (cleanup, dl_cleanup):
- if p and os.path.exists(p):
- try:
- os.remove(p)
- except Exception:
- pass
- return app
- if __name__ == "__main__":
- p = argparse.ArgumentParser(description="卡牌匹配 HTTP 服务(双区级联)")
- p.add_argument("--host", default="0.0.0.0")
- p.add_argument("--port", type=int, default=8000)
- args = p.parse_args()
- create_app().run(host=args.host, port=args.port, threaded=True)
|