serve_card_match_v2.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 卡牌匹配 HTTP 服务 v2(8020 生产接口 · 排队 + 缓存 + 多 worker 分卡)
  5. 定位:替代 8000 serve_card_match.py 的小程序多卡链路,正式上线版。
  6. - 同步等待 + 服务内部排队:请求入队(上限 V2_QUEUE_SIZE,满即 429+Retry-After),
  7. N 个推理 worker 消费(V2_WORKERS=0 时自动=可见 GPU 数,249 双 V100 即双 worker 分卡),
  8. handler 等结果带超时(V2_WAIT_TIMEOUT,超时 504,队头任务会被取消跳过)。
  9. - 内容 md5 缓存(LRU+TTL):URL 入参同样按下载后的内容算 key,同一张图重复识别零推理;
  10. 缓存值不含 image_url,返回前按本次请求盖章(URL/上传/同内容异源全部命中)。
  11. - 近距 mask 合并:modules/yolo_detector.detect_and_crop_all 内实现(8000 多卡路径共用),
  12. 阈值 CARD_MERGE_DIST_PX(默认 50px,中心距,保留 conf 高者)。
  13. - attribute 不跑 YOLO element 模型:按候选 card_id 查字段表 CSV(列现为空 → null,
  14. 业务侧把 PG cards_master_v2.attribute 填好后重导 CSV 即自动生效)。
  15. - 历史日志:data/match_v2_history.jsonl 逐行追加(弃 8000 全量重写单 JSON 的竞态写法)。
  16. 端点(响应契约 2026-09-03 定稿,顶层裸数组,每项):
  17. {"card_type":"pokemon", "card_id":"0119993",
  18. "image_url":"http://192.168.77.249:9000/grading/capp_img_data/capp_front_<ts>.jpg",
  19. "matches":[{rank, card_id, match_rate, card_name_ch, pg_label, year, card_no,
  20. rarity_short, language, attribute, material_cn, image_url} × 5]}
  21. top_k 固定 5(入参被忽略);未检出返回 [];入参缺失 400;队列满 429;推理超时 504;
  22. 异常 500 {"error":...}。请求可带 refresh=1 击穿缓存强制重算(测试/强制刷新用)。
  23. 启动(249,CVD 掩码必须 PCI_BUS_ID + CVD 两变量一起写,GTX1060=PCI index0 禁用):
  24. # 直跑(Flask 内置 server)
  25. CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=1,2 setsid nohup \
  26. ~/miniconda3/envs/pytorch/bin/python serve_card_match_v2.py --port 8020 \
  27. </dev/null > logs/serve_8020.log 2>&1 &
  28. # gunicorn(推荐:worker 崩溃自愈 + 优雅退出;禁 --preload,CUDA 不能 fork)
  29. CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=1,2 setsid nohup \
  30. ~/miniconda3/envs/pytorch/bin/gunicorn -w 1 --threads 8 -b 0.0.0.0:8020 \
  31. --timeout 300 --graceful-timeout 30 serve_card_match_v2:app \
  32. </dev/null > logs/serve_8020.log 2>&1 &
  33. 调用:
  34. curl -X POST http://192.168.77.249:8020/match_fields -F "image=@C:/某张卡.jpg"
  35. curl -X POST http://192.168.77.249:8020/match_fields -H 'Content-Type: application/json' \
  36. -d '{"image_url":"<图片URL>"}'
  37. """
  38. import os
  39. import sys
  40. import csv
  41. import json
  42. import time
  43. import queue
  44. import signal
  45. import argparse
  46. import threading
  47. import traceback
  48. import copy
  49. sys.stdout.reconfigure(encoding="utf-8")
  50. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  51. _SCRIPTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
  52. if _SCRIPTS not in sys.path:
  53. sys.path.insert(0, _SCRIPTS)
  54. import config
  55. from modules.capp_upload import upload_to_capp_minio, save_upload_to_tmp, file_md5
  56. from cascade_match import DualCascadeMatcher
  57. TOP_K_FIXED = config.TOP_K # 固定 5
  58. QUEUE_MAX = max(1, int(getattr(config, "V2_QUEUE_SIZE", 32)))
  59. WAIT_TIMEOUT = float(getattr(config, "V2_WAIT_TIMEOUT", 60))
  60. CACHE_MAX = max(1, int(getattr(config, "V2_CACHE_MAX", 256)))
  61. CACHE_TTL = float(getattr(config, "V2_CACHE_TTL", 900))
  62. MAX_CONTENT_MB = int(getattr(config, "V2_MAX_CONTENT_MB", 20))
  63. HISTORY_JSONL = os.path.join(config.DATA_DIR, "match_v2_history.jsonl")
  64. _T0 = time.time()
  65. _SHUTDOWN = threading.Event()
  66. _LOG_LOCK = threading.Lock()
  67. _STATS_LOCK = threading.Lock()
  68. _STATS = {"submitted": 0, "done": 0, "failed": 0, "queue_full": 0,
  69. "timeouts": 0, "cache_hits": 0, "cache_misses": 0}
  70. # ==================== 启动时一次性加载(gunicorn 兼容:模块级执行) ====================
  71. def _auto_workers():
  72. """worker 数:V2_WORKERS>0 用配置;0=可见 GPU 数(至少 1,无 GPU 走 CPU 单 worker)。"""
  73. w = int(getattr(config, "V2_WORKERS", 0) or 0)
  74. if w > 0:
  75. return w
  76. try:
  77. import torch
  78. return max(1, torch.cuda.device_count()) if torch.cuda.is_available() else 1
  79. except Exception:
  80. return 1
  81. def _gpu_count():
  82. try:
  83. import torch
  84. return torch.cuda.device_count() if torch.cuda.is_available() else 0
  85. except Exception:
  86. return 0
  87. N_WORKERS = _auto_workers()
  88. _GPU_COUNT = _gpu_count()
  89. print(f"[启动] 推理 worker 数 = {N_WORKERS}(可见 GPU = {_GPU_COUNT},"
  90. f"CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES', '<未设>')})", flush=True)
  91. # 每 worker 一套独立 matcher(YOLO 分割 + 双区 DINOv2 + 检索后端连接):
  92. # 多卡时各绑一张卡(torch.device 输入不会被 vendored ultralytics 改写 CVD),
  93. # milvus 连接各自独立 alias,避免多线程共享默认连接。
  94. MATCHERS = []
  95. for _i in range(N_WORKERS):
  96. _dev = None
  97. if _GPU_COUNT:
  98. _dev = f"cuda:{_i % _GPU_COUNT}"
  99. import torch as _torch
  100. _dev = _torch.device(_dev)
  101. print(f"[启动] worker{_i} 初始化 matcher(device={_dev})...", flush=True)
  102. MATCHERS.append(DualCascadeMatcher(
  103. alpha=config.CASCADE_ALPHA,
  104. top_k_recall=config.CASCADE_TOP_K_RECALL,
  105. device=_dev,
  106. detector_device=_dev,
  107. milvus_alias=f"v2w{_i}",
  108. verbose=True,
  109. ))
  110. # 字段表(92,333 行) DictReader 兼容无 attribute 列的旧 CSV(.get → None)
  111. CARD_FIELDS = {}
  112. _FIELDS_CSV = os.path.join(config.DATA_DIR, "card_master_fields_v2.csv")
  113. if not os.path.exists(_FIELDS_CSV):
  114. _FIELDS_CSV = os.path.join(config.DATA_DIR, "card_master_fields.csv")
  115. if os.path.exists(_FIELDS_CSV):
  116. with open(_FIELDS_CSV, encoding="utf-8-sig") as _f:
  117. for _r in csv.DictReader(_f):
  118. CARD_FIELDS[_r["card_id"]] = _r
  119. print(f"[启动] 卡牌字段表: {len(CARD_FIELDS)} 张 ({os.path.basename(_FIELDS_CSV)})", flush=True)
  120. else:
  121. print("[启动] ⚠ 未找到 card_master_fields_v2.csv,matches 字段将回退 gallery meta", flush=True)
  122. # gallery meta 兜底索引
  123. _ID2META = {}
  124. for _m in (MATCHERS[0].metas or []):
  125. _cid = (_m or {}).get("card_id")
  126. if _cid and _cid not in _ID2META:
  127. _ID2META[_cid] = _m
  128. config.ensure_dirs()
  129. os.makedirs(os.path.dirname(HISTORY_JSONL), exist_ok=True)
  130. def _card_fields(card_id):
  131. """card_id → 字段表行(dict);字段表缺失回退 gallery meta(空值输出 None)。"""
  132. row = CARD_FIELDS.get(card_id)
  133. if row:
  134. return row
  135. m = _ID2META.get(card_id) or {}
  136. return {"card_name_ch": m.get("card_name_ch"), "pg_label": m.get("pg_label"),
  137. "year": m.get("year"), "card_no": m.get("card_no"),
  138. "rarity": None, "rarity_short": None, "language": m.get("language"),
  139. "material_cn": None, "img_url": None, "img_url_legacy": None,
  140. "attribute": None}
  141. # ==================== 缓存(LRU + TTL,线程安全) ====================
  142. class TTLCache:
  143. """key=内容 md5 → 响应(不含 image_url,返回前盖章)。命中搬 LRU 尾,过期即删。"""
  144. def __init__(self, max_size, ttl):
  145. self.max_size = max_size
  146. self.ttl = ttl
  147. self._d = {} # key -> (expire_ts, value)
  148. self._lock = threading.Lock()
  149. def get(self, key):
  150. now = time.time()
  151. with self._lock:
  152. item = self._d.get(key)
  153. if item is None:
  154. return None
  155. expire, value = item
  156. if now >= expire:
  157. self._d.pop(key, None)
  158. return None
  159. self._d[key] = self._d.pop(key) # move to end (py3.7+ dict 有序)
  160. return value
  161. def put(self, key, value):
  162. with self._lock:
  163. self._d[key] = (time.time() + self.ttl, value)
  164. while len(self._d) > self.max_size:
  165. self._d.pop(next(iter(self._d)))
  166. def info(self):
  167. with self._lock:
  168. return {"size": len(self._d), "max": self.max_size, "ttl": self.ttl}
  169. _CACHE = TTLCache(CACHE_MAX, CACHE_TTL)
  170. # ==================== 任务队列 + worker ====================
  171. class _Job:
  172. __slots__ = ("src", "cleanup", "result", "error", "done", "cancelled",
  173. "worker_id", "elapsed_ms", "md5")
  174. def __init__(self, src, cleanup, md5hex):
  175. self.src = src
  176. self.cleanup = cleanup # 临时文件删除责任归 worker(429 未入队时 handler 自删)
  177. self.md5 = md5hex
  178. self.result = None
  179. self.error = None
  180. self.done = threading.Event()
  181. self.cancelled = False
  182. self.worker_id = None
  183. self.elapsed_ms = None
  184. _QUEUE = queue.Queue(maxsize=QUEUE_MAX)
  185. def _run_pipeline(matcher, src):
  186. """本地图路径 → 多卡识别 → 基础响应(不含本次请求的 image_url,盖章在 handler)。"""
  187. results = matcher.query_all(src, top_n=TOP_K_FIXED)
  188. items = []
  189. for r in results:
  190. predicted = r.get("predicted_card_id")
  191. matches = []
  192. for rank, t in enumerate(r.get("top_k") or [], 1):
  193. row = _card_fields(t.get("card_id"))
  194. matches.append({
  195. "rank": rank,
  196. "card_id": t.get("card_id"),
  197. "match_rate": int(max(0, min(100, round(float(t.get("fusion", 0)) * 100)))),
  198. "card_name_ch": row.get("card_name_ch"),
  199. "pg_label": row.get("pg_label"),
  200. "year": row.get("year"),
  201. "card_no": row.get("card_no"),
  202. "rarity_short": row.get("rarity_short") or row.get("rarity"),
  203. "language": row.get("language"),
  204. "attribute": row.get("attribute") or None, # 列现为空 → null;填库重导后自动生效
  205. "material_cn": row.get("material_cn") or "1",
  206. "image_url": row.get("img_url") or row.get("img_url_legacy") or None,
  207. })
  208. items.append({
  209. "card_type": r.get("card_type"),
  210. "card_id": predicted,
  211. "image_url": None,
  212. "matches": matches,
  213. })
  214. return items
  215. def _worker_loop(matcher, wid):
  216. while not _SHUTDOWN.is_set():
  217. try:
  218. job = _QUEUE.get(timeout=0.5)
  219. except queue.Empty:
  220. continue
  221. try:
  222. if job.cancelled: # handler 已超时放弃:跳过推理,仅清理
  223. with _STATS_LOCK:
  224. _STATS["done"] += 1
  225. return_present(job, skipped=True)
  226. continue
  227. t0 = time.perf_counter()
  228. try:
  229. job.result = _run_pipeline(matcher, job.src)
  230. with _STATS_LOCK:
  231. _STATS["done"] += 1
  232. except Exception as e:
  233. job.error = e
  234. traceback.print_exc()
  235. with _STATS_LOCK:
  236. _STATS["failed"] += 1
  237. finally:
  238. job.worker_id = wid
  239. job.elapsed_ms = int((time.perf_counter() - t0) * 1000)
  240. return_present(job)
  241. finally:
  242. _QUEUE.task_done()
  243. def return_present(job, skipped=False):
  244. """收尾:置 done、写 jsonl 历史、删临时文件。"""
  245. job.done.set()
  246. rec = {
  247. "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
  248. "worker": job.worker_id,
  249. "md5": job.md5,
  250. "skipped": bool(skipped),
  251. "elapsed_ms": job.elapsed_ms,
  252. "n_cards": len(job.result) if job.result is not None else None,
  253. "card_ids": [it.get("card_id") for it in (job.result or [])],
  254. "error": (str(job.error)[:300] if job.error else None),
  255. }
  256. try:
  257. with _LOG_LOCK:
  258. with open(HISTORY_JSONL, "a", encoding="utf-8") as f:
  259. f.write(json.dumps(rec, ensure_ascii=False) + "\n")
  260. except Exception:
  261. pass
  262. if job.cleanup and os.path.exists(job.cleanup):
  263. try:
  264. os.remove(job.cleanup)
  265. except OSError:
  266. pass
  267. def _start_workers():
  268. for i, m in enumerate(MATCHERS):
  269. t = threading.Thread(target=_worker_loop, args=(m, i),
  270. name=f"v2-worker{i}", daemon=True)
  271. t.start()
  272. print(f"[启动] worker{i} 线程已启动", flush=True)
  273. _start_workers()
  274. # ==================== HTTP ====================
  275. def _stamp(items, image_url):
  276. """把本次请求的 image_url 盖进每个 item(deepcopy 防跨请求共享)。"""
  277. out = copy.deepcopy(items)
  278. for it in out:
  279. it["image_url"] = image_url
  280. return out
  281. def _download_unique(url):
  282. """URL 图片下载到唯一临时路径(worker 推理后删)。
  283. 不用 image_downloader.download_card_image:它按 URL 命名且"存在即复用",
  284. 并发同 URL 会在同一文件上互踩(8020 队列并发场景必炸),且其断点续传语义
  285. 被建库批量逻辑依赖不能改。"""
  286. import requests as _rq
  287. last_err = None
  288. for _ in range(3):
  289. try:
  290. resp = _rq.get(url, timeout=15)
  291. if resp.status_code == 200 and resp.content:
  292. name = os.path.basename(url.split("?")[0].rstrip("/")) or ""
  293. return save_upload_to_tmp(resp.content, name, config.QUERY_IMG_DIR)
  294. last_err = "HTTP %s" % resp.status_code
  295. except Exception as e:
  296. last_err = str(e)
  297. print(f"[match_fields] 下载失败 url={url[:120]}: {last_err}", flush=True)
  298. return None
  299. def create_app():
  300. from flask import Flask, request, jsonify
  301. app = Flask(__name__)
  302. app.json.sort_keys = False
  303. app.json.ensure_ascii = False
  304. app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_MB * 1024 * 1024
  305. @app.errorhandler(413)
  306. def _too_large(_e):
  307. return jsonify({"error": f"上传文件超过 {MAX_CONTENT_MB}MB 上限"}), 413
  308. @app.get("/health")
  309. def health():
  310. m0 = MATCHERS[0]
  311. with _STATS_LOCK:
  312. stats = dict(_STATS)
  313. draining = _SHUTDOWN.is_set()
  314. return jsonify({
  315. "status": "draining" if draining else "ok",
  316. "service": "serve_card_match_v2",
  317. "model": (os.path.basename(config.CARD_SEG_MODEL_PATH)
  318. if os.path.isdir(config.CARD_SEG_MODEL_PATH) else
  319. os.path.basename(os.path.dirname( # 路径直指 best.pt 时取上两级目录名
  320. os.path.dirname(config.CARD_SEG_MODEL_PATH)))),
  321. "backend": m0.backend,
  322. "gallery_size": len(m0.card_ids),
  323. "workers": N_WORKERS,
  324. "gpus": {"count": _GPU_COUNT,
  325. "visible": os.environ.get("CUDA_VISIBLE_DEVICES", "")},
  326. "merge": {"dist_px": getattr(m0.detector, "merge_dist_px", None),
  327. "min_iou": getattr(m0.detector, "merge_min_iou", None)},
  328. "queue": {"max": QUEUE_MAX, "depth": _QUEUE.qsize(),
  329. "unfinished": _QUEUE.unfinished_tasks},
  330. "cache": dict(_CACHE.info(), hits=stats["cache_hits"], misses=stats["cache_misses"]),
  331. "stats": {k: v for k, v in stats.items()},
  332. "top_k_fixed": TOP_K_FIXED,
  333. "uptime_s": round(time.time() - _T0, 1),
  334. })
  335. @app.post("/match_fields")
  336. @app.get("/match_fields")
  337. def match_fields():
  338. """多卡识别(8020 主端点,2026-09-03 契约)。响应顶层裸数组,
  339. 每项 {card_type, card_id, image_url, matches[5]}。"""
  340. data = request.get_json(silent=True) or {}
  341. _refresh = str(data.get("refresh", request.args.get("refresh",
  342. request.form.get("refresh", "0")))).lower() in ("1", "true", "yes")
  343. # ---- 来源解析:multipart 上传 / front_image_url / image_url(query/form/json)----
  344. cleanup = None
  345. image_url = None
  346. up = request.files.get("image")
  347. if up and up.filename:
  348. blob = up.read()
  349. if not blob:
  350. return jsonify({"error": "上传文件为空"}), 400
  351. config.ensure_dirs()
  352. cleanup = save_upload_to_tmp(blob, up.filename, config.QUERY_IMG_DIR)
  353. if cleanup is None:
  354. src_url = (data.get("front_image_url") or request.args.get("front_image_url")
  355. or request.form.get("front_image_url")
  356. or data.get("image_url") or request.args.get("image_url")
  357. or request.form.get("image_url"))
  358. if not src_url:
  359. return jsonify({"error": "缺少图片:请上传 image 文件,或传 front_image_url / image_url"}), 400
  360. if not str(src_url).startswith(("http://", "https://")):
  361. return jsonify({"error": "image_url 仅支持 http(s) 地址(需服务器可达): " + str(src_url)[:200]}), 400
  362. config.ensure_dirs()
  363. path = _download_unique(src_url)
  364. if not path:
  365. return jsonify({"error": "图片下载失败(服务器访问不到该 URL): " + str(src_url)[:200]}), 400
  366. cleanup = path
  367. image_url = src_url # URL 入参原样回显,不二次上传(与 8000 /match_fields 一致)
  368. try:
  369. md5hex = file_md5(cleanup)
  370. except OSError as e:
  371. return jsonify({"error": f"读取图片失败: {e}"}), 400
  372. # ---- 缓存查询(上传场景的 MinIO 上传放在命中判断之后,命中时仍需上传拿回显地址)----
  373. if not _refresh:
  374. cached = _CACHE.get(md5hex)
  375. if cached is not None:
  376. with _STATS_LOCK:
  377. _STATS["cache_hits"] += 1
  378. if image_url is None:
  379. image_url = upload_to_capp_minio(cleanup)
  380. if cleanup and os.path.exists(cleanup):
  381. try:
  382. os.remove(cleanup)
  383. except OSError:
  384. pass
  385. cleanup = None
  386. return jsonify(_stamp(cached, image_url))
  387. with _STATS_LOCK:
  388. _STATS["cache_misses"] += 1
  389. if image_url is None:
  390. image_url = upload_to_capp_minio(cleanup) # 失败 None,不阻塞识别主流程
  391. # ---- 入队 + 等待 ----
  392. job = _Job(cleanup, cleanup, md5hex)
  393. try:
  394. _QUEUE.put_nowait(job)
  395. except queue.Full:
  396. if cleanup and os.path.exists(cleanup):
  397. try:
  398. os.remove(cleanup)
  399. except OSError:
  400. pass
  401. with _STATS_LOCK:
  402. _STATS["queue_full"] += 1
  403. resp = jsonify({"error": "服务繁忙(推理队列已满),请稍后重试"})
  404. resp.status_code = 429
  405. resp.headers["Retry-After"] = "5"
  406. return resp
  407. with _STATS_LOCK:
  408. _STATS["submitted"] += 1
  409. print(f"[match_fields] md5={md5hex[:12]} src={'upload' if request.files.get('image') else 'url'} "
  410. f"qdepth={_QUEUE.qsize()}", flush=True)
  411. if not job.done.wait(WAIT_TIMEOUT):
  412. job.cancelled = True # 仍在队列则 worker 取到即跳过;在跑则结果丢弃
  413. with _STATS_LOCK:
  414. _STATS["timeouts"] += 1
  415. return jsonify({"error": f"推理超时(>{WAIT_TIMEOUT:g}s),请稍后重试"}), 504
  416. if job.error is not None:
  417. return jsonify({"error": str(job.error) or "inference error"}), 500
  418. _CACHE.put(md5hex, job.result)
  419. return jsonify(_stamp(job.result, image_url))
  420. return app
  421. app = create_app()
  422. if __name__ == "__main__":
  423. def _graceful(signum, _frame):
  424. """直跑模式的优雅退出:停止收新任务,最多等 10s 排空队列。
  425. gunicorn 模式不注册(由 gunicorn master 管 SIGTERM/graceful-timeout)。"""
  426. print(f"[关闭] 收到信号 {signum},draining ...", flush=True)
  427. _SHUTDOWN.set()
  428. deadline = time.time() + 10
  429. while _QUEUE.unfinished_tasks > 0 and time.time() < deadline:
  430. time.sleep(0.1)
  431. os._exit(0)
  432. signal.signal(signal.SIGTERM, _graceful)
  433. signal.signal(signal.SIGINT, _graceful)
  434. p = argparse.ArgumentParser(description="卡牌匹配 HTTP 服务 v2(8020 生产)")
  435. p.add_argument("--host", default="0.0.0.0")
  436. p.add_argument("--port", type=int, default=8020)
  437. args = p.parse_args()
  438. app.run(host=args.host, port=args.port, threaded=True)