File size: 9,911 Bytes
a3b5cf9
 
 
 
 
bc7006b
 
a3b5cf9
 
 
bc7006b
a3b5cf9
 
 
bc7006b
a3b5cf9
 
4e25ac8
a3b5cf9
d65620a
a3b5cf9
 
 
4e25ac8
 
 
 
 
 
d65620a
4e25ac8
 
 
 
 
403700d
d65620a
 
 
a3b5cf9
 
 
d65620a
403700d
4e25ac8
 
d65620a
403700d
d65620a
403700d
 
 
 
 
 
 
 
 
 
 
 
 
 
d65620a
 
4bac563
a3b5cf9
4e25ac8
 
403700d
 
 
 
 
 
4e25ac8
 
 
 
 
 
 
 
 
 
 
 
 
d65620a
4e25ac8
 
d65620a
a3b5cf9
403700d
 
 
 
 
 
4e25ac8
a3b5cf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
003797e
 
 
a3b5cf9
 
 
 
 
 
 
 
 
 
 
 
 
d65620a
a3b5cf9
 
d65620a
a3b5cf9
 
 
 
86fa6e8
e16fbbb
d65620a
a3b5cf9
 
d65620a
4e25ac8
d65620a
4e25ac8
 
403700d
 
4e25ac8
403700d
a3b5cf9
 
 
 
d65620a
 
a3b5cf9
bc7006b
4bac563
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
"""
DINO Hierarchies — upload an image, get its condensed tree (hover) + reveal animation.
Runs DINOv3 ViT-L/16, builds the same condensed tree we precomputed for the static site,
and injects it into the existing JS widgets (no server-side rendering of the video).
"""
import os, io, json, html, base64, sys
print("[app] starting import", flush=True)
import numpy as np, torch
try:
    import gradio as gr
    print("[app] gradio", gr.__version__, flush=True)
except ImportError:
    gr = None          # allows importing the pipeline without gradio (local testing)
from PIL import Image
print("[app] transformers import...", flush=True)
from scipy.cluster.hierarchy import linkage, to_tree
from scipy.spatial.distance import squareform
from transformers import AutoModel, ViTMAEModel, CLIPVisionModel

RES, MS = 224, 4
DEV = "cuda" if torch.cuda.is_available() else "cpu"
TOKEN = os.environ.get("HF_TOKEN")

IMNET = (torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1),
         torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1))
CLIPN = (torch.tensor([0.48145466, 0.4578275, 0.40821073]).view(3, 1, 1),
         torch.tensor([0.26862954, 0.26130258, 0.27577711]).view(3, 1, 1))

# kind: how to strip prefix tokens / un-shuffle;  patch: 224/patch = grid side
MODELS = {
    "DINOv3 ViT-L · 0.3B (fast)":       dict(repo="facebook/dinov3-vitl16-pretrain-lvd1689m",     kind="dinov3", patch=16, norm=IMNET),
    "DINOv3 ViT-H+ · 0.84B (slower)":   dict(repo="facebook/dinov3-vith16plus-pretrain-lvd1689m", kind="dinov3", patch=16, norm=IMNET),
    "DINOv3 ViT-7B · 6.7B (needs GPU)": dict(repo="facebook/dinov3-vit7b16-pretrain-lvd1689m",    kind="dinov3", patch=16, norm=IMNET),
    "MAE ViT-L (pixel-reconstruction)": dict(repo="facebook/vit-mae-large",                       kind="mae",    patch=16, norm=IMNET),
    "CLIP ViT-L/14 (language-aligned)": dict(repo="openai/clip-vit-large-patch14",                kind="clip",   patch=14, norm=CLIPN),
    "DreamSim (human-aligned)":         dict(repo="dreamsim:dino_vitb16",                        kind="dreamsim", patch=16, norm=None),
}
DEFAULT_MODEL = next(iter(MODELS))

TREE_HTML = open("tree_widget.html").read()
REVEAL_HTML = open("reveal_widget.html").read()

