File size: 19,289 Bytes
140c2fe
 
 
 
 
 
 
 
 
 
 
090da24
 
 
 
140c2fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
090da24
140c2fe
 
090da24
140c2fe
090da24
140c2fe
090da24
140c2fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
090da24
140c2fe
090da24
140c2fe
 
 
 
090da24
140c2fe
 
090da24
140c2fe
 
090da24
140c2fe
 
090da24
140c2fe
 
090da24
 
 
140c2fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
090da24
 
140c2fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
090da24
 
140c2fe
 
090da24
 
140c2fe
090da24
 
 
140c2fe
090da24
 
140c2fe
 
 
 
 
 
 
 
 
 
 
 
 
 
090da24
140c2fe
 
090da24
 
140c2fe
090da24
 
 
140c2fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
090da24
140c2fe
090da24
 
 
140c2fe
 
 
 
 
 
 
 
090da24
 
 
 
140c2fe
090da24
140c2fe
 
 
 
090da24
140c2fe
090da24
140c2fe
090da24
140c2fe
 
 
 
 
 
 
 
 
 
090da24
 
 
140c2fe
090da24
140c2fe
090da24
 
140c2fe
090da24
140c2fe
 
090da24
 
140c2fe
 
 
 
 
 
090da24
140c2fe
 
090da24
 
 
140c2fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
090da24
 
 
140c2fe
090da24
140c2fe
090da24
140c2fe
 
090da24
 
140c2fe
 
 
 
 
 
090da24
 
 
140c2fe
090da24
140c2fe
 
090da24
140c2fe
090da24
140c2fe
 
 
 
 
 
 
 
090da24
 
 
140c2fe
 
 
 
090da24
 
140c2fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
090da24
 
 
 
140c2fe
 
 
 
 
 
 
 
 
 
 
 
090da24
140c2fe
 
090da24
 
140c2fe
 
 
 
090da24
 
140c2fe
 
 
 
 
090da24
 
140c2fe
 
090da24
 
140c2fe
 
 
 
 
090da24
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# ═══════════════════════════════════════════════════════════════════
#  Object Similarity Detector  v2.0
#  SAM 2.1 (Large) + DINOv2 ViT-L/14 + Ensemble Feature Matching
#  Research-backed improvements for maximum accuracy
# ═══════════════════════════════════════════════════════════════════

# ── IMPORTS ─────────────────────────────────────────────────────────
import os, time, urllib.request, warnings
warnings.filterwarnings("ignore")

import cv2, gradio as gr, numpy as np, torch
import torch.nn.functional as F
from PIL import Image
from torchvision import transforms

# ── AUTO-INSTALL sam2 if not present ────────────────────────────────
try:
    from sam2.build_sam import build_sam2
    from sam2.sam2_image_predictor import SAM2ImagePredictor
    from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator
    print("βœ… sam2 already installed")
except ImportError:
    print("Installing sam2...")
    os.system("pip install git+https://github.com/facebookresearch/sam2.git -q")
    from sam2.build_sam import build_sam2
    from sam2.sam2_image_predictor import SAM2ImagePredictor
    from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator

# ── CHECKPOINT DOWNLOAD ─────────────────────────────────────────────
SAM2_CKPT = "sam2.1_hiera_large.pt"
SAM2_CFG  = "configs/sam2.1/sam2.1_hiera_l.yaml"   # ships inside sam2 package

if not os.path.exists(SAM2_CKPT):
    print("Downloading SAM 2.1 Large (~900 MB)...")
    urllib.request.urlretrieve(
        "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_large.pt",
        SAM2_CKPT,
    )
    print("βœ… SAM 2.1 checkpoint downloaded!")

# ── DEVICE ──────────────────────────────────────────────────────────
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Running on: {device}")
if device == "cuda":
    torch.backends.cuda.matmul.allow_tf32 = True
    torch.backends.cudnn.allow_tf32 = True

# ── LOAD SAM 2.1 LARGE ──────────────────────────────────────────────
print("Loading SAM 2.1 Large…")
sam2_model = build_sam2(SAM2_CFG, SAM2_CKPT, device=device)
sam2_predictor = SAM2ImagePredictor(sam2_model)

