File size: 7,912 Bytes
90c4a87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""
Chipoint — run the image-geolocation model on your own photo(s).

Encodes each image with three frozen vision encoders, projects through trained heads, retrieves the nearest
gallery images, and applies the density-weighted consensus that produced the benchmark numbers. Prints a
predicted (latitude, longitude) per image.

Batch-friendly: pass many images and each 5 GB gallery index is read exactly ONCE for the whole batch (all
images are scored against it in a single pass), instead of re-reading 15 GB per image.

Usage:
    python run_chipoint.py path/to/photo.jpg [more.jpg ...]        # globs are expanded, e.g. photos/*.jpg

Weights (heads + gallery indexes + coords) must sit next to this script (as when you download the repo).
Override the location with CHIPOINT_DIR=/path/to/weights. DINOv3 is pulled ungated from ModelScope unless
DINOV3_PATH points at a local copy.
"""
import os, sys, glob
os.environ.setdefault("PYTORCH_HIP_ALLOC_CONF", "expandable_segments:True")
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
from pathlib import Path
import numpy as np, torch, torch.nn as nn, torch.nn.functional as F
from PIL import Image

W = Path(os.environ.get("CHIPOINT_DIR") or Path(__file__).resolve().parent)   # weights live next to this script
DEV = "cuda" if torch.cuda.is_available() else "cpu"
AC = torch.bfloat16 if DEV == "cuda" else torch.float32
M, K = 200, 64                                       # retrieve top-M per encoder, keep top-K fused candidates
Gv, LAM, MU, BW1, BW2 = 0.8, 1.7, 0.8, 1492.7, 750.0    # locked density-weighted-consensus config
ENC_D = {"so400m256": 1152, "dinov3": 1024, "gopt": 1536}

class Head(nn.Module):
    def __init__(s, D):
        super().__init__(); s.net = nn.Sequential(nn.Linear(D, 1024), nn.GELU(), nn.Dropout(0.1), nn.Linear(1024, 512))
    def forward(s, x): return F.normalize(s.net(x), dim=-1)

def load_head(tag, D):
    h = Head(D); sd = torch.load(W / f"proj_head_{tag}.pt", map_location="cpu")
    h.load_state_dict({k: v for k, v in sd.items() if k.startswith("net.")})   # drop geocell centers, unused at inference
    return h.to(DEV).eval()

# ---- encode ALL images with the 3 encoders, then release the encoders (free VRAM for retrieval) ----------
@torch.no_grad()
def encode_all(imgs, bs=64):
    import open_clip, torchvision.transforms as T
    from transformers import AutoModel, AutoImageProcessor
    E = {}
    for tag, name in [("so400m256", "ViT-SO400M-16-SigLIP2-256"), ("gopt", "ViT-gopt-16-SigLIP2-256")]:
        m, _, pre = open_clip.create_model_and_transforms(name, pretrained="webli"); m = m.to(DEV).eval(); out = []
        for i in range(0, len(imgs), bs):
            x = torch.stack([pre(p) for p in imgs[i:i+bs]]).to(DEV)
            with torch.autocast(DEV, dtype=AC): e = m.encode_image(x).float()
            out.append(F.normalize(e, dim=-1).cpu().numpy().astype("float32"))
        E[tag] = np.concatenate(out); del m
        if DEV == "cuda": torch.cuda.empty_cache()
    src = os.environ.get("DINOV3_PATH")
    if not src:
        from modelscope import snapshot_download as _ms
        src = _ms("facebook/dinov3-vitl16-pretrain-lvd1689m")
    dm = AutoModel.from_pretrained(src).to(DEV).eval()
    if DEV == "cuda": dm = dm.half()
    proc = AutoImageProcessor.from_pretrained(src)
    mean = torch.tensor(proc.image_mean).view(1, 3, 1, 1); std = torch.tensor(proc.image_std).view(1, 3, 1, 1)
    pre = T.Compose([T.Resize(256), T.CenterCrop(224), T.ToTensor()]); out = []
    for i in range(0, len(imgs), bs):
        x = torch.stack([(pre(p) - mean[0]) / std[0] for p in imgs[i:i+bs]]).to(DEV)
        x = x.half() if DEV == "cuda" else x
        o = dm(x); e = o.pooler_output if getattr(o, "pooler_output", None) is not None else o.last_hidden_state[:, 0]
        out.append(F.normalize(e.float(), dim=-1).cpu().numpy().astype("float32"))
    E["dinov3"] = np.concatenate(out); del dm
    if DEV == "cuda": torch.cuda.empty_cache()
    return E

@torch.no_grad()
def project(tag, emb):
    return F.normalize(HEADS[tag](torch.tensor(emb).to(DEV)), dim=-1).cpu().numpy().astype("float32")

# ---- retrieve ALL queries against one gallery in a SINGLE pass (gallery read once) -----------------------
@torch.no_grad()
def retrieve_all(tag, Q):                             # Q:[N,512] -> idx[N,M], sim[N,M]
    g = np.load(W / f"proj_head_gallery_512_{tag}.npy", mmap_mode="r"); N = len(Q)
    if DEV == "cuda":
        qt = torch.from_numpy(Q).to(DEV).half()
        bs = torch.full((N, M), -1e9, device=DEV); bi = torch.zeros((N, M), dtype=torch.long, device=DEV); step = 250000
        for i in range(0, g.shape[0], step):
            gg = torch.from_numpy(np.asarray(g[i:i+step], dtype="float16")).to(DEV)
            s = (qt @ gg.T).float(); cs, ci = s.topk(min(M, s.shape[1]), 1); ci = ci + i
            al = torch.cat([bs, cs], 1); ai = torch.cat([bi, ci], 1); o = al.topk(M, 1).indices
            bs = torch.gather(al, 1, o); bi = torch.gather(ai, 1, o); del gg, s
        torch.cuda.empty_cache()
        return bi.cpu().numpy(), bs.cpu().numpy()
    bs = np.full((N, M), -1e9, "float32"); bi = np.zeros((N, M), np.int64); step = 500000
    for i in range(0, g.shape[0], step):
        s = Q @ np.asarray(g[i:i+step], dtype="float32").T
        ci = np.argpartition(-s, min(M, s.shape[1]-1), 1)[:, :M]; cs = np.take_along_axis(s, ci, 1); ci = ci + i
        al = np.concatenate([bs, cs], 1); ai = np.concatenate([bi, ci], 1); o = np.argsort(-al, 1)[:, :M]
        bs = np.take_along_axis(al, o, 1); bi = np.take_along_axis(ai, o, 1)
    return bi, bs

def zc(a): return (a - a.mean()) / (a.std() + 1e-6)
def cell_key(ll, C): return (np.round(ll[..., 0]/C).astype(np.int64) << 20) ^ (np.round(ll[..., 1]/C).astype(np.int64) & 0xFFFFF)

def predict_one(R, n):                                # fuse the 3 encoders + density-weighted consensus for image n
    sc = {}
    for t in ENC_D:
        idx, sim = R[t]; z = zc(sim[n])
        for j, zz in zip(idx[n], z): sc[int(j)] = sc.get(int(j), 0.0) + float(zz)
    ci = np.array(sorted(sc, key=lambda k: -sc[k])[:K], np.int64); csim = np.array([sc[i] for i in ci], "float32")
    cc = GC[ci]
    DW = np.mean([[-np.log(DENS[C].get(int(k), 1) + 1.0) for k in cell_key(cc, C)] for C in (0.25,0.5,1.0,2.0)], 0)
    la, lo = np.radians(cc[:, 0]), np.radians(cc[:, 1])
    D = 2*6371.0*np.arcsin(np.sqrt(np.clip(np.sin((la[:,None]-la[None,:])/2)**2 +
        np.cos(la)[:,None]*np.cos(la)[None,:]*np.sin((lo[:,None]-lo[None,:])/2)**2, 0, 1)))
    vote = np.maximum(csim, 0) * np.exp(Gv * zc(DW))
    KV1 = (vote[None,:] * np.exp(-D/BW1)).sum(1); KV2 = (vote[None,:] * np.exp(-D/BW2)).sum(1)
    score = zc(csim) + 0.3*zc(DW) + LAM*zc(KV1) + MU*zc(KV2)
    return cc[int(score.argmax())]

if __name__ == "__main__":
    paths = [p for a in sys.argv[1:] for p in glob.glob(a)] or sys.exit("usage: python run_chipoint.py IMAGE ...")
    print(f"device={DEV}  loading heads + coords from {W} ...", flush=True)
    HEADS = {t: load_head(t, D) for t, D in ENC_D.items()}
    GC = np.load(W / "gallery_all_C_so400m256.npy").astype("float32")            # GPS per gallery entry
    DENS = {C: dict(zip(*[a.tolist() for a in np.unique(cell_key(GC, C), return_counts=True)])) for C in (0.25,0.5,1.0,2.0)}
    imgs = [Image.open(p).convert("RGB") for p in paths]
    print(f"encoding {len(imgs)} image(s) ...", flush=True)
    E = encode_all(imgs)                                                          # encoders freed after this
    print("retrieving (each gallery read once for the whole batch) ...", flush=True)
    R = {t: retrieve_all(t, project(t, E[t])) for t in ENC_D}                     # one 5 GB pass per encoder
    for n, p in enumerate(paths):
        lat, lon = predict_one(R, n)
        print(f"{p}\t{lat:.5f}, {lon:.5f}\thttps://maps.google.com/?q={lat:.5f},{lon:.5f}", flush=True)