# single-slot cache: keep only the most-recently-used model (avoids OOM when switching)
_cur = {"repo": None, "model": None, "pre": None}
def get_model(spec):
    repo = spec["repo"]
    if _cur["repo"] != repo:
        _cur["model"] = _cur["pre"] = None
        import gc; gc.collect()
        print(f"[app] loading {repo}", flush=True)
        if spec["kind"] == "dreamsim":
            from dreamsim import dreamsim as _ds          # lazy: heavy dep, only when selected
            cache = os.environ.get("DREAMSIM_CACHE", "/tmp/dreamsim_cache")
            os.makedirs(cache, exist_ok=True)
            mdl, pre = _ds(pretrained=True, dreamsim_type="dino_vitb16", cache_dir=cache, device=DEV)
            _cur["model"], _cur["pre"] = mdl.eval(), pre
        else:
            dt = torch.bfloat16 if "vit7b16" in repo else torch.float32   # 7B in bf16 to fit
            cls = {"dinov3": AutoModel, "mae": ViTMAEModel, "clip": CLIPVisionModel}[spec["kind"]]
            mdl = cls.from_pretrained(repo, dtype=dt, token=TOKEN, attn_implementation="eager")
            if spec["kind"] == "mae":
                mdl.config.mask_ratio = 0.0    # keep ALL patches (MAE masks 75% by default)
            _cur["model"] = mdl.eval().to(DEV)
        _cur["repo"] = repo
    return _cur["model"]


def encode(model, spec, x):
    """-> (feat[P,C], att[P]) with patches in spatial row-major order."""
    if spec["kind"] == "dreamsim":                        # LoRA-tuned DINO ViT-B/16 inside DreamSim
        vit = model.extractor_list[0].model
        with torch.no_grad():
            tok = vit.get_intermediate_layers(x, 1)[0]    # (1, 1+P, C)
            a = vit.get_last_selfattention(x)             # (1, heads, S, S)
        return tok[0, 1:].float().cpu().numpy(), a[0, :, 0, 1:].mean(0).float().cpu().numpy()
    with torch.no_grad():
        o = model(x, output_attentions=True)
    h, a = o.last_hidden_state, o.attentions[-1][0]            # a: (heads, S, S)
    if spec["kind"] == "mae":
        ids = o.ids_restore[0]                                 # MAE shuffles tokens -> un-shuffle
        feat, att = h[0, 1:][ids], a[:, 0, 1:][:, ids].mean(0)
    else:
        g = RES // spec["patch"]
        prefix = h.shape[1] - g * g                            # strip CLS (+ registers for DINOv3)
        feat, att = h[0, prefix:], a[:, 0, prefix:].mean(0)
    return feat.float().cpu().numpy(), att.float().cpu().numpy()