# Auto-mask generator β€” tuned for dense scene coverage
mask_generator = SAM2AutomaticMaskGenerator(
    model                        = sam2_model,
    points_per_side              = 32,   # higher β†’ more coverage (was 16 in v1)
    points_per_batch             = 64,
    pred_iou_thresh              = 0.80, # lower β†’ fewer missed objects
    stability_score_thresh       = 0.88,
    stability_score_offset       = 1.0,
    box_nms_thresh               = 0.70, # higher β†’ keep overlapping objects
    crop_n_layers                = 1,    # multi-crop pass β†’ catches small objects
    crop_n_points_downscale_factor = 2,
    min_mask_region_area         = 150,  # allow smaller objects
)
print("βœ… SAM 2.1 Large ready!")

# ── LOAD DINOv2 ViT-L/14 ────────────────────────────────────────────
# ViT-L: 1024-dim features vs ViT-B's 768-dim β†’ 33% richer representation
print("Loading DINOv2 ViT-L/14…")
dino = torch.hub.load("facebookresearch/dinov2", "dinov2_vitl14", pretrained=True)
dino.eval().to(device)
DINO_DIM = 1024  # ViT-L output dimension
print("βœ… DINOv2 ViT-L/14 ready!")

# Optimal resolution for ViT-L/14 (patch_size=14, so 518=37Γ—14)
DINO_RES = 518
dino_transform = transforms.Compose([
    transforms.Resize((DINO_RES, DINO_RES),
                      interpolation=transforms.InterpolationMode.BICUBIC),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225]),
])


# ═══════════════════════════════════════════════════════════════════
#  FEATURE EXTRACTION  (research-backed improvements vs v1)
#
#  β‘  White/neutral background (128-grey) instead of black
#    β†’ matches DINOv2 training distribution
#
#  β‘‘ Context padding +15% around bbox
#    β†’ richer context for identification
#
#  β‘’ 2048-dim descriptor = concat(CLS_token, mean_valid_patches)
#    Proven better than CLS-only in "DINOv2 Meets Text" (CVPR 2024):
#      [CLS avg] pooling beats [CLS]-only on both classification
#      AND dense retrieval tasks
#
#  β‘£ Filter high-norm outlier patches before computing patch mean
#    Outlier patches carry global info, not local β€” including them
#    pollutes the patch-mean with redundant global signal
#    (ICLR 2024: "Vision Transformers Need Registers")
#
#  β‘€ Multi-scale: features at 1Γ— crop + 0.8Γ— tight crop β†’ averaged
#    β†’ more robust to slight viewpoint/scale variation
# ═══════════════════════════════════════════════════════════════════

def make_crop(image_rgb: np.ndarray, mask: np.ndarray, bbox, pad_ratio=0.15):
    """Padded crop with neutral background. Returns numpy RGB or None."""
    x, y, w, h = [int(v) for v in bbox]
    H, W = image_rgb.shape[:2]
    px, py = int(w * pad_ratio), int(h * pad_ratio)
    x1, y1 = max(0, x - px), max(0, y - py)
    x2, y2 = min(W, x + w + px), min(H, y + h + py)
    if (x2 - x1) < 8 or (y2 - y1) < 8:
        return None
    canvas = np.full_like(image_rgb[y1:y2, x1:x2], 128)   # neutral grey bg
    seg = mask[y1:y2, x1:x2]
    canvas[seg] = image_rgb[y1:y2, x1:x2][seg]
    return canvas


def extract_rich_features(crop_np: np.ndarray) -> torch.Tensor:
    """
    Returns L2-normalised 2048-dim descriptor:
      concat(cls_token [1024], filtered_patch_mean [1024])
    """
    pil    = Image.fromarray(crop_np.astype(np.uint8))
    tensor = dino_transform(pil).unsqueeze(0).to(device)

    with torch.no_grad():
        out = dino.forward_features(tensor)
        cls    = out["x_norm_clstoken"].squeeze(0)        # [1024]
        patches = out["x_norm_patchtokens"].squeeze(0)    # [N, 1024]

    # Filter high-norm outlier patches (ICLR 2024 registers paper)
    norms = patches.norm(dim=-1)
    cutoff = norms.mean() + 2.0 * norms.std()
    valid  = patches[norms < cutoff]
    if valid.shape[0] == 0:
        valid = patches
    patch_mean = valid.mean(dim=0)                         # [1024]

    desc = torch.cat([cls, patch_mean], dim=0)             # [2048]
    return F.normalize(desc, dim=0)


