| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115 |
- """
- DINOv2 特征提取模块
- 复用 dinov2_base_retrieval_392_PokemonCN04 模型,输出 768 维 L2 归一化特征向量。
- 预处理逻辑与训练时保持一致(Letterbox 392×392 + ImageNet 归一化)。
- """
- import os
- import numpy as np
- class CardFeatureExtractor:
- """DINOv2 卡牌特征提取器"""
- def __init__(self, model_dir, device=None):
- import torch
- import torch.nn as nn
- from transformers import Dinov2Model
- from safetensors.torch import load_file
- if not os.path.isdir(model_dir):
- raise NotADirectoryError(f"模型目录不存在: {model_dir}")
- self.torch = torch
- self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
- # 加载骨干网络
- self.backbone = Dinov2Model.from_pretrained(model_dir, local_files_only=True)
- self.feature_dim = self.backbone.config.hidden_size
- self.image_size = self.backbone.config.image_size # 392
- # BN Neck(训练时用的,推理需加载其权重)
- self.bn_neck = nn.BatchNorm1d(self.feature_dim)
- st_path = os.path.join(model_dir, "model.safetensors")
- if os.path.exists(st_path):
- self._load_bn_weights(st_path)
- self.backbone.to(self.device).eval()
- self.bn_neck.to(self.device).eval()
- # ImageNet 归一化参数
- self.mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
- self.std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
- def _load_bn_weights(self, safetensors_path):
- from safetensors.torch import load_file
- state = load_file(safetensors_path, device="cpu")
- bn_dict = {k.replace("bn_neck.", ""): v for k, v in state.items() if k.startswith("bn_neck.")}
- if bn_dict:
- self.bn_neck.load_state_dict(bn_dict)
- def _letterbox(self, img_rgb):
- """Letterbox 等比缩放 + 黑边填充到 image_size×image_size"""
- import cv2
- shape = img_rgb.shape[:2] # h, w
- target = self.image_size
- r = min(target / shape[0], target / shape[1])
- new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r))) # w, h
- dw = (target - new_unpad[0]) / 2
- dh = (target - new_unpad[1]) / 2
- if (shape[1], shape[0]) != new_unpad:
- img_rgb = cv2.resize(img_rgb, new_unpad, interpolation=cv2.INTER_CUBIC)
- top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
- left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
- img_rgb = cv2.copyMakeBorder(img_rgb, top, bottom, left, right,
- cv2.BORDER_CONSTANT, value=(0, 0, 0))
- if img_rgb.shape[:2] != (target, target):
- img_rgb = cv2.resize(img_rgb, (target, target), interpolation=cv2.INTER_CUBIC)
- img = img_rgb.astype(np.float32) / 255.0
- img = (img - self.mean) / self.std
- return img.transpose(2, 0, 1) # CHW
- @property
- def _to_tensor(self):
- import torch.nn.functional as F
- return self.torch, F
- def extract(self, imgs, normalize=True):
- """
- 批量提取特征。
- Args:
- imgs: RGB numpy 数组列表 (每个 H,W,3 uint8)
- normalize: 是否 L2 归一化
- Returns:
- np.ndarray (N, 768),失败的行填充 NaN
- """
- import torch.nn.functional as F
- torch = self.torch
- tensors, valid_idx = [], []
- for i, img in enumerate(imgs):
- if img is None or img.ndim != 3:
- continue
- try:
- tensors.append(torch.from_numpy(self._letterbox(img)).float())
- valid_idx.append(i)
- except Exception:
- continue
- out = np.full((len(imgs), self.feature_dim), np.nan, dtype=np.float32)
- if not tensors:
- return out
- batch = torch.stack(tensors).to(self.device)
- with torch.no_grad():
- outputs = self.backbone(batch)
- cls = outputs.last_hidden_state[:, 0, :]
- feat = self.bn_neck(cls)
- if normalize:
- feat = F.normalize(feat, p=2, dim=1)
- feats_np = feat.cpu().numpy()
- for idx, vec in zip(valid_idx, feats_np):
- out[idx] = vec
- return out
|