Spaces:
Sleeping
Sleeping
jslmmfboom-coder
Fix: remove rect boxes from vis, pass per-image crop_offset, only draw lines
8ab04fc | """DINOv2 特征提取器 — vit_small_patch14_reg4_dinov2 (带寄存器令牌)""" | |
| import torch | |
| import torch.nn.functional as F | |
| from torchvision import transforms | |
| from PIL import Image | |
| import numpy as np | |
| from module.config import DEVICE | |
| class DINOv2Extractor: | |
| """DINOv2 全局语义特征提取器 | |
| 使用 vit_small_patch14_reg4_dinov2 模型(4个寄存器令牌), | |
| 相比无寄存器版本,CLS token 特征质量更高,对背景噪声更鲁棒。 | |
| 输出 384 维归一化特征向量。 | |
| 支持提取 patch 特征用于可视化匹配。 | |
| """ | |
| TIMM_MODEL_NAME = 'vit_small_patch14_reg4_dinov2' | |
| PATCH_SIZE = 14 | |
| def __init__(self, device=None): | |
| self.device = device or DEVICE | |
| self.model = None | |
| self._use_timm = False | |
| # DINOv2 标准预处理:518x518,ImageNet 归一化 | |
| self.transform = transforms.Compose([ | |
| transforms.Resize(518, interpolation=transforms.InterpolationMode.BICUBIC), | |
| transforms.CenterCrop(518), | |
| transforms.ToTensor(), | |
| transforms.Normalize(mean=[0.485, 0.456, 0.406], | |
| std=[0.229, 0.224, 0.225]), | |
| ]) | |
| try: | |
| import timm | |
| print(f"通过 timm 加载 DINOv2 (reg4): {self.TIMM_MODEL_NAME}") | |
| self.model = timm.create_model(self.TIMM_MODEL_NAME, pretrained=True) | |
| self._use_timm = True | |
| self.model = self.model.to(self.device).eval() | |
| print("DINOv2 reg4 模型加载完成") | |
| except Exception as e: | |
| print(f"timm 加载 DINOv2 失败: {e}") | |
| self.model = None | |
| def is_available(self): | |
| return self.model is not None | |
| def _get_cls_feature(self, img_tensor): | |
| """从 forward_features 提取 CLS token,适配 timm 的 dict 返回格式""" | |
| features = self.model.forward_features(img_tensor) | |
| # timm 0.9+ 返回 dict | |
| if isinstance(features, dict): | |
| if 'x_norm_clstoken' in features: | |
| return features['x_norm_clstoken'] | |
| # 兜底:取第一个值的 CLS 位置 | |
| for v in features.values(): | |
| if isinstance(v, torch.Tensor) and v.dim() >= 2: | |
| return v[:, 0] | |
| # 旧版 timm 返回 tensor (B, N, C) | |
| if isinstance(features, torch.Tensor): | |
| return features[:, 0] | |
| raise ValueError(f"无法从 forward_features 提取 CLS token: {type(features)}") | |
| def extract_feature(self, img_path): | |
| """提取单张图片的 DINOv2 CLS 全局特征向量(384维,已归一化)""" | |
| if self.model is None: | |
| return None | |
| img = Image.open(img_path).convert('RGB') | |
| tensor = self.transform(img).unsqueeze(0).to(self.device) | |
| cls_token = self._get_cls_feature(tensor) | |
| return F.normalize(cls_token, dim=-1).squeeze(0) | |
| def compute_similarity(self, img_path1, img_path2): | |
| """计算两张图片的 DINOv2 CLS 特征余弦相似度(返回 0~1)""" | |
| feat1 = self.extract_feature(img_path1) | |
| feat2 = self.extract_feature(img_path2) | |
| if feat1 is None or feat2 is None: | |
| return None | |
| sim = F.cosine_similarity(feat1.unsqueeze(0), feat2.unsqueeze(0)) | |
| return float(sim.item()) | |
| def extract_patch_features(self, img_path): | |
| """提取图片的 patch 级别特征(用于可视化匹配) | |
| Returns: | |
| dict with keys: | |
| - 'cls': CLS token (384维) | |
| - 'patches': patch token 特征 (n_patches, 384) | |
| - 'patch_grid': (n_h, n_w) patch 网格尺寸 | |
| - 'image_size': 原始图片尺寸 (w, h) | |
| """ | |
| if self.model is None: | |
| return None | |
| img_pil = Image.open(img_path).convert('RGB') | |
| orig_w, orig_h = img_pil.size | |
| tensor = self.transform(img_pil).unsqueeze(0).to(self.device) | |
| features = self.model.forward_features(tensor) | |
| if isinstance(features, dict): | |
| cls_token = features.get('x_norm_clstoken', None) | |
| patch_tokens = features.get('x_norm_patchtokens', None) | |
| # 如果没有 x_norm_patchtokens,从 x_norm_tokens 中提取 | |
| if patch_tokens is None and 'x_norm_tokens' in features: | |
| all_tokens = features['x_norm_tokens'] | |
| # 第0个是 CLS,后面是 patches + registers | |
| # reg4 模型有4个 register tokens 在末尾 | |
| patch_tokens = all_tokens[:, 1:-4] if all_tokens.shape[1] > 1 else None | |
| if cls_token is None and 'x_norm_tokens' in features: | |
| cls_token = features['x_norm_tokens'][:, 0] | |
| elif isinstance(features, torch.Tensor): | |
| # 旧版 timm: (B, N, C),N = 1(cls) + n_patches + n_registers | |
| cls_token = features[:, 0] | |
| # reg4 有4个 register tokens 在末尾 | |
| patch_tokens = features[:, 1:-4] if features.shape[1] > 5 else features[:, 1:] | |
| else: | |
| return None | |
| if cls_token is None or patch_tokens is None: | |
| return None | |
| cls_feat = F.normalize(cls_token.squeeze(0), dim=-1) | |
| patch_feats = F.normalize(patch_tokens.squeeze(0), dim=-1) | |
| # DINOv2 预处理:Resize(518) → CenterCrop(518) | |
| # 需要记录 CenterCrop 的偏移量,用于可视化时将 patch 坐标映射回原图 | |
| input_size = 518 # CenterCrop 尺寸 | |
| n_patches = patch_feats.shape[0] | |
| n_side = int(n_patches ** 0.5) | |
| if n_side * n_side != n_patches: | |
| n_h = input_size // self.PATCH_SIZE | |
| n_w = n_h | |
| else: | |
| n_h = n_w = n_side | |
| # 计算 CenterCrop 在 Resize 后图像上的偏移 | |
| # Resize(518) 将短边缩放到518,长边按比例缩放 | |
| resize_w, resize_h = orig_w, orig_h | |
| if orig_w <= orig_h: | |
| resize_w = 518 | |
| resize_h = int(orig_h * 518 / orig_w) | |
| else: | |
| resize_h = 518 | |
| resize_w = int(orig_w * 518 / orig_h) | |
| crop_x = (resize_w - input_size) // 2 | |
| crop_y = (resize_h - input_size) // 2 | |
| return { | |
| 'cls': cls_feat.cpu().numpy(), | |
| 'patches': patch_feats.cpu().numpy(), | |
| 'patch_grid': (n_h, n_w), | |
| 'image_size': (orig_w, orig_h), | |
| 'crop_offset': (crop_x, crop_y), # CenterCrop 偏移 | |
| 'resize_size': (resize_w, resize_h), # Resize 后尺寸 | |
| } | |
| def compute_patch_matches(self, img_path1, img_path2, top_k=50): | |
| """计算两张图片之间的 patch 级别匹配(用于可视化) | |
| Returns: | |
| dict with keys: | |
| - 'cls_similarity': CLS token 余弦相似度 | |
| - 'matches': list of (query_idx, hist_idx, similarity) 最优匹配 | |
| - 'query_grid': (n_h, n_w) | |
| - 'hist_grid': (n_h, n_w) | |
| - 'query_image_size': (w, h) | |
| - 'hist_image_size': (w, h) | |
| """ | |
| feat1 = self.extract_patch_features(img_path1) | |
| feat2 = self.extract_patch_features(img_path2) | |
| if feat1 is None or feat2 is None: | |
| return None | |
| # CLS 相似度 | |
| cls_sim = float(np.dot(feat1['cls'], feat2['cls'])) | |
| # Patch 间余弦相似度矩阵 | |
| sim_matrix = feat1['patches'] @ feat2['patches'].T # (n1, n2) | |
| # 双向最近邻匹配(类似 MASt3R 的互为最近邻) | |
| matches = [] | |
| q_best = sim_matrix.argmax(axis=1) # 每个 query patch 的最佳匹配 | |
| h_best = sim_matrix.argmax(axis=0) # 每个 hist patch 的最佳匹配 | |
| for qi, hi in enumerate(q_best): | |
| if h_best[hi] == qi: # 互为最近邻 | |
| sim = float(sim_matrix[qi, hi]) | |
| matches.append((qi, hi, sim)) | |
| # 按相似度排序,取 top_k | |
| matches.sort(key=lambda x: x[2], reverse=True) | |
| matches = matches[:top_k] | |
| return { | |
| 'cls_similarity': round(cls_sim, 4), | |
| 'matches': [(q, h, round(s, 4)) for q, h, s in matches], | |
| 'query_grid': feat1['patch_grid'], | |
| 'hist_grid': feat2['patch_grid'], | |
| 'query_image_size': feat1['image_size'], | |
| 'hist_image_size': feat2['image_size'], | |
| 'query_crop_offset': feat1.get('crop_offset', (0, 0)), | |
| 'hist_crop_offset': feat2.get('crop_offset', (0, 0)), | |
| 'query_resize_size': feat1.get('resize_size', (518, 518)), | |
| 'hist_resize_size': feat2.get('resize_size', (518, 518)), | |
| } | |