def build_tree(image, model_label):
    spec = MODELS.get(model_label, MODELS[DEFAULT_MODEL])
    model = get_model(spec)
    mdt = next(model.parameters()).dtype
    img = image.convert("RGB").resize((RES, RES), Image.BICUBIC)
    if spec["kind"] == "dreamsim":
        x = _cur["pre"](img).to(DEV)                      # DreamSim ships its own transform
    else:
        mean, std = spec["norm"]
        arr = np.asarray(img).astype(np.float32) / 255
        x = ((torch.from_numpy(arr).permute(2, 0, 1) - mean) / std).unsqueeze(0).to(DEV, mdt)
    feat, att = encode(model, spec, x)
    att_pct = 100.0 * att / att.sum()
    P = feat.shape[0]; g = int(round(P ** 0.5))
    fn = feat / (np.linalg.norm(feat, axis=1, keepdims=True) + 1e-8)
    root = to_tree(linkage(squareform(1.0 - fn @ fn.T, checks=False), method="average"))

    nodes = []
    def add(cn, depth, parent):
        idx = len(nodes)
        nodes.append({"leaves": cn.pre_order(lambda v: v.id), "children": [], "depth": depth,
                      "split_tau": None, "parent": parent})
        cur, fo = cn, []
        while not cur.is_leaf():
            l, r = cur.left, cur.right
            if l.count >= MS and r.count >= MS: break
            small, big = (r, l) if l.count >= r.count else (l, r); fo.append(small); cur = big
        if not cur.is_leaf():
            nodes[idx]["split_tau"] = round(1.0 - cur.dist, 3)
            for ch in (cur.left, cur.right):
                nodes[idx]["children"].append(add(ch, depth + 1, idx))
            for f in fo:
                fi = len(nodes); nodes.append({"leaves": f.pre_order(lambda v: v.id), "children": [],
                                               "depth": depth + 1, "split_tau": None, "parent": idx})
                nodes[idx]["children"].append(fi)
        return idx
    add(root, 0, -1)

    xpos = {}; cnt = [0]
    def setx(i):
        ch = nodes[i]["children"]
        if not ch: xpos[i] = cnt[0]; cnt[0] += 1
        else:
            for c in ch: setx(c)
            xpos[i] = float(np.mean([xpos[c] for c in ch]))
    setx(0)
    nleaves = cnt[0]; maxdepth = max(n["depth"] for n in nodes)
    tree = {"g": g, "nleaves": nleaves, "maxdepth": maxdepth,
            "nodes": [{"id": i, "x": round(xpos[i], 3), "depth": n["depth"], "parent": n["parent"],
                       "leaf": len(n["children"]) == 0, "n": len(n["leaves"]),
                       "att": round(float(att_pct[n["leaves"]].sum()), 1),       # % of total attention
                       "attm": round(float(att_pct[n["leaves"]].mean()), 4),     # mean attention % per patch (reveal order)
                       "attmv": round(float(att[n["leaves"]].mean()), 5),        # raw mean attention value per patch
                       "tau": n["split_tau"], "patches": n["leaves"]} for i, n in enumerate(nodes)]}
    buf = io.BytesIO(); img.save(buf, "PNG")
    return tree, base64.b64encode(buf.getvalue()).decode()


def iframe(widget_html, tree, img_b64, height):
    inject = (f'<script>window.TREE={json.dumps(tree)};'
              f'window.IMG_SRC="data:image/png;base64,{img_b64}";</script>')
    doc = widget_html.replace("<body>", "<body>" + inject, 1)
    return (f'<iframe srcdoc="{html.escape(doc, quote=True)}" '
            f'style="width:100%;height:{height}px;border:none;background:#0f1117;border-radius:8px"></iframe>')


def process(image, model_label):
    if image is None:
        return "<p style='color:#999'>Upload an image first.</p>", ""
    tree, img_b64 = build_tree(image, model_label)
    return iframe(TREE_HTML, tree, img_b64, 660), iframe(REVEAL_HTML, tree, img_b64, 700)


if gr is not None:
    with gr.Blocks(title="Visual Tree", theme=gr.themes.Base()) as demo:
        gr.Markdown("# 🌳 Visual Tree\nUpload an image → get its **condensed tree** (hover a node) "
                    "and the **reveal animation** (best-first by CLS attention).")
        with gr.Row():
            inp = gr.Image(type="pil", label="Upload an image", height=300)
            with gr.Column(scale=0):
                model_sel = gr.Dropdown(choices=list(MODELS.keys()), value=DEFAULT_MODEL, label="Encoder")
                btn = gr.Button("Build tree + reveal", variant="primary")
                gr.Markdown("<small>Compare encoder families: **DINOv3** (self-supervised, semantic), "
                            "**MAE** (pixel-reconstruction — more texture/appearance driven), "
                            "**CLIP** (language-aligned — 16×16 grid, patch-14), "
                            "**DreamSim** (tuned on *human* similarity judgments).<br>"
                            "ViT-H+/7B are large — slow on the free CPU; **ViT-7B realistically needs a GPU Space**. "
                            "First use of each model downloads its weights (one-time; DreamSim is ~1 GB).</small>")
        with gr.Tab("Condensed tree (hover)"):
            out_tree = gr.HTML()
        with gr.Tab("Reveal animation"):
            out_rev = gr.HTML()
        btn.click(process, [inp, model_sel], [out_tree, out_rev])
        inp.upload(process, [inp, model_sel], [out_tree, out_rev])

    print("[app] launching gradio", flush=True)
    demo.queue().launch()   # module-level: HF Spaces imports this file, so no __main__ guard