def extract_multiscale_features(crop_np: np.ndarray) -> torch.Tensor:
    """Average features from full-padded crop + tighter inner crop."""
    feat1 = extract_rich_features(crop_np)
    H, W  = crop_np.shape[:2]
    my, mx = int(H * 0.10), int(W * 0.10)
    tight  = crop_np[my: H - my, mx: W - mx]
    if tight.shape[0] > 8 and tight.shape[1] > 8:
        feat2 = extract_rich_features(tight)
        return F.normalize(feat1 + feat2, dim=0)   # average β†’ renorm
    return feat1


# ── GLOBAL CACHE ─────────────────────────────────────────────────────
cache: dict = {
    "image_rgb"    : None,
    "candidates"   : None,
    "features"     : None,   # [N, 2048] L2-normalised
    "valid_indices": None,
    "status"       : "empty",
}


def preprocess_image(image_rgb: np.ndarray) -> float:
    """Run SAM2 auto-mask + DINOv2 features β€” called ONCE per image."""
    global cache
    cache["status"] = "processing"
    print("πŸ”„ Preprocessing image…")
    t0 = time.time()

    candidates = mask_generator.generate(image_rgb)
    print(f"  SAM2: {len(candidates)} regions in {time.time()-t0:.1f}s")

    t1 = time.time()
    all_feats, valid_idx = [], []
    for i, c in enumerate(candidates):
        crop = make_crop(image_rgb, c["segmentation"], c["bbox"])
        if crop is None:
            continue
        all_feats.append(extract_multiscale_features(crop))
        valid_idx.append(i)

    if not all_feats:
        cache["status"] = "error"
        return 0.0

    features = torch.stack(all_feats)   # [N, 2048]
    print(f"  DINOv2: {len(all_feats)} descriptors in {time.time()-t1:.1f}s")

    cache.update(image_rgb=image_rgb, candidates=candidates,
                 features=features, valid_indices=valid_idx, status="ready")

    total = time.time() - t0
    print(f"βœ… Done in {total:.1f}s")
    return total


# ═══════════════════════════════════════════════════════════════════
#  SIMILARITY SCORING  (improved vs v1)
#
#  v1:  score = cosine(CLS_q, CLS_c)   β€” single 768-dim CLS
#  v2:  ensemble = 0.50 Γ— cosine(full_desc_q, full_desc_c)   [2048]
#                + 0.30 Γ— cosine(cls_only_q,  cls_only_c)    [1024]
#                + 0.20 Γ— cosine(patch_only_q, patch_only_c) [1024]
#
#       Full-desc captures semantic + texture jointly.
#       CLS sub-score acts as a semantic gate (right category?).
#       Patch sub-score captures fine-grained texture/appearance.
#
#  Selection: top-K βˆͺ {score β‰₯ threshold}
#       β†’ always surfaces closest matches even if below threshold
#       β†’ avoids completely empty results when threshold is too high
# ═══════════════════════════════════════════════════════════════════

def ensemble_similarity(q: torch.Tensor, C: torch.Tensor) -> torch.Tensor:
    """
    q: [2048] normalised query
    C: [N, 2048] normalised corpus
    Returns [N] ensemble similarity scores.
    """
    sim_full  = torch.mv(C, q)                                         # [N]

    q_cls = F.normalize(q[:DINO_DIM].unsqueeze(0), dim=1).squeeze()
    c_cls = F.normalize(C[:, :DINO_DIM], dim=1)
    sim_cls   = torch.mv(c_cls, q_cls)                                  # [N]

    q_pat = F.normalize(q[DINO_DIM:].unsqueeze(0), dim=1).squeeze()
    c_pat = F.normalize(C[:, DINO_DIM:], dim=1)
    sim_pat   = torch.mv(c_pat, q_pat)                                  # [N]

    return 0.50 * sim_full + 0.30 * sim_cls + 0.20 * sim_pat


