feature_extractor.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. """
  2. DINOv2 特征提取模块
  3. 复用 dinov2_base_retrieval_392_PokemonCN04 模型,输出 768 维 L2 归一化特征向量。
  4. 预处理逻辑与训练时保持一致(Letterbox 392×392 + ImageNet 归一化)。
  5. """
  6. import os
  7. import numpy as np
  8. class CardFeatureExtractor:
  9. """DINOv2 卡牌特征提取器"""
  10. def __init__(self, model_dir, device=None):
  11. import torch
  12. import torch.nn as nn
  13. from transformers import Dinov2Model
  14. from safetensors.torch import load_file
  15. if not os.path.isdir(model_dir):
  16. raise NotADirectoryError(f"模型目录不存在: {model_dir}")
  17. self.torch = torch
  18. self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
  19. # 加载骨干网络
  20. self.backbone = Dinov2Model.from_pretrained(model_dir, local_files_only=True)
  21. self.feature_dim = self.backbone.config.hidden_size
  22. self.image_size = self.backbone.config.image_size # 392
  23. # BN Neck(训练时用的,推理需加载其权重)
  24. self.bn_neck = nn.BatchNorm1d(self.feature_dim)
  25. st_path = os.path.join(model_dir, "model.safetensors")
  26. if os.path.exists(st_path):
  27. self._load_bn_weights(st_path)
  28. self.backbone.to(self.device).eval()
  29. self.bn_neck.to(self.device).eval()
  30. # ImageNet 归一化参数
  31. self.mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
  32. self.std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
  33. def _load_bn_weights(self, safetensors_path):
  34. from safetensors.torch import load_file
  35. state = load_file(safetensors_path, device="cpu")
  36. bn_dict = {k.replace("bn_neck.", ""): v for k, v in state.items() if k.startswith("bn_neck.")}
  37. if bn_dict:
  38. self.bn_neck.load_state_dict(bn_dict)
  39. def _letterbox(self, img_rgb):
  40. """Letterbox 等比缩放 + 黑边填充到 image_size×image_size"""
  41. import cv2
  42. shape = img_rgb.shape[:2] # h, w
  43. target = self.image_size
  44. r = min(target / shape[0], target / shape[1])
  45. new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r))) # w, h
  46. dw = (target - new_unpad[0]) / 2
  47. dh = (target - new_unpad[1]) / 2
  48. if (shape[1], shape[0]) != new_unpad:
  49. img_rgb = cv2.resize(img_rgb, new_unpad, interpolation=cv2.INTER_CUBIC)
  50. top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
  51. left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
  52. img_rgb = cv2.copyMakeBorder(img_rgb, top, bottom, left, right,
  53. cv2.BORDER_CONSTANT, value=(0, 0, 0))
  54. if img_rgb.shape[:2] != (target, target):
  55. img_rgb = cv2.resize(img_rgb, (target, target), interpolation=cv2.INTER_CUBIC)
  56. img = img_rgb.astype(np.float32) / 255.0
  57. img = (img - self.mean) / self.std
  58. return img.transpose(2, 0, 1) # CHW
  59. @property
  60. def _to_tensor(self):
  61. import torch.nn.functional as F
  62. return self.torch, F
  63. def extract(self, imgs, normalize=True):
  64. """
  65. 批量提取特征。
  66. Args:
  67. imgs: RGB numpy 数组列表 (每个 H,W,3 uint8)
  68. normalize: 是否 L2 归一化
  69. Returns:
  70. np.ndarray (N, 768),失败的行填充 NaN
  71. """
  72. import torch.nn.functional as F
  73. torch = self.torch
  74. tensors, valid_idx = [], []
  75. for i, img in enumerate(imgs):
  76. if img is None or img.ndim != 3:
  77. continue
  78. try:
  79. tensors.append(torch.from_numpy(self._letterbox(img)).float())
  80. valid_idx.append(i)
  81. except Exception:
  82. continue
  83. out = np.full((len(imgs), self.feature_dim), np.nan, dtype=np.float32)
  84. if not tensors:
  85. return out
  86. batch = torch.stack(tensors).to(self.device)
  87. with torch.no_grad():
  88. outputs = self.backbone(batch)
  89. cls = outputs.last_hidden_state[:, 0, :]
  90. feat = self.bn_neck(cls)
  91. if normalize:
  92. feat = F.normalize(feat, p=2, dim=1)
  93. feats_np = feat.cpu().numpy()
  94. for idx, vec in zip(valid_idx, feats_np):
  95. out[idx] = vec
  96. return out