Files
Vase-Matcher/pipeline/vase_compare.py
2026-08-04 17:09:29 +05:30

159 lines
5.6 KiB
Python

"""
Vase-identity comparison: crops the vase region (via SAM3's "vase" concept
mask -- see pipeline/sam3_client.py) from both the upload and the matched
template, then compares the two crops with two complementary embedding
models:
- DINOv2 (facebook/dinov2-base): self-supervised, patch-level visual
features -- good at fine-grained shape/texture/material detail.
- CLIP (openai/clip-vit-base-patch32): contrastive image embedding -- a
coarser, more holistic notion of visual similarity, used as a second
opinion that isn't fooled by the same quirks DINO might be.
Purely informational, opt-in (run alongside the SAM/YOLO-World flower count,
triggered by the same button) -- never feeds into matching/scoring.
"""
import gc
import logging
import cv2
import numpy as np
import torch
from PIL import Image
import config
logger = logging.getLogger(__name__)
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
_dino_model = None
_dino_processor = None
_clip_model = None
_clip_processor = None
def _get_dino():
global _dino_model, _dino_processor
if _dino_model is None:
logger.info("Loading DINOv2 (%s) on %s...", config.DINO_MODEL_NAME, DEVICE)
from transformers import AutoImageProcessor, AutoModel
_dino_processor = AutoImageProcessor.from_pretrained(config.DINO_MODEL_NAME)
_dino_model = AutoModel.from_pretrained(config.DINO_MODEL_NAME).eval().to(DEVICE)
return _dino_model, _dino_processor
def _get_clip():
global _clip_model, _clip_processor
if _clip_model is None:
logger.info("Loading CLIP (%s) on %s...", config.CLIP_MODEL_NAME, DEVICE)
from transformers import CLIPModel, CLIPProcessor
_clip_model = CLIPModel.from_pretrained(config.CLIP_MODEL_NAME).eval().to(DEVICE)
_clip_processor = CLIPProcessor.from_pretrained(config.CLIP_MODEL_NAME)
return _clip_model, _clip_processor
def unload_models():
global _dino_model, _dino_processor, _clip_model, _clip_processor
freed = _dino_model is not None or _clip_model is not None
_dino_model = None
_dino_processor = None
_clip_model = None
_clip_processor = None
if freed:
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
return freed
def _to_pil(bgr):
return Image.fromarray(cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB))
def _dino_embed(bgr):
model, processor = _get_dino()
with torch.no_grad():
inputs = processor(images=_to_pil(bgr), return_tensors="pt").to(DEVICE)
out = model(**inputs)
feat = out.last_hidden_state[:, 0] # CLS token
feat = torch.nn.functional.normalize(feat, dim=-1)
return feat.cpu().numpy()[0]
def _clip_embed(bgr):
model, processor = _get_clip()
with torch.no_grad():
inputs = processor(images=_to_pil(bgr), return_tensors="pt").to(DEVICE)
feat = model.get_image_features(**inputs)
feat = torch.nn.functional.normalize(feat, dim=-1)
return feat.cpu().numpy()[0]
def _cosine_pct(a, b):
"""Cosine similarity of two L2-normalized vectors, clamped to [0, 1] and
reported as a percentage -- negative similarity (near-opposite vectors)
is clamped to 0% rather than reported as a negative number, since
"how similar" isn't meaningful past that point for this use case."""
sim = float(np.dot(a, b))
return round(max(0.0, min(1.0, sim)) * 100, 1)
def crop_mask(bgr, mask_bool):
"""Crops the bounding box of mask_bool out of bgr, with a small pad
(config.VASE_CROP_PAD_FRAC) so the mask's own (occasionally imprecise)
edge doesn't cut off the vase's rim or base -- then blacks out any
pixel inside that padded box that the mask doesn't cover, so DINOv2/CLIP
only ever see vase pixels, not whatever flower stems or ribbon happen to
share the box's corners. Returns None if the mask is empty."""
ys, xs = np.nonzero(mask_bool)
if len(xs) == 0:
return None
h, w = bgr.shape[:2]
x1, y1, x2, y2 = xs.min(), ys.min(), xs.max(), ys.max()
bw, bh = x2 - x1, y2 - y1
pad = config.VASE_CROP_PAD_FRAC
px1, px2 = x1 - bw * pad, x2 + bw * pad
py1, py2 = y1 - bh * pad, y2 + bh * pad
px1, py1 = max(0, int(round(px1))), max(0, int(round(py1)))
px2, py2 = min(w, int(round(px2))), min(h, int(round(py2)))
if px2 <= px1 or py2 <= py1:
return None
crop = bgr[py1:py2, px1:px2].copy()
mask_crop = mask_bool[py1:py2, px1:px2]
crop[~mask_crop] = 0
return crop
def clip_similarity_pct(crop_a, crop_b):
"""CLIP-only similarity between two crops -- no DINOv2. Used for the
auto-triggered "flower check" beside the weighted verdict (see
engine.flower_summary): unlike the opt-in vase comparison, that one runs
on every confident match, so it deliberately loads only CLIP (small,
fast) rather than both embedding models."""
return _cosine_pct(_clip_embed(crop_a), _clip_embed(crop_b))
def compare_vases(input_crop_bgr, template_crop_bgr):
dino_pct = _cosine_pct(_dino_embed(input_crop_bgr), _dino_embed(template_crop_bgr))
clip_pct = _cosine_pct(_clip_embed(input_crop_bgr), _clip_embed(template_crop_bgr))
combined_pct = round(
config.VASE_DINO_WEIGHT * dino_pct + config.VASE_CLIP_WEIGHT * clip_pct, 1
)
if combined_pct >= config.VASE_SAME_THRESHOLD:
verdict = "same"
elif combined_pct >= config.VASE_UNCERTAIN_THRESHOLD:
verdict = "uncertain"
else:
verdict = "different"
return {
"dino_similarity_pct": dino_pct,
"clip_similarity_pct": clip_pct,
"combined_pct": combined_pct,
"verdict": verdict,
}