def query_at_click(click_x: int, click_y: int,
                   threshold: float = 0.72, top_k: int = 8):
    if cache["status"] != "ready":
        return None, "Not ready", None

    image_rgb = cache["image_rgb"]

    # Segment clicked object
    with torch.inference_mode():
        sam2_predictor.set_image(image_rgb)
        masks, scores, _ = sam2_predictor.predict(
            point_coords=np.array([[click_x, click_y]]),
            point_labels=np.array([1]),
            multimask_output=True,
        )
    query_mask = masks[np.argmax(scores)]

    ys, xs = np.where(query_mask)
    if len(xs) == 0:
        return None, "No object at click point", None

    bbox_q    = (xs.min(), ys.min(), xs.max()-xs.min(), ys.max()-ys.min())
    query_crop = make_crop(image_rgb, query_mask, bbox_q)
    if query_crop is None:
        return None, "Object too small", None

    query_feat = extract_multiscale_features(query_crop)    # [2048]

    sims = ensemble_similarity(query_feat, cache["features"])   # [N]

    # Adaptive selection: top-K βˆͺ threshold
    k = min(top_k, sims.shape[0])
    topk_idx = torch.topk(sims, k).indices
    thresh_idx = (sims >= threshold).nonzero(as_tuple=False).squeeze(1)
    final_idx  = torch.unique(torch.cat([topk_idx, thresh_idx]))

    # Sort descending by score
    order      = torch.argsort(sims[final_idx], descending=True)
    final_idx  = final_idx[order].tolist()
    final_sims = sims[torch.tensor(final_idx)].tolist()

    matched_masks = [
        cache["candidates"][cache["valid_indices"][i]]["segmentation"]
        for i in final_idx
    ]
    return matched_masks, final_sims, query_mask


# ── RENDERING ────────────────────────────────────────────────────────

def render_overlay(image_rgb, matched_masks, query_mask,
                   click_x, click_y, scores, threshold):
    overlay = image_rgb.copy().astype(np.float32)

    # Confidence-coloured masks: green (high) β†’ yellow β†’ orange (low)
    for mask, score in zip(matched_masks, scores):
        t = max(0.0, min(1.0, (score - (threshold - 0.1)) /
                               (1.0   - (threshold - 0.1) + 1e-6)))
        color = np.array([50 + int(200*(1-t)), 180 + int(75*t), 50], np.float32)
        overlay[mask] = overlay[mask] * 0.35 + color * 0.65

    # Blue = clicked object (on top)
    overlay[query_mask] = overlay[query_mask] * 0.35 + np.array([50,130,255]) * 0.65

    result = np.clip(overlay, 0, 255).astype(np.uint8)

    # Score labels
    for mask, score in zip(matched_masks[:10], scores[:10]):
        ys, xs = np.where(mask)
        if len(xs) == 0:
            continue
        cx, cy = int(xs.mean()), int(ys.mean())
        lbl = f"{score:.2f}"
        cv2.putText(result, lbl, (cx-18, cy+5),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0,0,0), 3)
        cv2.putText(result, lbl, (cx-18, cy+5),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255,255,255), 1)

    # Click marker
    cv2.circle(result, (click_x, click_y), 10, (255, 50, 50), -1)
    cv2.circle(result, (click_x, click_y), 13, (255,255,255),  2)

    n_above = sum(1 for s in scores if s >= threshold)
    banner  = f"{n_above} matches above threshold  |  top-{len(matched_masks)} shown"
    cv2.putText(result, banner, (15,38), cv2.FONT_HERSHEY_SIMPLEX, 0.85,(0,0,0),4)
    cv2.putText(result, banner, (15,38), cv2.FONT_HERSHEY_SIMPLEX, 0.85,(50,255,100),2)
    return result


# ── GRADIO HANDLERS ──────────────────────────────────────────────────

def handle_upload(image):
    if image is None:
        return None, "⬆️ Upload an image to begin"
    image_rgb = np.array(image.convert("RGB"))
    t = preprocess_image(image_rgb)
    preview = image_rgb.copy()
    cv2.putText(preview, "βœ… Ready β€” click any object!",
                (15,38), cv2.FONT_HERSHEY_SIMPLEX, 0.85, (0,0,0), 3)
    cv2.putText(preview, "βœ… Ready β€” click any object!",
                (15,38), cv2.FONT_HERSHEY_SIMPLEX, 0.85, (50,255,100), 2)
    status = (f"βœ… {len(cache['candidates'])} regions preprocessed in {t:.1f}s  "
              f"β”‚  SAM2-Large + DINOv2-ViT-L  β”‚  2048-dim ensemble features")
    return Image.fromarray(preview), status


