serve_card_match.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 卡牌匹配 HTTP 服务(双区级联版 · 与入库框架同源)
  5. 用途:前端 curl 发一张图片 URL / 上传文件 → 算法端用「上半区召回 + 下半区重排」
  6. 返回命中的 card_id + cards_master_v2 有用字段。
  7. 流程(与 scripts/cascade_match.py / 入库完全体同源):
  8. 下载/读图 → CardDetector 裁卡
  9. → letterbox 392 → 上半区[0:196] DINOv2-large → 全库点积召回 Top-K
  10. → 下半区[196:392] Layer3 → fusion=α·upper+(1-α)·lower 重排
  11. → card_id → 查 card_master_fields.csv → 返回
  12. 返回字段(/match 后台接口 2026-08-28 二次定稿,契约见 指令文档/8000后台接口字段解释文档.md):
  13. 识别输入 = front_image_url(拍摄图正面地址,树莓派直传什么就是什么,不限定 cos——MinIO 内网
  14. 地址等只要 73 服务器可访问即可下载识别;back_image_url 不参与识别仅回显);
  15. 识别得出: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) /
  16. attribute(YOLO yolo26s_det_attribute, 中文如'火',检不出 null;JSON 字段名 2026-09-02 由 element 改为 attribute)
  17. 透传回显:front_image_url / back_image_url
  18. matches:Top-K 候选(默认 5,top_k 可调,match_rate=round(fusion×100)),每项除 rank/card_id/
  19. match_rate 外均为 card_id 查 cards_master_v2 库字段:
  20. card_name_ch / pg_label / year / card_no(编号'042/222') / rarity_short(简称'RR',缺则回退
  21. 全称) / language / material_cn(库值优先如'精灵球闪',缺则"1") / image_url(库版cos图)
  22. /match_fields(小程序C端 2026-09-02):顶层裸数组,每项结构与 /match 完全一致;
  23. 一张拍照图可能有多张卡,按阅读顺序(上→下、同行左→右)依次输出;
  24. C 端拍摄图落到 249 MinIO grading/capp_img_data,front_image_url 回真地址,
  25. back_image_url 恒为 null(C 端无背面)。未检出卡返回 []。
  26. 启动:
  27. python serve_card_match.py --port 8000
  28. 调用(后台 /match:front_image_url 为识别输入源,任意 73 可访问地址均可,不限 cos;image_url 仍兼容):
  29. 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'
  30. curl -X POST http://<服务器>:8000/match -F "image=@C:/本机/某张卡.jpg"
  31. curl 'http://<服务器>:8000/match?image_url=/home/user/某张卡.jpg'
  32. """
  33. import os
  34. import sys
  35. import csv
  36. import argparse
  37. import traceback
  38. sys.stdout.reconfigure(encoding="utf-8")
  39. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  40. import numpy as np
  41. import config
  42. from modules import image_downloader
  43. # DualCascadeMatcher 放在 scripts/ 下,需要把 scripts 加进 path
  44. _SCRIPTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
  45. if _SCRIPTS not in sys.path:
  46. sys.path.insert(0, _SCRIPTS)
  47. from cascade_match import DualCascadeMatcher # noqa: E402
  48. from modules.attribute_detector import AttributeDetector # noqa: E402
  49. # ==================== 启动时一次性加载 ====================
  50. print("[启动] 加载双区级联匹配器(上半区召回 + 下半区重排)...", flush=True)
  51. MATCHER = DualCascadeMatcher(
  52. alpha=config.CASCADE_ALPHA,
  53. top_k_recall=config.CASCADE_TOP_K_RECALL,
  54. verbose=True,
  55. )
  56. print("[启动] 图库大小:", len(MATCHER.card_ids),
  57. "α=", MATCHER.alpha, "K_recall=", MATCHER.top_k_recall, flush=True)
  58. print("[启动] 加载元素属性检测模型...", flush=True)
  59. ELEM = AttributeDetector()
  60. # cards_master_v2(PG hs_sync_data.cards_master_v2) 导出的字段表(2026-08-28 v2 版):
  61. # rarity=全称(C端用) / rarity_short=简称(后台matches用) / material_cn=材质(空则接口定值1) /
  62. # img_url=库版cos地址(pokemon_masked_v2,后台matches[].image_url) /
  63. # img_url_legacy=旧ai_image地址(C端frontImageUrl沿用,保证C端数据不变)
  64. CARD_FIELDS = {}
  65. _FIELDS_CSV = os.path.join(config.DATA_DIR, "card_master_fields_v2.csv")
  66. if not os.path.exists(_FIELDS_CSV):
  67. _FIELDS_CSV = os.path.join(config.DATA_DIR, "card_master_fields.csv")
  68. if os.path.exists(_FIELDS_CSV):
  69. with open(_FIELDS_CSV, encoding="utf-8-sig") as _f:
  70. for _r in csv.DictReader(_f):
  71. CARD_FIELDS[_r["card_id"]] = {
  72. "card_name_ch": _r.get("card_name_ch"),
  73. "pg_label": _r.get("pg_label"),
  74. "year": _r.get("year"),
  75. "card_no": _r.get("card_no"),
  76. "rarity": _r.get("rarity"),
  77. "rarity_short": _r.get("rarity_short"),
  78. "language": _r.get("language"),
  79. "material_cn": _r.get("material_cn"),
  80. "img_url": _r.get("img_url"),
  81. "img_url_legacy": _r.get("img_url_legacy") or _r.get("img_url"),
  82. }
  83. print("[启动] 卡牌字段表: %d 张 (%s)" % (len(CARD_FIELDS), os.path.basename(_FIELDS_CSV)), flush=True)
  84. else:
  85. print("[启动] ⚠ 未找到 card_master_fields_v2.csv / card_master_fields.csv,将回退双区 gallery meta", flush=True)
  86. # 双区 meta 索引(fallback)
  87. _ID2META = {}
  88. for _m in MATCHER.metas:
  89. cid = _m.get("card_id")
  90. if cid and cid not in _ID2META:
  91. _ID2META[cid] = _m
  92. HISTORY_JSON_PATH = os.path.join(config.DATA_DIR, "match_results_history.json")
  93. print("[启动] 就绪,等待请求", flush=True)
  94. def _public_item(result, front_url=None, back_url=None):
  95. """把 match_image 内部结果组装成 /match 对外契约(单卡 dict)。"""
  96. full_match = result.get("match") or {}
  97. return {
  98. "card_type": result.get("card_type") or 1,
  99. "card_id": full_match.get("card_id"),
  100. "attribute": result.get("element"),
  101. "front_image_url": front_url,
  102. "back_image_url": back_url,
  103. "matches": [{
  104. "rank": it.get("rank"),
  105. "card_id": it.get("card_id"),
  106. "match_rate": int(max(0, min(100, round(float(it.get("fusion", 0)) * 100)))),
  107. "card_name_ch": it.get("card_name_ch"),
  108. "pg_label": it.get("pg_label"),
  109. "year": it.get("year"),
  110. "card_no": it.get("card_no"),
  111. "rarity_short": it.get("rarity_short") or it.get("rarity"),
  112. "language": it.get("language"),
  113. "material_cn": it.get("material_cn") or "1",
  114. "image_url": it.get("img_url"),
  115. } for it in (result.get("top_k") or [])],
  116. }
  117. def _upload_capp_to_minio(local_path):
  118. """C 端拍摄图落到 249 MinIO grading/capp_img_data,返回对外 URL;失败返回 None。"""
  119. if not local_path or not os.path.exists(local_path):
  120. return None
  121. cfg = getattr(config, "CAPP_MINIO", None)
  122. if not cfg:
  123. return None
  124. try:
  125. from minio import Minio
  126. from datetime import datetime
  127. client = Minio(
  128. cfg["endpoint"],
  129. access_key=cfg["access_key"],
  130. secret_key=cfg["secret_key"],
  131. secure=bool(cfg.get("secure")),
  132. )
  133. ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")
  134. ext = os.path.splitext(local_path)[1].lower() or ".jpg"
  135. if ext not in (".jpg", ".jpeg", ".png", ".webp", ".bmp"):
  136. ext = ".jpg"
  137. object_name = "%s/capp_front_%s%s" % (cfg["prefix"], ts, ext)
  138. ctype = "image/jpeg" if ext in (".jpg", ".jpeg") else "image/%s" % ext.lstrip(".")
  139. client.fput_object(cfg["bucket"], object_name, local_path, content_type=ctype)
  140. return "%s/%s/%s" % (cfg["public_base"].rstrip("/"), cfg["bucket"], object_name)
  141. except Exception:
  142. traceback.print_exc()
  143. return None
  144. def _card_fields(card_id):
  145. """优先 card_master_fields_v2.csv;否则回退双区 gallery meta。"""
  146. row = CARD_FIELDS.get(card_id)
  147. if row:
  148. return dict(row)
  149. m = _ID2META.get(card_id) or {}
  150. return {
  151. "card_name_ch": m.get("card_name_ch"),
  152. "pg_label": m.get("pg_label"),
  153. "year": m.get("year"),
  154. "card_no": m.get("card_no"),
  155. "rarity": None,
  156. "rarity_short": None,
  157. "language": m.get("language"),
  158. "material_cn": None,
  159. "img_url": None,
  160. "img_url_legacy": None,
  161. }
  162. def _append_history(result):
  163. import json
  164. from datetime import datetime
  165. history = []
  166. if os.path.exists(HISTORY_JSON_PATH):
  167. try:
  168. with open(HISTORY_JSON_PATH, "r", encoding="utf-8") as f:
  169. history = json.load(f)
  170. if not isinstance(history, list):
  171. history = []
  172. except Exception:
  173. history = []
  174. result = dict(result)
  175. result["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  176. history.append(result)
  177. with open(HISTORY_JSON_PATH, "w", encoding="utf-8") as f:
  178. json.dump(history, f, ensure_ascii=False, indent=2)
  179. def _resolve_local_path(src):
  180. """把 image_url / 上传路径解析为服务器本地文件路径。
  181. 返回 (path, cleanup_path):cleanup 非 None 表示本次临时文件,推理后删。"""
  182. if src.startswith(("http://", "https://")):
  183. img_path = image_downloader.download_card_image(src, config.QUERY_IMG_DIR)
  184. if not img_path:
  185. raise RuntimeError("图片下载失败(公网 URL 访问不到): " + src)
  186. return img_path, img_path
  187. path = src[7:] if src.startswith("file://") else src
  188. if not os.path.isabs(path):
  189. path = os.path.join(config.QUERY_IMG_DIR, path)
  190. if not os.path.exists(path):
  191. raise RuntimeError("本地文件不存在(注意是服务器上的路径,不是本机路径): " + path)
  192. return path, None
  193. def _internal_from_query(result_q, element):
  194. """query()/query_all() 单卡结果 + element → 内部 result(历史日志 + _public_item 共用)。"""
  195. predicted = result_q["predicted_card_id"]
  196. fusion = float(result_q["fusion_score"])
  197. upper = float(result_q["upper_sim"])
  198. lower = float(result_q["lower_sim"])
  199. topk = []
  200. for rank, item in enumerate(result_q.get("top_k") or [], 1):
  201. cid = item["card_id"]
  202. row = _card_fields(cid)
  203. row["card_id"] = cid
  204. row["rank"] = rank
  205. row["similarity"] = round(float(item.get("fusion", 0)), 4)
  206. row["fusion"] = round(float(item.get("fusion", 0)), 4)
  207. row["upper_sim"] = round(float(item.get("upper", 0)), 4)
  208. row["lower_sim"] = round(float(item.get("lower", 0)), 4)
  209. topk.append(row)
  210. match_row = _card_fields(predicted)
  211. match_row["card_id"] = predicted
  212. match_row["similarity"] = round(fusion, 4)
  213. match_row["fusion"] = round(fusion, 4)
  214. match_row["upper_sim"] = round(upper, 4)
  215. match_row["lower_sim"] = round(lower, 4)
  216. match_row["above_threshold"] = fusion >= config.SIMILARITY_THRESHOLD
  217. return {
  218. "predicted_card_id": predicted,
  219. "similarity": round(fusion, 4),
  220. "fusion": round(fusion, 4),
  221. "upper_sim": round(upper, 4),
  222. "lower_sim": round(lower, 4),
  223. "above_threshold": fusion >= config.SIMILARITY_THRESHOLD,
  224. "card_type": result_q.get("card_type"),
  225. "element": element,
  226. "match": match_row,
  227. "top_k": topk,
  228. "model": "card_seg_v0904",
  229. "alpha": MATCHER.alpha,
  230. "top_k_recall": MATCHER.top_k_recall,
  231. }
  232. def match_image_all(src, top_k=5):
  233. """多卡:YOLO 一次检出全部实例,按阅读顺序各自检索。未检出返回 []。
  234. 返回 (items, img_path, dl_cleanup);调用方负责上传 MinIO 后再删临时文件。"""
  235. img_path, dl_cleanup = _resolve_local_path(src)
  236. results_q = MATCHER.query_all(img_path, top_n=top_k)
  237. items = []
  238. for result_q in results_q:
  239. crop = result_q.get("crop")
  240. element = ELEM.predict(crop) if crop is not None else None
  241. result = _internal_from_query(result_q, element)
  242. try:
  243. _append_history(result)
  244. except Exception:
  245. pass
  246. items.append(result)
  247. return items, img_path, dl_cleanup
  248. def match_image(src, top_k=5, cleanup_path=None):
  249. """图片(URL/服务器路径/上传临时文件) → 双区级联预测 card_id + 字段。
  250. language/angle 参数在双区级联入库链路里不作为检索前置(入库用召回后语种消歧),
  251. 本 HTTP 服务保持与入库同源:直接双区检索。"""
  252. img_path, dl_cleanup = _resolve_local_path(src)
  253. to_delete = cleanup_path or dl_cleanup
  254. try:
  255. # DualCascadeMatcher 内部:裁卡 → letterbox → 上下半区特征 → 召回+重排
  256. # 它的 query() 读文件路径;我们已落到本地文件
  257. result_q = MATCHER.query(img_path, top_n=top_k, want_crop=True)
  258. if result_q is None:
  259. raise RuntimeError("未检出卡牌区域或特征提取失败")
  260. # 元素属性检测(用裁卡图跑 element 模型)
  261. crop = result_q.get("crop")
  262. element = ELEM.predict(crop) if crop is not None else None
  263. result = _internal_from_query(result_q, element)
  264. try:
  265. _append_history(result)
  266. except Exception:
  267. pass
  268. return result
  269. finally:
  270. if to_delete and os.path.exists(to_delete):
  271. try:
  272. os.remove(to_delete)
  273. except Exception:
  274. pass
  275. def create_app():
  276. from flask import Flask, request, jsonify
  277. app = Flask(__name__)
  278. # 按插入顺序输出字段(/match 的字段顺序即指令文档示例顺序)+ 中文直出
  279. app.json.sort_keys = False
  280. app.json.ensure_ascii = False
  281. @app.get("/health")
  282. def health():
  283. return jsonify({
  284. "status": "ok",
  285. "gallery_size": len(MATCHER.card_ids),
  286. "model": "card_seg_v0904",
  287. "feature_dim": 1024,
  288. "alpha": MATCHER.alpha,
  289. "top_k_recall": MATCHER.top_k_recall,
  290. })
  291. def _resolve_src():
  292. """解析请求里的图片来源。返回 (src, cleanup, top_k, err)。
  293. err 非 None 时为 ({...}, http_code)。"""
  294. data = request.get_json(silent=True) or {}
  295. try:
  296. top_k = int(data.get("top_k", request.args.get("top_k", request.form.get("top_k", config.TOP_K))))
  297. except (TypeError, ValueError):
  298. top_k = config.TOP_K
  299. src = None
  300. cleanup = None
  301. up = request.files.get("image")
  302. if up and up.filename:
  303. import hashlib
  304. import re as _re
  305. blob = up.read()
  306. if not blob:
  307. return None, None, top_k, ({"error": "上传文件为空"}, 400)
  308. config.ensure_dirs()
  309. # 保留上传方原始文件名(如树莓派的 raspi_front_<时间戳>.jpg,全链路命名统一);
  310. # 名字不合法(缺名/带路径/非法字符)时回退 md5 命名。请求图均为临时文件,推理后即删。
  311. raw = os.path.basename(up.filename.replace("\\", "/")).strip()
  312. if raw and _re.fullmatch(r"[A-Za-z0-9._\-()()一-鿿]+", raw) and raw.lower().endswith(
  313. (".jpg", ".jpeg", ".png", ".webp", ".bmp")):
  314. fname = raw
  315. else:
  316. fname = hashlib.md5(blob).hexdigest() + ".jpg"
  317. dst = os.path.join(config.QUERY_IMG_DIR, fname)
  318. with open(dst, "wb") as fo:
  319. fo.write(blob)
  320. src = dst
  321. cleanup = dst
  322. if src is None:
  323. # 后台识别源:拍摄图正面 cos 地址(树莓派直传 front_image_url);
  324. # 兼容旧参数 image_url(小程序 /match_fields 仍用它,不带 front_image_url 不受影响)
  325. src = (data.get("front_image_url") or request.args.get("front_image_url")
  326. or request.form.get("front_image_url")
  327. or data.get("image_url") or request.args.get("image_url")
  328. or request.form.get("image_url"))
  329. if not src:
  330. return None, None, top_k, ({"error": "缺少图片:请传 front_image_url(拍摄图正面)或 image_url,或上传 image 文件"}, 400)
  331. return src, cleanup, top_k, None
  332. def _resolve_and_run():
  333. src, cleanup, top_k, err = _resolve_src()
  334. if err:
  335. return None, err
  336. try:
  337. result = match_image(src, top_k=top_k, cleanup_path=cleanup)
  338. return result, None
  339. except Exception as e:
  340. traceback.print_exc()
  341. return None, ({"error": str(e)}, 500)
  342. @app.post("/match")
  343. @app.get("/match")
  344. def match():
  345. """后台接口(2026-08-28 二次定稿,字段契约见文件头 docstring;C 端 /match_fields 不在此列)。
  346. 识别输入 = front_image_url(拍摄图正面 cos 地址,树莓派直传);back_image_url 不参与
  347. 识别、仅回显。识别得出 card_type/card_id/attribute,其余字段经 card_id 从
  348. cards_master_v2 导出的字段表查得。"""
  349. # front/back 参数三种来源都收(GET query / POST form / JSON body)
  350. data = request.get_json(silent=True) or {}
  351. front_url = (data.get("front_image_url") or request.args.get("front_image_url")
  352. or request.form.get("front_image_url"))
  353. back_url = (data.get("back_image_url") or request.args.get("back_image_url")
  354. or request.form.get("back_image_url"))
  355. result, err = _resolve_and_run()
  356. if err:
  357. return jsonify(err[0]), err[1]
  358. return jsonify(_public_item(result, front_url=front_url, back_url=back_url))
  359. @app.post("/match_fields")
  360. @app.get("/match_fields")
  361. def match_fields():
  362. """小程序接口(2026-09-02):顶层裸数组,每项结构与 /match 完全一致。
  363. 一张拍照图可能有多张卡,按阅读顺序依次输出;未检出返回 []。
  364. C 端拍摄图落到 249 MinIO grading/capp_img_data,front_image_url 回真地址,
  365. back_image_url 恒为 null。传 URL 入参时 front_image_url 原样回显(不再二次上传)。"""
  366. src, cleanup, top_k, err = _resolve_src()
  367. if err:
  368. return jsonify(err[0]), err[1]
  369. data = request.get_json(silent=True) or {}
  370. passed_url = (data.get("front_image_url") or request.args.get("front_image_url")
  371. or request.form.get("front_image_url")
  372. or data.get("image_url") or request.args.get("image_url")
  373. or request.form.get("image_url"))
  374. is_http = isinstance(passed_url, str) and passed_url.startswith(("http://", "https://"))
  375. img_path = None
  376. dl_cleanup = None
  377. try:
  378. items, img_path, dl_cleanup = match_image_all(src, top_k=top_k)
  379. if is_http:
  380. front_url = passed_url
  381. else:
  382. front_url = _upload_capp_to_minio(img_path)
  383. resp = [_public_item(it, front_url=front_url, back_url=None) for it in items]
  384. return jsonify(resp)
  385. except Exception as e:
  386. traceback.print_exc()
  387. return jsonify({"error": str(e)}), 500
  388. finally:
  389. for p in (cleanup, dl_cleanup):
  390. if p and os.path.exists(p):
  391. try:
  392. os.remove(p)
  393. except Exception:
  394. pass
  395. return app
  396. if __name__ == "__main__":
  397. p = argparse.ArgumentParser(description="卡牌匹配 HTTP 服务(双区级联)")
  398. p.add_argument("--host", default="0.0.0.0")
  399. p.add_argument("--port", type=int, default=8000)
  400. args = p.parse_args()
  401. create_app().run(host=args.host, port=args.port, threaded=True)