attribute_detector.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. # -*- coding: utf-8 -*-
  2. """
  3. attribute_detector.py - 宝可梦卡牌属性检测(火/水/草/电...)
  4. 与 cat16 三属性流水线(ebay_sync/infer_cat16_pipeline.py)同款配方:
  5. 裁卡图 → roi_box.json 的 box3 归一化框(右上角元素图标区)裁小图
  6. → 4x LANCZOS 放大 → yolo26s_det_attribute 检测取 top1 类别名
  7. 整卡直接喂模型检不出框(图标太小),必须先裁 ROI 再放大。
  8. 轻量常驻,加载 20MB pt,推理 ~10ms。
  9. """
  10. import os
  11. import json
  12. import numpy as np
  13. from PIL import Image
  14. from modules.ultralytics_compat import import_yolo
  15. _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  16. ATTRIBUTE_MODEL_PATH = os.path.join(
  17. _ROOT, "ultralytics/runs/detect/yolo26s_det_attribute/weights/best.pt"
  18. )
  19. ROI_JSON_PATH = os.path.join(
  20. _ROOT, "data_all/卡牌区域/images_pokemon_card_crops/roi_box.json"
  21. )
  22. BOX3_LABEL = "box3" # 右上角元素图标区(归一化 x1,y1,x2,y2)
  23. UPSCALE = 4 # LANCZOS 放大倍数(同 cat16 / OCR v5 方案)
  24. def _load_box3(path=None):
  25. """roi_box.json → box3 归一化框 (x1, y1, x2, y2);缺失返回 None。"""
  26. try:
  27. with open(path or ROI_JSON_PATH, encoding="utf-8-sig") as f:
  28. rois = json.load(f)["rois_normalized"]
  29. for item in rois:
  30. if item["label"] == BOX3_LABEL:
  31. return (item["x1"], item["y1"], item["x2"], item["y2"])
  32. except Exception:
  33. pass
  34. return None
  35. class AttributeDetector:
  36. def __init__(self, model_path=None, roi_json_path=None):
  37. self.roi = _load_box3(roi_json_path)
  38. if self.roi is None:
  39. print("[AttributeDetector] roi_box.json 缺失或无 box3,attribute 将始终为 None", flush=True)
  40. self.model = None
  41. return
  42. YOLO = import_yolo()
  43. path = model_path or ATTRIBUTE_MODEL_PATH
  44. if not os.path.exists(path):
  45. print("[AttributeDetector] 模型不存在: %s,attribute 将始终为 None" % path, flush=True)
  46. self.model = None
  47. return
  48. self.model = YOLO(path, task="detect")
  49. self.device = "" # 空串走auto分支: vendored select_device(device=0)会把CVD覆盖成0从而撞GTX1060
  50. self.names = self.model.model.names
  51. print("[AttributeDetector] loaded %d classes: %s" % (len(self.names), list(self.names.values())), flush=True)
  52. def _crop_roi_4x(self, img_rgb):
  53. """裁卡 RGB → box3 ROI 裁块 → 4x LANCZOS 放大(cat16 同款)。"""
  54. h, w = img_rgb.shape[:2]
  55. x1, y1, x2, y2 = self.roi
  56. px1, py1 = max(0, int(x1 * w)), max(0, int(y1 * h))
  57. px2, py2 = min(w, int(x2 * w)), min(h, int(y2 * h))
  58. if px2 <= px1 or py2 <= py1:
  59. return None
  60. patch = Image.fromarray(img_rgb[py1:py2, px1:px2])
  61. return np.array(patch.resize((patch.size[0] * UPSCALE, patch.size[1] * UPSCALE), Image.LANCZOS))
  62. def predict(self, img_rgb):
  63. """输入裁卡 RGB numpy 数组,返回元素标签字符串(如 '草'/'水')或 None。"""
  64. if self.model is None or img_rgb is None:
  65. return None
  66. roi_img = self._crop_roi_4x(img_rgb)
  67. if roi_img is None:
  68. return None
  69. results = self.model.predict(
  70. roi_img, conf=0.25, imgsz=640,
  71. device=self.device, verbose=False,
  72. )
  73. if not results:
  74. return None
  75. res = results[0]
  76. boxes = res.boxes
  77. if boxes is None or len(boxes) == 0:
  78. return None
  79. # 取置信度最高的实例类别名
  80. confs = boxes.conf.cpu().numpy()
  81. clses = boxes.cls.cpu().numpy()
  82. best_idx = int(np.argmax(confs))
  83. cls_id = int(clses[best_idx])
  84. return self.names.get(cls_id)