def handle_click(image, evt: gr.SelectData, threshold, top_k):
    if cache["status"] != "ready":
        return image, "⚠️ Upload an image first"
    cx, cy = int(evt.index[0]), int(evt.index[1])
    t0 = time.time()
    result = query_at_click(cx, cy, threshold=float(threshold), top_k=int(top_k))
    if result[0] is None:
        return image, f"⚠️ {result[1]}"
    matched, scores, qmask = result
    ms = (time.time()-t0)*1000
    rendered = render_overlay(cache["image_rgb"], matched, qmask,
                               cx, cy, scores, float(threshold))
    sc_str = ", ".join(f"{s:.3f}" for s in scores[:5])
    status = (f"🎯 ({cx},{cy})  β”‚  {len(matched)} results  β”‚  "
              f"{ms:.0f}ms  β”‚  scores: [{sc_str}{'…' if len(scores)>5 else ''}]")
    return Image.fromarray(rendered), status


# ── GRADIO UI ─────────────────────────────────────────────────────────

with gr.Blocks(theme=gr.themes.Soft(primary_hue="emerald"),
               title="Object Similarity Detector v2") as app:

    gr.Markdown("""
    # πŸ”© Object Similarity Detector  `v2.0`
    ### SAM 2.1 Large &nbsp;Β·&nbsp; DINOv2 ViT-L/14 &nbsp;Β·&nbsp; 2048-dim Ensemble Descriptors &nbsp;Β·&nbsp; Adaptive Top-K

    | What improved | v1 | v2 |
    |---|---|---|
    | Segmentation model | SAM ViT-B | **SAM 2.1 Large** (6Γ— more accurate) |
    | Feature model | DINOv2 ViT-B 768-dim | **DINOv2 ViT-L 1024-dim** |
    | Descriptor | CLS token only | **CLS + filtered patch mean = 2048-dim** |
    | Background | Black (hurts features) | **Neutral grey** |
    | Resolution | 224Γ—224 | **518Γ—518** (native ViT-L res) |
    | Scoring | Single cosine sim | **3-way ensemble cosine** |
    | Selection | Hard threshold only | **Top-K βˆͺ threshold** |
    | Small objects | Often missed | **crop_n_layers=1 in SAM2** |

    > **How to use:** Upload image β†’ wait for preprocessing β†’ click any object β†’ instant results ⚑
    """)

    with gr.Row():
        with gr.Column(scale=1):
            upload = gr.Image(label="πŸ“ Upload Image", type="pil", height=280)
            with gr.Group():
                threshold_slider = gr.Slider(
                    0.50, 0.97, value=0.72, step=0.01,
                    label="🎚️ Similarity Threshold",
                    info="Lower = more matches. Raise to cut false positives."
                )
                topk_slider = gr.Slider(
                    1, 30, value=8, step=1,
                    label="πŸ”’ Always show top-K",
                    info="Min results shown even below threshold"
                )
            status_box = gr.Textbox(
                label="Status", value="Upload an image to begin…",
                interactive=False, lines=2
            )
            gr.Markdown("""
            **Legend**
            - 🟦 Blue = clicked object
            - πŸŸ©β†’πŸŸ¨ Green/Yellow = matches (brighter = higher score)
            - Numbers = similarity score
            - πŸ”΄ Red dot = click point

            **Troubleshooting**
            - Missing objects β†’ lower threshold or raise top-K
            - Too many false matches β†’ raise threshold
            - GPU recommended (SAM2-Large needs ~4 GB VRAM)
            """)
        with gr.Column(scale=2):
            output_image = gr.Image(
                label="πŸ–ΌοΈ Click any object below",
                type="pil", height=640, interactive=False
            )

    upload.change(fn=handle_upload, inputs=[upload],
                  outputs=[output_image, status_box])
    output_image.select(fn=handle_click,
                        inputs=[output_image, threshold_slider, topk_slider],
                        outputs=[output_image, status_box])

app.launch()