Jovan Bjegovic commited on
Commit
6e32234
Β·
0 Parent(s):

Move region indexes to HF dataset (geobot-indexes); load on demand via hf_hub_download

Browse files
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ WORKDIR /app
3
+ ENV HF_HOME=/app/hf-cache \
4
+ PYTHONUNBUFFERED=1
5
+
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
8
+ && pip install --no-cache-dir -r requirements.txt
9
+
10
+ # Bake BOTH switchable CLIP models into the image (Space disk is non-persistent at runtime).
11
+ RUN python - <<'PY'
12
+ from transformers import CLIPVisionModelWithProjection, CLIPImageProcessor
13
+ for mid in ("openai/clip-vit-base-patch32", "openai/clip-vit-large-patch14", "geolocal/StreetCLIP"):
14
+ CLIPVisionModelWithProjection.from_pretrained(mid)
15
+ CLIPImageProcessor.from_pretrained(mid)
16
+ PY
17
+
18
+ COPY . .
19
+ EXPOSE 7860
20
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: GeoBot
3
+ emoji: 🌍
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # GeoBot
12
+
13
+ A tiny, fully self-hosted AI opponent for a GeoGuessr-style game. It receives two
14
+ Street View images (headings ~0Β° and ~180Β°) and returns a country guess with a
15
+ coordinate and human-readable reasoning.
16
+
17
+ It does **no training and no external AI calls**. It embeds the two images with
18
+ CLIP (`openai/clip-vit-base-patch32`, vision tower only, CPU) and retrieves the
19
+ nearest examples from a precomputed index of visual geography clues, votes on a
20
+ country, and fills reasoning templates from the matched clue labels.
21
+
22
+ - `POST /guess` β€” multipart `image0`, `image180`; header `X-API-Key`.
23
+ - `GET /health` β€” liveness + index size (also the wake-up ping target).
24
+
25
+ Geographic clue knowledge derived from the community guides at
26
+ [plonkit.net](https://plonkit.net) (embeddings only; no images redistributed).
app.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 2 β€” FastAPI inference service for the HF Docker Space (port 7860).
2
+
3
+ Loads ALL switchable CLIP models + their indexes once at startup, asserts each
4
+ index matches its model, then serves POST /guess (multipart, X-API-Key) and
5
+ GET /health (no auth). Pick a model per request with ?model=fast|pro.
6
+ """
7
+
8
+ import io
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ from collections import Counter
14
+ from contextlib import asynccontextmanager
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+ from fastapi import FastAPI, File, Header, HTTPException, Query, UploadFile
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from PIL import Image
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent / "shared"))
23
+ from version import MODELS, DEFAULT_MODEL # noqa: E402
24
+ from embedder import Embedder # noqa: E402
25
+ import guess as guesslib # noqa: E402
26
+ import atlas as atlaslib # noqa: E402
27
+ import region as regionlib # noqa: E402
28
+
29
+ DATA = Path(__file__).resolve().parent / "data"
30
+ API_KEY = os.environ.get("API_KEY", "")
31
+ ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
32
+ MAX_BYTES = 4 * 1024 * 1024
33
+ RATE_LIMIT_PER_MIN = 30
34
+
35
+ # Region kNN indexes live in a free HF Dataset repo (the 1GB Space can't hold them all).
36
+ # Resolve a region file from local data/ if present, else download from the dataset (cached).
37
+ INDEX_DATASET = os.environ.get("INDEX_DATASET", "GeoguessrAngular/geobot-indexes")
38
+
39
+
40
+ def region_file_path(fname):
41
+ local = DATA / fname
42
+ if local.exists():
43
+ return local
44
+ from huggingface_hub import hf_hub_download
45
+ return Path(hf_hub_download(repo_id=INDEX_DATASET, filename=fname, repo_type="dataset"))
46
+
47
+ Image.MAX_IMAGE_PIXELS = 50_000_000
48
+
49
+ STATE = {}
50
+ _req_times = [] # global in-memory rate limiter timestamps
51
+
52
+
53
+ @asynccontextmanager
54
+ async def lifespan(app: FastAPI):
55
+ centroids = json.loads((DATA / "centroids.json").read_text(encoding="utf-8"))
56
+ priors_path = DATA / "priors.json"
57
+ priors = json.loads(priors_path.read_text(encoding="utf-8")) if priors_path.exists() else None
58
+
59
+ models = {}
60
+ embedder_cache = {} # model_id -> Embedder (heavy weights loaded once)
61
+ index_cache = {} # index_file -> (index, rows, count, model_id, index_version)
62
+ for key, m in MODELS.items():
63
+ if m["model_id"] not in embedder_cache:
64
+ embedder_cache[m["model_id"]] = Embedder(m["model_id"])
65
+
66
+ # --- region kNN locator (e.g. Serbia) ---
67
+ if m.get("region_file"):
68
+ ref_emb, ref_lat, ref_lng = regionlib.load_region(region_file_path(m["region_file"]))
69
+ models[key] = {
70
+ "type": "region", "embedder": embedder_cache[m["model_id"]],
71
+ "ref_emb": ref_emb, "ref_lat": ref_lat, "ref_lng": ref_lng,
72
+ "country_slug": m.get("country_slug", key), "country_name": m.get("country_name", key),
73
+ "model_id": m["model_id"], "label": m.get("label", key),
74
+ }
75
+ print(f"Loaded model '{key}': region kNN {ref_emb.shape}, {m['model_id']}")
76
+ continue
77
+
78
+ # --- learned classifier head (Atlas) ---
79
+ if m.get("head_file"):
80
+ W, b, classes = atlaslib.load_head(DATA / m["head_file"])
81
+ models[key] = {
82
+ "type": "head", "embedder": embedder_cache[m["model_id"]],
83
+ "W": W, "b": b, "classes": classes,
84
+ "model_id": m["model_id"], "label": m.get("label", key),
85
+ }
86
+ print(f"Loaded model '{key}': head W{W.shape} {len(classes)} classes, {m['model_id']}")
87
+ continue
88
+
89
+ # --- retrieval (index) model ---
90
+ if m["index_file"] not in index_cache:
91
+ meta = json.loads((DATA / m["meta_file"]).read_text(encoding="utf-8"))
92
+ index = np.load(DATA / m["index_file"]).astype(np.float32)
93
+ rows = meta["rows"]
94
+ assert index.shape[0] == len(rows), f"[{key}] index/meta row mismatch"
95
+ index_cache[m["index_file"]] = (
96
+ index, rows, Counter(r["country"] for r in rows),
97
+ meta["model_id"], meta["index_version"])
98
+ index, rows, count, meta_mid, meta_iv = index_cache[m["index_file"]]
99
+ if meta_mid != m["model_id"] or meta_iv != m["index_version"]:
100
+ raise RuntimeError(
101
+ f"[{key}] index/version mismatch: meta has {meta_mid}/{meta_iv}, "
102
+ f"expected {m['model_id']}/{m['index_version']}")
103
+
104
+ text_vecs, text_countries = None, None
105
+ if m.get("text_file"):
106
+ text_vecs = np.load(DATA / m["text_file"]).astype(np.float32)
107
+ text_countries = json.loads((DATA / m["text_countries_file"]).read_text(encoding="utf-8"))
108
+
109
+ models[key] = {
110
+ "type": "retrieval", "embedder": embedder_cache[m["model_id"]],
111
+ "index": index, "rows": rows, "count": count,
112
+ "model_id": m["model_id"], "index_version": m["index_version"],
113
+ "label": m.get("label", key),
114
+ "text_vecs": text_vecs, "text_countries": text_countries,
115
+ }
116
+ print(f"Loaded model '{key}': index {index.shape}, {m['model_id']} {m['index_version']}"
117
+ f"{', +zeroshot-text' if text_vecs is not None else ''}")
118
+
119
+ # Optional script (writing-system) branch for the Atlas head.
120
+ script_vecs = script_names = country_scripts = None
121
+ if (DATA / "script_text.npy").exists() and (DATA / "country_scripts.json").exists():
122
+ script_vecs = np.load(DATA / "script_text.npy").astype(np.float32)
123
+ script_names = json.loads((DATA / "script_names.json").read_text(encoding="utf-8"))
124
+ country_scripts = json.loads((DATA / "country_scripts.json").read_text(encoding="utf-8"))
125
+ print(f"Loaded script branch: {len(script_names)} scripts, {len(country_scripts)} country maps")
126
+
127
+ STATE["models"] = models
128
+ STATE["centroids"] = centroids
129
+ STATE["priors"] = priors
130
+ STATE["script_vecs"] = script_vecs
131
+ STATE["script_names"] = script_names
132
+ STATE["country_scripts"] = country_scripts
133
+ STATE["cfg"] = guesslib.Config()
134
+ print(f"Ready. models={list(models)} default={DEFAULT_MODEL} "
135
+ f"centroids={len(centroids)} priors={len(priors) if priors else 0}")
136
+ yield
137
+ STATE.clear()
138
+
139
+
140
+ app = FastAPI(title="GeoBot", lifespan=lifespan)
141
+ app.add_middleware(CORSMiddleware, allow_origins=[ALLOWED_ORIGIN] if ALLOWED_ORIGIN != "*" else ["*"],
142
+ allow_methods=["*"], allow_headers=["*"])
143
+
144
+
145
+ def _check_key(x_api_key):
146
+ if not API_KEY:
147
+ return # unset key disables auth (local dev)
148
+ if x_api_key != API_KEY:
149
+ raise HTTPException(status_code=401, detail="bad or missing API key")
150
+
151
+
152
+ def _rate_limit():
153
+ now = time.time()
154
+ cutoff = now - 60
155
+ while _req_times and _req_times[0] < cutoff:
156
+ _req_times.pop(0)
157
+ if len(_req_times) >= RATE_LIMIT_PER_MIN:
158
+ raise HTTPException(status_code=429, detail="rate limited")
159
+ _req_times.append(now)
160
+
161
+
162
+ async def _read_image(upload: UploadFile):
163
+ raw = await upload.read()
164
+ if len(raw) > MAX_BYTES:
165
+ raise HTTPException(status_code=413, detail="image too large (>4 MB)")
166
+ try:
167
+ return Image.open(io.BytesIO(raw)).convert("RGB")
168
+ except Exception:
169
+ raise HTTPException(status_code=400, detail="undecodable image")
170
+
171
+
172
+ @app.get("/health")
173
+ def health():
174
+ models = STATE.get("models", {})
175
+ return {
176
+ "status": "ok",
177
+ "default_model": DEFAULT_MODEL,
178
+ "models": {
179
+ k: {"type": v.get("type"), "model_id": v["model_id"], "label": v["label"],
180
+ **({"index_size": int(v["index"].shape[0]), "index_version": v["index_version"]}
181
+ if v.get("type") == "retrieval" else
182
+ {"refs": int(v["ref_emb"].shape[0])} if v.get("type") == "region" else
183
+ {"classes": len(v["classes"])})}
184
+ for k, v in models.items()
185
+ },
186
+ }
187
+
188
+
189
+ @app.post("/guess")
190
+ async def do_guess(images: list[UploadFile] = File(None),
191
+ image0: UploadFile = File(None), image180: UploadFile = File(None),
192
+ skill: float = Query(1.0, ge=0.0, le=1.0),
193
+ model: str = Query(DEFAULT_MODEL),
194
+ x_api_key: str = Header(None, alias="X-API-Key")):
195
+ _check_key(x_api_key)
196
+ _rate_limit()
197
+ # Accept either N frames under repeated field "images", or legacy image0/image180.
198
+ uploads = [u for u in (images or []) if u is not None]
199
+ if not uploads:
200
+ uploads = [u for u in (image0, image180) if u is not None]
201
+ if not uploads:
202
+ raise HTTPException(status_code=400, detail="no images")
203
+ key = model if model in STATE["models"] else DEFAULT_MODEL
204
+ M = STATE["models"][key]
205
+ t0 = time.time()
206
+ pil = [await _read_image(u) for u in uploads]
207
+ emb = M["embedder"].embed(pil)
208
+ try:
209
+ if M.get("type") == "region":
210
+ result = regionlib.predict(list(emb), M["ref_emb"], M["ref_lat"], M["ref_lng"],
211
+ M["country_slug"], M["country_name"])
212
+ elif M.get("type") == "head":
213
+ result = atlaslib.predict(list(emb), M["W"], M["b"], M["classes"],
214
+ STATE["centroids"], STATE["cfg"], STATE["priors"],
215
+ STATE["script_vecs"], STATE["script_names"],
216
+ STATE["country_scripts"])
217
+ else:
218
+ result = guesslib.guess(list(emb), M["index"], M["rows"],
219
+ STATE["centroids"], M["count"], STATE["cfg"],
220
+ STATE["priors"], M["text_vecs"], M["text_countries"])
221
+ except Exception as e:
222
+ raise HTTPException(status_code=500, detail=f"guess failed: {type(e).__name__}")
223
+ result["timing_ms"] = int((time.time() - t0) * 1000)
224
+ result["model"] = key
225
+ result["model_id"] = M["model_id"]
226
+ result["index_version"] = M.get("index_version")
227
+ print(f"[{key}] guess winner={result['country']} conf={result['confidence']} "
228
+ f"ms={result['timing_ms']}")
229
+ return result
atlas.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Atlas β€” the LEARNED model's inference. A linear classifier head on top of the
2
+ frozen StreetCLIP embedding: probs = softmax(emb @ W + b) over playable countries,
3
+ averaged across the round's frames, then weighted by the game-location prior.
4
+
5
+ Unlike guess.py (nearest-neighbour retrieval), this is a trained model: it learned
6
+ what distinguishes each country from all PlonkIt + geohints images.
7
+ """
8
+
9
+ import numpy as np
10
+
11
+ ASSERTIVE = [
12
+ "I've learned to read scenes like this as {country} β€” the overall mix of road, signage and surroundings fits.",
13
+ "This reads clearly as {country} to me, weighing everything in view.",
14
+ "Confident on {country}: the combination of cues I trained on lines up.",
15
+ "My read is {country} β€” the whole scene matches what I know for it.",
16
+ ]
17
+ HEDGED = [
18
+ "Looks most like {country}, though {runner} crossed my mind. Going with {country}.",
19
+ "Leaning {country} here; {runner} was the next best. Committing to {country}.",
20
+ "Probably {country} β€” {runner} was close, but I'll take {country}.",
21
+ "My best read is {country}, with {runner} as a maybe.",
22
+ ]
23
+ UNCERTAIN = [
24
+ "Tough scene β€” nothing jumps out, but it leans {country}, so that's my guess.",
25
+ "Low confidence, but the overall feel points to {country}.",
26
+ "Hard to read; I'll take {country} as the most likely.",
27
+ "Not sure, but {country} fits best, so guessing there.",
28
+ ]
29
+
30
+
31
+ def load_head(path):
32
+ z = np.load(path, allow_pickle=True)
33
+ return (z["W"].astype(np.float32), z["b"].astype(np.float32),
34
+ [str(c) for c in z["classes"]])
35
+
36
+
37
+ def _pick(variants, seed_text):
38
+ return variants[abs(hash(seed_text)) % len(variants)]
39
+
40
+
41
+ SCRIPT_PENALTY = 0.15 # multiply prob of countries that don't use the detected (non-Latin) script
42
+ # Only these distinctive scripts are reliable zero-shot; sinhala/lao/bengali/etc. are
43
+ # noisy attractors that misfire on text-less scenes, so they're excluded from detection.
44
+ RELIABLE_SCRIPTS = {"thai", "cyrillic", "greek", "cjk", "devanagari", "arabic", "hebrew"}
45
+ SCRIPT_MARGIN = 0.015 # the detected script must beat Latin by at least this
46
+
47
+
48
+ def _detect_script(embs, script_vecs, script_names):
49
+ """Return the detected writing system, or None (Latin / text-less / uncertain).
50
+ Fires only when a RELIABLE non-Latin script clearly beats Latin."""
51
+ if "latin" not in script_names:
52
+ return None
53
+ sims = np.zeros(len(script_names))
54
+ for e in embs:
55
+ sims += script_vecs @ e
56
+ sims /= len(embs)
57
+ latin = sims[script_names.index("latin")]
58
+ best, best_s = None, -1e9
59
+ for i, name in enumerate(script_names):
60
+ if name in RELIABLE_SCRIPTS and sims[i] > best_s:
61
+ best, best_s = name, sims[i]
62
+ return best if best is not None and (best_s - latin) >= SCRIPT_MARGIN else None
63
+
64
+
65
+ def predict(embs, W, b, classes, centroids, cfg, priors=None,
66
+ script_vecs=None, script_names=None, country_scripts=None):
67
+ """embs: list of (768,) StreetCLIP embeddings. Returns the response dict."""
68
+ if not isinstance(embs, (list, tuple)):
69
+ embs = [embs]
70
+ probs = np.zeros(len(classes), dtype=np.float64)
71
+ for e in embs:
72
+ logits = e @ W + b
73
+ logits = logits - logits.max()
74
+ ex = np.exp(logits)
75
+ probs += ex / ex.sum()
76
+ probs /= len(embs)
77
+
78
+ # weight by the game-location prior (and hard-filter 0-location countries)
79
+ if priors is not None:
80
+ for i, c in enumerate(classes):
81
+ av = priors.get(c, 0)
82
+ probs[i] = 0.0 if av <= 0 else probs[i] * (av ** cfg.PRIOR_BETA)
83
+ s = probs.sum()
84
+ if s > 0:
85
+ probs /= s
86
+
87
+ # script branch: if a distinctive (non-Latin) writing system is detected,
88
+ # down-weight countries that don't use it (Thai β†’ only Thailand, etc.)
89
+ detected = None
90
+ if script_vecs is not None and country_scripts is not None:
91
+ detected = _detect_script(embs, script_vecs, script_names)
92
+ if detected:
93
+ for i, c in enumerate(classes):
94
+ if detected not in country_scripts.get(c, ["latin"]):
95
+ probs[i] *= SCRIPT_PENALTY
96
+ s = probs.sum()
97
+ if s > 0:
98
+ probs /= s
99
+
100
+ order = np.argsort(-probs)
101
+ winner = classes[order[0]]
102
+ confidence = float(probs[order[0]])
103
+ runner = classes[order[1]] if len(order) > 1 else None
104
+ cen = centroids[winner]
105
+ runner_name = centroids.get(runner, {}).get("name", runner) if runner else None
106
+
107
+ seed = winner + f"{confidence:.2f}"
108
+ if confidence >= 0.55:
109
+ reasoning = _pick(ASSERTIVE, seed).format(country=cen["name"])
110
+ elif confidence >= 0.30:
111
+ reasoning = _pick(HEDGED, seed).format(country=cen["name"], runner=runner_name or "a neighbour")
112
+ else:
113
+ reasoning = _pick(UNCERTAIN, seed).format(country=cen["name"])
114
+ if detected:
115
+ reasoning += f" (I can see {detected} script, which points here.)"
116
+
117
+ return {
118
+ "lat": cen["lat"], "lon": cen["lon"],
119
+ "country": winner, "country_name": cen["name"],
120
+ "confidence": round(confidence, 3),
121
+ "runner_up": runner,
122
+ "runner_up_name": runner_name,
123
+ "runner_up_conf": round(float(probs[order[1]]), 3) if len(order) > 1 else None,
124
+ "reasoning": reasoning,
125
+ "clues": [],
126
+ "script": detected,
127
+ }
data/atlas_head.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8bf5bc3985fee140b7a5b323434e5d27f66c325619004b89afdd85a87bdd5ede
3
+ size 352815
data/centroids.json ADDED
@@ -0,0 +1,818 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "alaska": {
3
+ "lat": 64.2,
4
+ "lon": -149.49,
5
+ "name": "Alaska",
6
+ "src": "hardcoded"
7
+ },
8
+ "albania": {
9
+ "lat": 41.14564,
10
+ "lon": 20.00649,
11
+ "name": "Albania",
12
+ "src": "Albania"
13
+ },
14
+ "american-samoa": {
15
+ "lat": -14.31113,
16
+ "lon": -170.75345,
17
+ "name": "American Samoa",
18
+ "src": "American Samoa"
19
+ },
20
+ "andorra": {
21
+ "lat": 42.53604,
22
+ "lon": 1.56174,
23
+ "name": "Andorra",
24
+ "src": "Andorra"
25
+ },
26
+ "antarctica": {
27
+ "lat": -76.60565,
28
+ "lon": 66.27535,
29
+ "name": "Antarctica",
30
+ "src": "Antarctica"
31
+ },
32
+ "argentina": {
33
+ "lat": -37.0905,
34
+ "lon": -63.96988,
35
+ "name": "Argentina",
36
+ "src": "Argentina"
37
+ },
38
+ "australia": {
39
+ "lat": -24.92291,
40
+ "lon": 133.08113,
41
+ "name": "Australia",
42
+ "src": "Australia"
43
+ },
44
+ "austria": {
45
+ "lat": 47.69458,
46
+ "lon": 14.7636,
47
+ "name": "Austria",
48
+ "src": "Austria"
49
+ },
50
+ "azores": {
51
+ "lat": 37.80081,
52
+ "lon": -25.4669,
53
+ "name": "Azores",
54
+ "src": "Azores"
55
+ },
56
+ "bangladesh": {
57
+ "lat": 23.67772,
58
+ "lon": 89.85934,
59
+ "name": "Bangladesh",
60
+ "src": "Bangladesh"
61
+ },
62
+ "belarus": {
63
+ "lat": 53.69944,
64
+ "lon": 28.01873,
65
+ "name": "Belarus",
66
+ "src": "Belarus"
67
+ },
68
+ "belgium": {
69
+ "lat": 51.09263,
70
+ "lon": 4.16957,
71
+ "name": "Belgium",
72
+ "src": "Flemish"
73
+ },
74
+ "bermuda": {
75
+ "lat": 32.3192,
76
+ "lon": -64.72802,
77
+ "name": "Bermuda",
78
+ "src": "Bermuda"
79
+ },
80
+ "bhutan": {
81
+ "lat": 27.52397,
82
+ "lon": 90.29573,
83
+ "name": "Bhutan",
84
+ "src": "Bhutan"
85
+ },
86
+ "bolivia": {
87
+ "lat": -16.28784,
88
+ "lon": -64.28579,
89
+ "name": "Bolivia",
90
+ "src": "Bolivia"
91
+ },
92
+ "botswana": {
93
+ "lat": -22.3453,
94
+ "lon": 24.47144,
95
+ "name": "Botswana",
96
+ "src": "Botswana"
97
+ },
98
+ "brazil": {
99
+ "lat": -14.23886,
100
+ "lon": -49.72801,
101
+ "name": "Brazil",
102
+ "src": "Brazil"
103
+ },
104
+ "british-indian-ocean-territory": {
105
+ "lat": -6.19232,
106
+ "lon": 71.34757,
107
+ "name": "British Indian Ocean Territory",
108
+ "src": "Br. Indian Ocean Ter."
109
+ },
110
+ "bulgaria": {
111
+ "lat": 42.73222,
112
+ "lon": 25.18968,
113
+ "name": "Bulgaria",
114
+ "src": "Bulgaria"
115
+ },
116
+ "cambodia": {
117
+ "lat": 12.55877,
118
+ "lon": 105.10263,
119
+ "name": "Cambodia",
120
+ "src": "Cambodia"
121
+ },
122
+ "canada": {
123
+ "lat": 56.83692,
124
+ "lon": -110.43087,
125
+ "name": "Canada",
126
+ "src": "Canada"
127
+ },
128
+ "chile": {
129
+ "lat": -35.71034,
130
+ "lon": -71.4964,
131
+ "name": "Chile",
132
+ "src": "Chile"
133
+ },
134
+ "china": {
135
+ "lat": 36.90367,
136
+ "lon": 98.60392,
137
+ "name": "China",
138
+ "src": "China"
139
+ },
140
+ "christmas-island": {
141
+ "lat": -10.49635,
142
+ "lon": 105.64974,
143
+ "name": "Christmas Island",
144
+ "src": "Christmas I."
145
+ },
146
+ "cocos-islands": {
147
+ "lat": -12.17779,
148
+ "lon": 96.91627,
149
+ "name": "Cocos Islands",
150
+ "src": "Cocos Is."
151
+ },
152
+ "colombia": {
153
+ "lat": 4.11347,
154
+ "lon": -72.58619,
155
+ "name": "Colombia",
156
+ "src": "Colombia"
157
+ },
158
+ "costa-rica": {
159
+ "lat": 9.62102,
160
+ "lon": -83.6334,
161
+ "name": "Costa Rica",
162
+ "src": "Costa Rica"
163
+ },
164
+ "croatia": {
165
+ "lat": 44.74512,
166
+ "lon": 15.32158,
167
+ "name": "Croatia",
168
+ "src": "Croatia"
169
+ },
170
+ "curacao": {
171
+ "lat": 12.21206,
172
+ "lon": -69.03453,
173
+ "name": "CuraΓ§ao",
174
+ "src": "CuraΓ§ao"
175
+ },
176
+ "cyprus": {
177
+ "lat": 34.90489,
178
+ "lon": 32.97995,
179
+ "name": "Cyprus",
180
+ "src": "Cyprus"
181
+ },
182
+ "czechia": {
183
+ "lat": 49.80045,
184
+ "lon": 15.51259,
185
+ "name": "Czechia",
186
+ "src": "Czechia"
187
+ },
188
+ "denmark": {
189
+ "lat": 56.27434,
190
+ "lon": 9.26047,
191
+ "name": "Denmark",
192
+ "src": "Denmark"
193
+ },
194
+ "dominican-republic": {
195
+ "lat": 18.77655,
196
+ "lon": -70.12528,
197
+ "name": "Dominican Republic",
198
+ "src": "Dominican Rep."
199
+ },
200
+ "ecuador": {
201
+ "lat": -1.78872,
202
+ "lon": -78.28143,
203
+ "name": "Ecuador",
204
+ "src": "Ecuador"
205
+ },
206
+ "egypt": {
207
+ "lat": 26.82439,
208
+ "lon": 29.46414,
209
+ "name": "Egypt",
210
+ "src": "Egypt"
211
+ },
212
+ "estonia": {
213
+ "lat": 58.58849,
214
+ "lon": 25.49317,
215
+ "name": "Estonia",
216
+ "src": "Estonia"
217
+ },
218
+ "eswatini": {
219
+ "lat": -26.53957,
220
+ "lon": 31.44785,
221
+ "name": "Eswatini",
222
+ "src": "eSwatini"
223
+ },
224
+ "falkland-islands": {
225
+ "lat": -51.79424,
226
+ "lon": -58.61389,
227
+ "name": "Falkland Islands",
228
+ "src": "Falkland Is."
229
+ },
230
+ "faroe-islands": {
231
+ "lat": 62.19918,
232
+ "lon": -6.78831,
233
+ "name": "Faroe Islands",
234
+ "src": "Faeroe Is."
235
+ },
236
+ "finland": {
237
+ "lat": 64.94389,
238
+ "lon": 27.41573,
239
+ "name": "Finland",
240
+ "src": "Finland"
241
+ },
242
+ "france": {
243
+ "lat": 46.6,
244
+ "lon": 2.5,
245
+ "name": "France",
246
+ "src": "manual (metropolitan France β€” NE polygon repr. point fell in French Guiana)"
247
+ },
248
+ "germany": {
249
+ "lat": 51.08513,
250
+ "lon": 10.48123,
251
+ "name": "Germany",
252
+ "src": "Germany"
253
+ },
254
+ "ghana": {
255
+ "lat": 7.95184,
256
+ "lon": -1.07721,
257
+ "name": "Ghana",
258
+ "src": "Ghana"
259
+ },
260
+ "gibraltar": {
261
+ "lat": 36.12684,
262
+ "lon": -5.34628,
263
+ "name": "Gibraltar",
264
+ "src": "Gibraltar"
265
+ },
266
+ "greece": {
267
+ "lat": 39.07032,
268
+ "lon": 21.95268,
269
+ "name": "Greece",
270
+ "src": "Greece"
271
+ },
272
+ "greenland": {
273
+ "lat": 71.811,
274
+ "lon": -40.33342,
275
+ "name": "Greenland",
276
+ "src": "Greenland"
277
+ },
278
+ "guam": {
279
+ "lat": 13.45962,
280
+ "lon": 144.76055,
281
+ "name": "Guam",
282
+ "src": "Guam"
283
+ },
284
+ "guatemala": {
285
+ "lat": 15.77389,
286
+ "lon": -90.28683,
287
+ "name": "Guatemala",
288
+ "src": "Guatemala"
289
+ },
290
+ "hawaii": {
291
+ "lat": 19.9,
292
+ "lon": -155.58,
293
+ "name": "Hawaii",
294
+ "src": "hardcoded"
295
+ },
296
+ "hong-kong": {
297
+ "lat": 22.4112,
298
+ "lon": 114.056,
299
+ "name": "Hong Kong",
300
+ "src": "Hong Kong"
301
+ },
302
+ "hungary": {
303
+ "lat": 47.15799,
304
+ "lon": 19.11919,
305
+ "name": "Hungary",
306
+ "src": "Hungary"
307
+ },
308
+ "iceland": {
309
+ "lat": 64.96471,
310
+ "lon": -18.46765,
311
+ "name": "Iceland",
312
+ "src": "Iceland"
313
+ },
314
+ "india": {
315
+ "lat": 21.78675,
316
+ "lon": 80.22651,
317
+ "name": "India",
318
+ "src": "India"
319
+ },
320
+ "indonesia": {
321
+ "lat": 0.10491,
322
+ "lon": 113.32523,
323
+ "name": "Indonesia",
324
+ "src": "Indonesia"
325
+ },
326
+ "iraq": {
327
+ "lat": 35.93951,
328
+ "lon": 44.51636,
329
+ "name": "Iraq",
330
+ "src": "Iraqi Kurdistan"
331
+ },
332
+ "ireland": {
333
+ "lat": 53.41606,
334
+ "lon": -7.95824,
335
+ "name": "Ireland",
336
+ "src": "Ireland"
337
+ },
338
+ "isle-of-man": {
339
+ "lat": 54.23865,
340
+ "lon": -4.50997,
341
+ "name": "Isle of Man",
342
+ "src": "Isle of Man"
343
+ },
344
+ "israel-west-bank": {
345
+ "lat": 31.44616,
346
+ "lon": 34.66272,
347
+ "name": "Israel & the West Bank",
348
+ "src": "Israel"
349
+ },
350
+ "italy": {
351
+ "lat": 42.49249,
352
+ "lon": 12.69203,
353
+ "name": "Italy",
354
+ "src": "Italy"
355
+ },
356
+ "japan": {
357
+ "lat": 43.46212,
358
+ "lon": 143.33765,
359
+ "name": "Japan",
360
+ "src": "Japan"
361
+ },
362
+ "jersey": {
363
+ "lat": 49.21833,
364
+ "lon": -2.12238,
365
+ "name": "Jersey",
366
+ "src": "Jersey"
367
+ },
368
+ "jordan": {
369
+ "lat": 31.26985,
370
+ "lon": 36.2997,
371
+ "name": "Jordan",
372
+ "src": "Jordan"
373
+ },
374
+ "kazakhstan": {
375
+ "lat": 48.01087,
376
+ "lon": 66.3259,
377
+ "name": "Kazakhstan",
378
+ "src": "Kazakhstan"
379
+ },
380
+ "kenya": {
381
+ "lat": 0.15482,
382
+ "lon": 37.44792,
383
+ "name": "Kenya",
384
+ "src": "Kenya"
385
+ },
386
+ "kyrgyzstan": {
387
+ "lat": 41.22467,
388
+ "lon": 75.06975,
389
+ "name": "Kyrgyzstan",
390
+ "src": "Kyrgyzstan"
391
+ },
392
+ "laos": {
393
+ "lat": 18.20551,
394
+ "lon": 104.68352,
395
+ "name": "Laos",
396
+ "src": "Laos"
397
+ },
398
+ "latvia": {
399
+ "lat": 56.86803,
400
+ "lon": 24.38329,
401
+ "name": "Latvia",
402
+ "src": "Latvia"
403
+ },
404
+ "lebanon": {
405
+ "lat": 33.8692,
406
+ "lon": 35.90693,
407
+ "name": "Lebanon",
408
+ "src": "Lebanon"
409
+ },
410
+ "lesotho": {
411
+ "lat": -29.6153,
412
+ "lon": 28.16436,
413
+ "name": "Lesotho",
414
+ "src": "Lesotho"
415
+ },
416
+ "liechtenstein": {
417
+ "lat": 47.15736,
418
+ "lon": 9.5351,
419
+ "name": "Liechtenstein",
420
+ "src": "Liechtenstein"
421
+ },
422
+ "lithuania": {
423
+ "lat": 55.17273,
424
+ "lon": 24.14687,
425
+ "name": "Lithuania",
426
+ "src": "Lithuania"
427
+ },
428
+ "luxembourg": {
429
+ "lat": 49.80704,
430
+ "lon": 6.06511,
431
+ "name": "Luxembourg",
432
+ "src": "Luxembourg"
433
+ },
434
+ "macau": {
435
+ "lat": 22.13618,
436
+ "lon": 113.55943,
437
+ "name": "Macau",
438
+ "src": "Macao"
439
+ },
440
+ "madagascar": {
441
+ "lat": -18.76902,
442
+ "lon": 46.72857,
443
+ "name": "Madagascar",
444
+ "src": "Madagascar"
445
+ },
446
+ "madeira": {
447
+ "lat": 32.75674,
448
+ "lon": -16.95071,
449
+ "name": "Madeira",
450
+ "src": "Madeira"
451
+ },
452
+ "malaysia": {
453
+ "lat": 3.98945,
454
+ "lon": 102.11153,
455
+ "name": "Malaysia",
456
+ "src": "Malaysia"
457
+ },
458
+ "mali": {
459
+ "lat": 17.60567,
460
+ "lon": -0.75406,
461
+ "name": "Mali",
462
+ "src": "Mali"
463
+ },
464
+ "malta": {
465
+ "lat": 35.89501,
466
+ "lon": 14.43814,
467
+ "name": "Malta",
468
+ "src": "Malta"
469
+ },
470
+ "martinique": {
471
+ "lat": 14.64433,
472
+ "lon": -61.01815,
473
+ "name": "Martinique",
474
+ "src": "Martinique"
475
+ },
476
+ "mexico": {
477
+ "lat": 23.62927,
478
+ "lon": -102.25815,
479
+ "name": "Mexico",
480
+ "src": "Mexico"
481
+ },
482
+ "monaco": {
483
+ "lat": 43.74161,
484
+ "lon": 7.40293,
485
+ "name": "Monaco",
486
+ "src": "Monaco"
487
+ },
488
+ "mongolia": {
489
+ "lat": 46.85869,
490
+ "lon": 105.40843,
491
+ "name": "Mongolia",
492
+ "src": "Mongolia"
493
+ },
494
+ "montenegro": {
495
+ "lat": 42.70141,
496
+ "lon": 19.28945,
497
+ "name": "Montenegro",
498
+ "src": "Montenegro"
499
+ },
500
+ "namibia": {
501
+ "lat": -22.95749,
502
+ "lon": 17.23377,
503
+ "name": "Namibia",
504
+ "src": "Namibia"
505
+ },
506
+ "nepal": {
507
+ "lat": 28.37663,
508
+ "lon": 83.10311,
509
+ "name": "Nepal",
510
+ "src": "Nepal"
511
+ },
512
+ "netherlands": {
513
+ "lat": 52.10563,
514
+ "lon": 5.51641,
515
+ "name": "Netherlands",
516
+ "src": "Netherlands"
517
+ },
518
+ "new-zealand": {
519
+ "lat": -43.59547,
520
+ "lon": 171.23435,
521
+ "name": "New Zealand",
522
+ "src": "New Zealand"
523
+ },
524
+ "nigeria": {
525
+ "lat": 9.07622,
526
+ "lon": 7.93263,
527
+ "name": "Nigeria",
528
+ "src": "Nigeria"
529
+ },
530
+ "north-macedonia": {
531
+ "lat": 41.60515,
532
+ "lon": 21.73102,
533
+ "name": "North Macedonia",
534
+ "src": "North Macedonia"
535
+ },
536
+ "northern-mariana-islands": {
537
+ "lat": 14.15843,
538
+ "lon": 145.21334,
539
+ "name": "Northern Mariana Islands",
540
+ "src": "N. Mariana Is."
541
+ },
542
+ "norway": {
543
+ "lat": 64.56244,
544
+ "lon": 12.67465,
545
+ "name": "Norway",
546
+ "src": "Norway"
547
+ },
548
+ "oman": {
549
+ "lat": 20.81733,
550
+ "lon": 56.96465,
551
+ "name": "Oman",
552
+ "src": "Oman"
553
+ },
554
+ "pakistan": {
555
+ "lat": 30.38006,
556
+ "lon": 70.08668,
557
+ "name": "Pakistan",
558
+ "src": "Pakistan"
559
+ },
560
+ "panama": {
561
+ "lat": 8.41721,
562
+ "lon": -81.46731,
563
+ "name": "Panama",
564
+ "src": "Panama"
565
+ },
566
+ "peru": {
567
+ "lat": -9.18342,
568
+ "lon": -75.76765,
569
+ "name": "Peru",
570
+ "src": "Peru"
571
+ },
572
+ "philippines": {
573
+ "lat": 7.698,
574
+ "lon": 125.24433,
575
+ "name": "Philippines",
576
+ "src": "Philippines"
577
+ },
578
+ "pitcairn-islands": {
579
+ "lat": -24.36871,
580
+ "lon": -128.31689,
581
+ "name": "Pitcairn Islands",
582
+ "src": "Pitcairn Is."
583
+ },
584
+ "poland": {
585
+ "lat": 51.91992,
586
+ "lon": 19.15627,
587
+ "name": "Poland",
588
+ "src": "Poland"
589
+ },
590
+ "portugal": {
591
+ "lat": 39.56508,
592
+ "lon": -8.29157,
593
+ "name": "Portugal",
594
+ "src": "Portugal"
595
+ },
596
+ "puerto-rico": {
597
+ "lat": 18.22116,
598
+ "lon": -66.40149,
599
+ "name": "Puerto Rico",
600
+ "src": "Puerto Rico"
601
+ },
602
+ "qatar": {
603
+ "lat": 25.36365,
604
+ "lon": 51.13794,
605
+ "name": "Qatar",
606
+ "src": "Qatar"
607
+ },
608
+ "reunion": {
609
+ "lat": -21.11907,
610
+ "lon": 55.54424,
611
+ "name": "Reunion",
612
+ "src": "RΓ©union"
613
+ },
614
+ "romania": {
615
+ "lat": 45.96169,
616
+ "lon": 24.24783,
617
+ "name": "Romania",
618
+ "src": "Romania"
619
+ },
620
+ "russia": {
621
+ "lat": 59.46461,
622
+ "lon": 88.38747,
623
+ "name": "Russia",
624
+ "src": "Russia"
625
+ },
626
+ "rwanda": {
627
+ "lat": -1.94732,
628
+ "lon": 29.98075,
629
+ "name": "Rwanda",
630
+ "src": "Rwanda"
631
+ },
632
+ "saint-pierre-and-miquelon": {
633
+ "lat": 46.78162,
634
+ "lon": -56.19375,
635
+ "name": "Saint Pierre and Miquelon",
636
+ "src": "St. Pierre and Miquelon"
637
+ },
638
+ "san-marino": {
639
+ "lat": 43.93418,
640
+ "lon": 12.43819,
641
+ "name": "San Marino",
642
+ "src": "San Marino"
643
+ },
644
+ "sao-tome-and-principe": {
645
+ "lat": 0.21552,
646
+ "lon": 6.60119,
647
+ "name": "SΓ£o TomΓ© and PrΓ­ncipe",
648
+ "src": "SΓ£o TomΓ© and Principe"
649
+ },
650
+ "senegal": {
651
+ "lat": 14.48858,
652
+ "lon": -14.65724,
653
+ "name": "Senegal",
654
+ "src": "Senegal"
655
+ },
656
+ "serbia": {
657
+ "lat": 43.66231,
658
+ "lon": 20.97381,
659
+ "name": "Serbia",
660
+ "src": "Serbia"
661
+ },
662
+ "singapore": {
663
+ "lat": 1.36455,
664
+ "lon": 103.83054,
665
+ "name": "Singapore",
666
+ "src": "Singapore"
667
+ },
668
+ "slovakia": {
669
+ "lat": 48.67465,
670
+ "lon": 19.64504,
671
+ "name": "Slovakia",
672
+ "src": "Slovakia"
673
+ },
674
+ "slovenia": {
675
+ "lat": 46.15021,
676
+ "lon": 14.61594,
677
+ "name": "Slovenia",
678
+ "src": "Slovenia"
679
+ },
680
+ "south-africa": {
681
+ "lat": -28.47459,
682
+ "lon": 26.12089,
683
+ "name": "South Africa",
684
+ "src": "South Africa"
685
+ },
686
+ "south-georgia-sandwich-islands": {
687
+ "lat": -54.42978,
688
+ "lon": -36.49215,
689
+ "name": "South Georgia & Sandwich Islands",
690
+ "src": "S. Geo. and the Is."
691
+ },
692
+ "south-korea": {
693
+ "lat": 38.20457,
694
+ "lon": 127.00228,
695
+ "name": "South Korea",
696
+ "src": "Korean DMZ (south)"
697
+ },
698
+ "spain": {
699
+ "lat": 39.89948,
700
+ "lon": -3.47653,
701
+ "name": "Spain",
702
+ "src": "Spain"
703
+ },
704
+ "sri-lanka": {
705
+ "lat": 7.87889,
706
+ "lon": 80.66733,
707
+ "name": "Sri Lanka",
708
+ "src": "Sri Lanka"
709
+ },
710
+ "svalbard": {
711
+ "lat": 79.85177,
712
+ "lon": 22.69697,
713
+ "name": "Svalbard",
714
+ "src": "Svalbard Is."
715
+ },
716
+ "sweden": {
717
+ "lat": 62.19453,
718
+ "lon": 14.90512,
719
+ "name": "Sweden",
720
+ "src": "Sweden"
721
+ },
722
+ "switzerland": {
723
+ "lat": 46.81195,
724
+ "lon": 8.427,
725
+ "name": "Switzerland",
726
+ "src": "Switzerland"
727
+ },
728
+ "taiwan": {
729
+ "lat": 23.61665,
730
+ "lon": 120.82792,
731
+ "name": "Taiwan",
732
+ "src": "Taiwan"
733
+ },
734
+ "tanzania": {
735
+ "lat": -6.35412,
736
+ "lon": 34.20886,
737
+ "name": "Tanzania",
738
+ "src": "Tanzania"
739
+ },
740
+ "thailand": {
741
+ "lat": 13.03238,
742
+ "lon": 101.69302,
743
+ "name": "Thailand",
744
+ "src": "Thailand"
745
+ },
746
+ "tunisia": {
747
+ "lat": 33.79141,
748
+ "lon": 8.86273,
749
+ "name": "Tunisia",
750
+ "src": "Tunisia"
751
+ },
752
+ "turkey": {
753
+ "lat": 38.96105,
754
+ "lon": 35.47854,
755
+ "name": "Turkey",
756
+ "src": "Turkey"
757
+ },
758
+ "uganda": {
759
+ "lat": 1.38017,
760
+ "lon": 32.6841,
761
+ "name": "Uganda",
762
+ "src": "Uganda"
763
+ },
764
+ "ukraine": {
765
+ "lat": 48.79126,
766
+ "lon": 31.06142,
767
+ "name": "Ukraine",
768
+ "src": "Ukraine"
769
+ },
770
+ "united-arab-emirates": {
771
+ "lat": 24.35262,
772
+ "lon": 55.13711,
773
+ "name": "United Arab Emirates",
774
+ "src": "United Arab Emirates"
775
+ },
776
+ "united-kingdom": {
777
+ "lat": 54.64134,
778
+ "lon": -6.88882,
779
+ "name": "United Kingdom",
780
+ "src": "N. Ireland"
781
+ },
782
+ "united-states": {
783
+ "lat": 37.24636,
784
+ "lon": -99.69843,
785
+ "name": "United States of America",
786
+ "src": "United States of America"
787
+ },
788
+ "uruguay": {
789
+ "lat": -32.52663,
790
+ "lon": -55.81866,
791
+ "name": "Uruguay",
792
+ "src": "Uruguay"
793
+ },
794
+ "us-virgin-islands": {
795
+ "lat": 17.73298,
796
+ "lon": -64.75742,
797
+ "name": "US Virgin Islands",
798
+ "src": "U.S. Virgin Is."
799
+ },
800
+ "vanuatu": {
801
+ "lat": -17.67514,
802
+ "lon": 168.35423,
803
+ "name": "Vanuatu",
804
+ "src": "Vanuatu"
805
+ },
806
+ "vietnam": {
807
+ "lat": 15.96243,
808
+ "lon": 107.85285,
809
+ "name": "Vietnam",
810
+ "src": "Vietnam"
811
+ },
812
+ "us-minor-outlying-islands": {
813
+ "lat": -0.02,
814
+ "lon": -176.3,
815
+ "name": "US Minor Outlying Islands",
816
+ "src": "hardcoded"
817
+ }
818
+ }
data/country_scripts.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"alaska":["latin"],"albania":["latin"],"american-samoa":["latin"],"india":["latin","devanagari"],"andorra":["latin"],"argentina":["latin"],"russia":["latin","cyrillic"],"australia":["latin"],"austria":["latin"],"azores":["latin"],"spain":["latin"],"bangladesh":["latin","bengali"],"belarus":["latin","cyrillic"],"belgium":["latin"],"bermuda":["latin"],"bhutan":["latin"],"bolivia":["latin"],"botswana":["latin"],"brazil":["latin"],"bulgaria":["latin","cyrillic"],"cambodia":["latin","khmer"],"canada":["latin"],"chile":["latin"],"colombia":["latin"],"france":["latin"],"costa-rica":["latin"],"croatia":["latin"],"curacao":["latin"],"czechia":["latin"],"denmark":["latin"],"dominican-republic":["latin"],"ecuador":["latin"],"united-kingdom":["latin"],"estonia":["latin"],"eswatini":["latin"],"faroe-islands":["latin"],"finland":["latin"],"germany":["latin"],"ghana":["latin"],"gibraltar":["latin"],"greece":["latin","greek"],"greenland":["latin"],"guatemala":["latin"],"hawaii":["latin"],"hong-kong":["latin","cjk"],"hungary":["latin"],"iceland":["latin"],"ireland":["latin"],"isle-of-man":["latin"],"israel-west-bank":["latin","hebrew"],"italy":["latin"],"japan":["latin","cjk"],"jersey":["latin"],"jordan":["latin","arabic"],"indonesia":["latin"],"kazakhstan":["latin","cyrillic"],"kyrgyzstan":["latin","cyrillic"],"kenya":["latin"],"laos":["latin","lao"],"latvia":["latin"],"lebanon":["latin","arabic"],"lesotho":["latin"],"liechtenstein":["latin"],"lithuania":["latin"],"luxembourg":["latin"],"madeira":["latin"],"malaysia":["latin"],"malta":["latin"],"mexico":["latin"],"monaco":["latin"],"mongolia":["latin","cyrillic"],"montenegro":["latin","cyrillic"],"namibia":["latin"],"nepal":["latin","devanagari"],"netherlands":["latin"],"new-zealand":["latin"],"nigeria":["latin"],"north-macedonia":["latin","cyrillic"],"norway":["latin"],"oman":["latin","arabic"],"pakistan":["latin"],"panama":["latin"],"peru":["latin"],"philippines":["latin"],"poland":["latin"],"portugal":["latin"],"puerto-rico":["latin"],"qatar":["latin","arabic"],"cyprus":["latin","greek"],"romania":["latin"],"rwanda":["latin"],"reunion":["latin"],"san-marino":["latin"],"senegal":["latin"],"serbia":["latin","cyrillic"],"singapore":["latin"],"slovakia":["latin"],"slovenia":["latin"],"south-africa":["latin"],"sri-lanka":["latin","sinhala"],"sweden":["latin"],"switzerland":["latin"],"sao-tome-and-principe":["latin"],"taiwan":["latin","cjk"],"thailand":["latin","thai"],"tunisia":["latin","arabic"],"turkey":["latin"],"us-virgin-islands":["latin"],"uganda":["latin"],"ukraine":["latin","cyrillic"],"united-arab-emirates":["latin","arabic"],"united-states":["latin"],"uruguay":["latin"],"vietnam":["latin"]}
data/index_fast.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e85b6c2e9cf17d7b0d087ee52766153bcb7424c4a940ce7d5a72592c62421935
3
+ size 4879488
data/index_geo.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3d36c01d8fa7969e27d31b14cf7a3a6729cbf8ef95e7611476523538d206d831
3
+ size 7319168
data/index_pro.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f7c329f262461377f4ed439f8a71d2d34b0cb61046b03d59b24e8f29b6360f66
3
+ size 7319168
data/meta_fast.json ADDED
The diff for this file is too large to render. See raw diff
 
data/meta_geo.json ADDED
The diff for this file is too large to render. See raw diff
 
data/meta_pro.json ADDED
The diff for this file is too large to render. See raw diff
 
data/priors.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"alaska":90,"albania":210,"american-samoa":30,"india":1215,"andorra":30,"argentina":1200,"russia":1650,"australia":1410,"austria":450,"azores":60,"spain":1362,"bangladesh":600,"belarus":6,"belgium":400,"bermuda":30,"bhutan":300,"bolivia":510,"botswana":420,"brazil":1350,"bulgaria":600,"cambodia":450,"canada":1200,"chile":840,"colombia":900,"france":1300,"costa-rica":300,"croatia":540,"curacao":60,"czechia":600,"denmark":450,"dominican-republic":150,"ecuador":600,"united-kingdom":1350,"estonia":300,"eswatini":150,"faroe-islands":90,"finland":600,"germany":1200,"ghana":450,"gibraltar":30,"greece":700,"greenland":3,"guatemala":300,"hawaii":150,"hong-kong":60,"hungary":510,"iceland":450,"ireland":600,"isle-of-man":45,"israel-west-bank":600,"italy":1150,"japan":1350,"jersey":15,"jordan":300,"indonesia":1790,"kazakhstan":300,"kyrgyzstan":720,"kenya":650,"laos":90,"latvia":300,"lebanon":45,"lesotho":150,"liechtenstein":45,"lithuania":300,"luxembourg":135,"madeira":45,"malaysia":900,"malta":60,"mexico":1200,"monaco":60,"mongolia":300,"montenegro":240,"namibia":360,"nepal":240,"netherlands":360,"new-zealand":750,"nigeria":750,"north-macedonia":240,"norway":600,"oman":600,"pakistan":30,"panama":300,"peru":1140,"philippines":1200,"poland":600,"portugal":540,"puerto-rico":120,"qatar":90,"cyprus":240,"romania":600,"rwanda":300,"reunion":60,"san-marino":60,"senegal":450,"serbia":450,"singapore":90,"slovakia":450,"slovenia":450,"south-africa":1200,"sri-lanka":300,"sweden":600,"switzerland":300,"sao-tome-and-principe":15,"taiwan":300,"thailand":1200,"tunisia":300,"turkey":750,"us-virgin-islands":90,"uganda":150,"ukraine":450,"united-arab-emirates":450,"united-states":1200,"uruguay":300,"vietnam":540}
data/script_names.json ADDED
@@ -0,0 +1 @@
 
 
1
+ ["latin", "cyrillic", "greek", "thai", "cjk", "arabic", "hebrew", "devanagari", "bengali", "sinhala", "lao", "khmer"]
data/script_text.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e8a98b41c66636024e464c8795557d7c42559faf5f2b5526d996d876264573ef
3
+ size 18560
data/text_geo.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:12ca4f31d1a267ff6c2fd51cc86f3d90b5a56f2bafc283af30977b288807fc27
3
+ size 175232
data/text_geo_countries.json ADDED
@@ -0,0 +1 @@
 
 
1
+ ["alaska", "albania", "american-samoa", "andorra", "argentina", "australia", "austria", "azores", "bangladesh", "belarus", "belgium", "bermuda", "bhutan", "bolivia", "botswana", "brazil", "bulgaria", "cambodia", "canada", "chile", "colombia", "costa-rica", "croatia", "curacao", "cyprus", "czechia", "denmark", "dominican-republic", "ecuador", "estonia", "eswatini", "faroe-islands", "finland", "france", "germany", "ghana", "gibraltar", "greece", "greenland", "guatemala", "hawaii", "hong-kong", "hungary", "iceland", "india", "indonesia", "ireland", "isle-of-man", "israel-west-bank", "italy", "japan", "jersey", "jordan", "kazakhstan", "kenya", "kyrgyzstan", "laos", "latvia", "lebanon", "lesotho", "liechtenstein", "lithuania", "luxembourg", "madeira", "malaysia", "malta", "mexico", "monaco", "mongolia", "montenegro", "namibia", "nepal", "netherlands", "new-zealand", "nigeria", "north-macedonia", "norway", "oman", "pakistan", "panama", "peru", "philippines", "poland", "portugal", "puerto-rico", "qatar", "reunion", "romania", "russia", "rwanda", "san-marino", "sao-tome-and-principe", "senegal", "serbia", "singapore", "slovakia", "slovenia", "south-africa", "spain", "sri-lanka", "sweden", "switzerland", "taiwan", "thailand", "tunisia", "turkey", "uganda", "ukraine", "united-arab-emirates", "united-kingdom", "united-states", "uruguay", "us-virgin-islands", "vietnam"]
guess.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 2 β€” search + country vote + template reasoning. No LLM, fully offline.
2
+
3
+ Pure logic given an index (N,512 fp32, L2-normed), meta rows, and centroids.
4
+ The same module is imported by app.py (the Space) and eval/eval.py (the PC), so
5
+ the served algorithm and the evaluated algorithm are byte-identical.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ import numpy as np
11
+
12
+
13
+ @dataclass
14
+ class Config:
15
+ K: int = 15 # top matches per view
16
+ P: float = 8.0 # similarity sharpening exponent
17
+ ALPHA: float = 0.4 # large-guide bias correction (S_c /= max(count[c], MIN_COUNT)**ALPHA)
18
+ MIN_COUNT: int = 20 # floor on the per-country image count used for ALPHA normalization,
19
+ # so tiny guides (5–9 imgs) don't get a huge score boost and beat
20
+ # large countries on weak matches (the "India β†’ mid-Pacific" bug)
21
+ PRIOR_BETA: float = 0.3 # strength of the game-location prior (S_c *= available[c]**BETA).
22
+ # 0 disables the prior (filter still applies). Higher = trust the
23
+ # "how many real locations exist in this country" signal more.
24
+ TEXT_WEIGHT: float = 0.25 # blend weight for the zero-shot TEXT branch (geo only):
25
+ # final = (1-W)*image_retrieval + W*image↔country_text. 0 disables.
26
+ P_TEXT: float = 4.0 # sharpening exponent for zero-shot text sims
27
+ JITTER_DEG: float = 0.0
28
+ MAX_CLUES: int = 3
29
+ RUNNER_SENTENCE_RATIO: float = 0.5 # show runner-up note when its score >= 50% of winner
30
+
31
+
32
+ # --- reasoning templates, keyed by confidence band; chosen deterministically ---
33
+ ASSERTIVE = [
34
+ "Both views match features documented for {country} β€” {clue1} and {clue2}.{runner} Placing my guess in central {country}.",
35
+ "This is {country} for me: I'm seeing {clue1}, and {clue2} backs it up.{runner} Dropping my pin in central {country}.",
36
+ "Clear {country} signals here β€” {clue1} together with {clue2}.{runner} I'll guess central {country}.",
37
+ "Confident on {country}: {clue1} and {clue2} both line up.{runner} Going central {country}.",
38
+ ]
39
+ HEDGED = [
40
+ "This looks most like {country} to me β€” I matched {clue1}, though I also saw similarities to {runner_c}. Going with {country}.",
41
+ "Leaning {country}: {clue1} points that way, but {runner_c} crossed my mind too. I'll commit to {country}.",
42
+ "Probably {country} β€” {clue1} is the strongest hint, with {runner_c} as a maybe. Guessing {country}.",
43
+ "My read is {country} based on {clue1}, even if {runner_c} isn't far off. Placing it in {country}.",
44
+ ]
45
+ UNCERTAIN = [
46
+ "Tough one. Weak matches all around, but {clue1} nudges me toward {country}, so that's my guess.",
47
+ "Not much to go on here β€” {clue1} is the only real hint, pointing at {country}. Rolling with it.",
48
+ "Low confidence on this. {clue1} loosely suggests {country}, so I'll take the shot.",
49
+ "Hard to read. {clue1} is faint, but it leans {country} β€” guessing there.",
50
+ ]
51
+
52
+
53
+ def _pick(variants, seed_text):
54
+ return variants[abs(hash(seed_text)) % len(variants)]
55
+
56
+
57
+ def _distinct_clues(matches, k):
58
+ """Up to k distinct clue texts, ordered by sim desc."""
59
+ out, seen = [], set()
60
+ for m in sorted(matches, key=lambda x: -x["sim"]):
61
+ c = m["clue"]
62
+ if c not in seen:
63
+ seen.add(c)
64
+ out.append(m)
65
+ if len(out) >= k:
66
+ break
67
+ return out
68
+
69
+
70
+ def build_reasoning(country_name, confidence, winner_matches, runner_name, runner_matches, cfg):
71
+ clues = _distinct_clues(winner_matches, cfg.MAX_CLUES)
72
+ clue1 = clues[0]["clue"] if clues else "a few subtle details"
73
+ clue2 = clues[1]["clue"] if len(clues) > 1 else clue1
74
+ seed = "|".join(c["clue"] for c in clues) + country_name
75
+
76
+ if confidence >= 0.55:
77
+ runner = ""
78
+ if runner_name and runner_matches:
79
+ runner = f" I also weighed {runner_matches[0]['country_name']}, but those matches were weaker."
80
+ return _pick(ASSERTIVE, seed).format(
81
+ country=country_name, clue1=clue1.lower(), clue2=clue2.lower(), runner=runner)
82
+ if confidence >= 0.35:
83
+ runner_c = runner_matches[0]["country_name"] if runner_matches else "a neighbour"
84
+ return _pick(HEDGED, seed).format(country=country_name, clue1=clue1.lower(), runner_c=runner_c)
85
+ return _pick(UNCERTAIN, seed).format(country=country_name, clue1=clue1.lower())
86
+
87
+
88
+ def guess(embs, index, rows, centroids, count, cfg=Config(), priors=None,
89
+ text_vecs=None, text_countries=None):
90
+ """embs: list of (D,) float32 L2-normed view embeddings (1+; e.g. 5 frames around
91
+ the spot + a downward car view). Returns the response dict.
92
+
93
+ priors: optional {slug: available_location_count}. When given, the bot can ONLY
94
+ guess countries present here (those the game actually has locations for) and
95
+ weights each by available_count**PRIOR_BETA.
96
+
97
+ text_vecs/(text_countries): optional (C,D) L2-normed country text embeddings and
98
+ their aligned slugs (StreetCLIP zero-shot). When given, the final score blends
99
+ image retrieval with image↔country-text similarity (cfg.TEXT_WEIGHT).
100
+ """
101
+ if not isinstance(embs, (list, tuple)):
102
+ embs = [embs]
103
+ matches = {} # index row -> {sim, ...meta}
104
+ for e in embs:
105
+ sims = index @ e
106
+ top = np.argpartition(-sims, cfg.K)[: cfg.K] if len(sims) > cfg.K else np.arange(len(sims))
107
+ for idx in top:
108
+ s = float(sims[idx])
109
+ # if a row appears in both views, keep the larger sim but it still counts once here;
110
+ # union semantics with double-contribution handled by summing weights per view below
111
+ prev = matches.get(idx)
112
+ if prev is None:
113
+ r = rows[idx]
114
+ matches[idx] = {"sim": s, "country": r["country"],
115
+ "country_name": r["country_name"], "clue": r["clue"],
116
+ "page": r["page"], "_w": max(s, 0.0) ** cfg.P}
117
+ else:
118
+ # second view also matched this row: add its weight (intended double vote)
119
+ prev["_w"] += max(s, 0.0) ** cfg.P
120
+ prev["sim"] = max(prev["sim"], s)
121
+
122
+ # country scores
123
+ scores, by_country = {}, {}
124
+ for m in matches.values():
125
+ c = m["country"]
126
+ if priors is not None and priors.get(c, 0) <= 0:
127
+ continue # country has no game locations β†’ the bot must never guess it
128
+ scores[c] = scores.get(c, 0.0) + m["_w"]
129
+ by_country.setdefault(c, []).append(m)
130
+ for c in scores:
131
+ scores[c] /= (max(count.get(c, 1), cfg.MIN_COUNT) ** cfg.ALPHA)
132
+ if priors is not None:
133
+ scores[c] *= priors[c] ** cfg.PRIOR_BETA
134
+
135
+ if not scores:
136
+ # None of the matched countries are in the game's pool β€” fall back to the
137
+ # most location-rich country so we still return a valid, in-pool guess.
138
+ winner = max(priors, key=priors.get) if priors else rows[0]["country"]
139
+ cen = centroids[winner]
140
+ return {
141
+ "lat": cen["lat"], "lon": cen["lon"],
142
+ "country": winner, "country_name": cen["name"],
143
+ "confidence": 0.0, "runner_up": None,
144
+ "reasoning": "Couldn't match these views to anywhere I know β€” taking a blind guess.",
145
+ "clues": [],
146
+ }
147
+
148
+ # --- zero-shot TEXT branch (geo): blend image↔country-text with retrieval ---
149
+ if text_vecs is not None and text_countries is not None and cfg.TEXT_WEIGHT > 0:
150
+ sims_t = np.max(np.stack([text_vecs @ e for e in embs]), axis=0) # best view per country
151
+ zs = {}
152
+ for i, c in enumerate(text_countries):
153
+ if priors is not None and priors.get(c, 0) <= 0:
154
+ continue
155
+ s = max(float(sims_t[i]), 0.0) ** cfg.P_TEXT
156
+ if priors is not None:
157
+ s *= priors[c] ** cfg.PRIOR_BETA
158
+ zs[c] = s
159
+ rsum = sum(scores.values()) or 1.0
160
+ zsum = sum(zs.values()) or 1.0
161
+ W = cfg.TEXT_WEIGHT
162
+ final = {
163
+ c: (1 - W) * (scores.get(c, 0.0) / rsum) + W * (zs.get(c, 0.0) / zsum)
164
+ for c in set(scores) | set(zs)
165
+ }
166
+ else:
167
+ final = scores
168
+
169
+ ranked = sorted(final.items(), key=lambda kv: -kv[1])
170
+ winner, win_score = ranked[0]
171
+ total = sum(final.values()) or 1.0
172
+ confidence = win_score / total
173
+ runner = ranked[1][0] if len(ranked) > 1 else None
174
+ runner_score = ranked[1][1] if len(ranked) > 1 else 0.0
175
+
176
+ win_matches = sorted(by_country.get(winner, []), key=lambda x: -x["sim"])
177
+ runner_matches = sorted(by_country.get(runner, []), key=lambda x: -x["sim"]) if runner else []
178
+
179
+ cen = centroids[winner]
180
+ # The ratio gate only governs the optional extra runner-up SENTENCE in the
181
+ # assertive band; the hedged band always names the actual runner-up.
182
+ show_runner = bool(runner) and runner_score >= cfg.RUNNER_SENTENCE_RATIO * win_score
183
+ if confidence >= 0.55:
184
+ reasoning = build_reasoning(cen["name"], confidence, win_matches,
185
+ runner if show_runner else None,
186
+ runner_matches if show_runner else [], cfg)
187
+ else:
188
+ reasoning = build_reasoning(cen["name"], confidence, win_matches,
189
+ runner, runner_matches, cfg)
190
+
191
+ clue_list = []
192
+ for m in _distinct_clues(win_matches, cfg.MAX_CLUES):
193
+ clue_list.append({"text": m["clue"], "country": m["country"],
194
+ "page": m["page"], "sim": round(m["sim"], 3)})
195
+ if runner_matches:
196
+ m = runner_matches[0]
197
+ clue_list.append({"text": m["clue"], "country": m["country"],
198
+ "page": m["page"], "sim": round(m["sim"], 3)})
199
+
200
+ return {
201
+ "lat": cen["lat"], "lon": cen["lon"],
202
+ "country": winner, "country_name": cen["name"],
203
+ "confidence": round(confidence, 3),
204
+ "runner_up": runner,
205
+ "reasoning": reasoning,
206
+ "clues": clue_list,
207
+ }
region.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Region kNN locator (Nomad-style, but for one country's scraped Street View).
2
+
3
+ A region model holds N reference embeddings + their exact lat/lng. Inference:
4
+ embed the round's frames β†’ cosine-nearest reference across ALL frames β†’ predict
5
+ that reference's coordinates. Best for in-country precision (e.g. the Serbia model
6
+ guessing where in Serbia a panorama is), not country classification.
7
+ """
8
+ import numpy as np
9
+
10
+
11
+ def load_region(path):
12
+ """Load a region index .npz β†’ (ref_emb f32 normalised, lat, lng)."""
13
+ z = np.load(path, allow_pickle=True)
14
+ emb = z["emb"].astype(np.float32)
15
+ emb /= np.linalg.norm(emb, axis=1, keepdims=True) + 1e-8
16
+ return emb, z["lat"].astype(np.float64), z["lng"].astype(np.float64)
17
+
18
+
19
+ def predict(embs, ref_emb, ref_lat, ref_lng, country_slug, country_name, k=5):
20
+ """embs: list of (D,) query-frame embeddings. Returns a scoreable guess dict
21
+ (same shape the bot/guess.py emits): predicted lat/lon from the nearest
22
+ reference image, confidence from cosine similarity."""
23
+ Q = np.stack([e / (np.linalg.norm(e) + 1e-8) for e in embs]) # (F, D)
24
+ sims = Q @ ref_emb.T # (F, N)
25
+ fi, ni = np.unravel_index(int(np.argmax(sims)), sims.shape)
26
+ best = float(sims[fi, ni])
27
+
28
+ # Top-k across all frames β†’ geo-medoid (robust to a single odd neighbour).
29
+ flat = sims.reshape(-1)
30
+ kk = min(k, flat.shape[0])
31
+ top = np.argpartition(-flat, kk - 1)[:kk]
32
+ cols = (top % ref_emb.shape[0])
33
+ cla, cln = ref_lat[cols], ref_lng[cols]
34
+ R = 6371.0088; p = np.pi / 180.0
35
+ dsum = np.empty(len(cols))
36
+ for i in range(len(cols)):
37
+ a = (np.sin((cla - cla[i]) * p / 2) ** 2
38
+ + np.cos(cla[i] * p) * np.cos(cla * p) * np.sin((cln - cln[i]) * p / 2) ** 2)
39
+ dsum[i] = (2 * R * np.arcsin(np.sqrt(np.clip(a, 0, 1)))).sum()
40
+ m = int(dsum.argmin())
41
+ lat, lon = float(cla[m]), float(cln[m])
42
+
43
+ conf = round(max(0.0, min(1.0, best)), 3)
44
+ return {
45
+ "country": country_slug,
46
+ "country_name": country_name,
47
+ "lat": lat,
48
+ "lon": lon,
49
+ "confidence": conf,
50
+ "runner_up": None,
51
+ "runner_up_conf": None,
52
+ "script": None,
53
+ "reasoning": f"Matched the closest of {ref_emb.shape[0]} {country_name} street views "
54
+ f"(similarity {conf}).",
55
+ "clues": [],
56
+ }
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # torch CPU is installed separately in the Dockerfile (pinned to the CPU index).
2
+ transformers==5.11.0
3
+ huggingface_hub
4
+ fastapi==0.115.6
5
+ uvicorn[standard]==0.34.0
6
+ pillow==11.1.0
7
+ numpy==2.2.1
8
+ python-multipart==0.0.20
shared/__init__.py ADDED
File without changes
shared/embedder.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The ONE embedder. Imported by both the indexer (PC) and the server (Space).
2
+
3
+ Any divergence between index-time and serve-time preprocessing silently
4
+ destroys retrieval accuracy, so there must be exactly one implementation.
5
+ Vision-only: the server never needs the text tower.
6
+ """
7
+
8
+ import numpy as np
9
+ import torch
10
+ from PIL import Image
11
+
12
+ from transformers import CLIPVisionModelWithProjection, CLIPImageProcessor
13
+
14
+ try:
15
+ from .version import MODEL_ID
16
+ except ImportError: # allow running as a loose script
17
+ from version import MODEL_ID
18
+
19
+
20
+ class Embedder:
21
+ def __init__(self, model_id: str = MODEL_ID):
22
+ self.model_id = model_id
23
+ self.proc = CLIPImageProcessor.from_pretrained(model_id)
24
+ self.model = CLIPVisionModelWithProjection.from_pretrained(model_id).eval()
25
+
26
+ @torch.no_grad()
27
+ def embed(self, images: list[Image.Image]) -> np.ndarray:
28
+ """Return (B, 512) float32, L2-normalized rows. Input PIL images (any mode)."""
29
+ rgb = [im.convert("RGB") for im in images]
30
+ inputs = self.proc(images=rgb, return_tensors="pt")
31
+ emb = self.model(**inputs).image_embeds
32
+ emb = emb / emb.norm(dim=-1, keepdim=True)
33
+ return emb.float().cpu().numpy()
shared/version.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single source of truth for model + index versioning.
2
+
3
+ The indexer (PC) and the server (HF Space) both import these constants.
4
+ The server asserts that meta.json carries matching values before it starts,
5
+ so a stale index can never be served against a different model.
6
+ """
7
+
8
+ # The indexer (build_index.py) builds THIS model's index (currently the "geo" index).
9
+ MODEL_ID = "geolocal/StreetCLIP"
10
+ INDEX_VERSION = "v3"
11
+
12
+ # The Space serves multiple switchable models. Each has its own baked weights and
13
+ # its own index/meta files in data/. Switch per request via /guess?model=<key>.
14
+ MODELS = {
15
+ "fast": {
16
+ "model_id": "openai/clip-vit-base-patch32",
17
+ "index_version": "v1",
18
+ "index_file": "index_fast.npy",
19
+ "meta_file": "meta_fast.json",
20
+ "label": "Lite β€” ViT-B/32 (fast, less accurate)",
21
+ },
22
+ "pro": {
23
+ "model_id": "openai/clip-vit-large-patch14",
24
+ "index_version": "v2",
25
+ "index_file": "index_pro.npy",
26
+ "meta_file": "meta_pro.json",
27
+ "label": "Pro β€” ViT-L/14 (slower, more accurate)",
28
+ },
29
+ "geo": {
30
+ "model_id": "geolocal/StreetCLIP",
31
+ "index_version": "v3",
32
+ "index_file": "index_geo.npy",
33
+ "meta_file": "meta_geo.json",
34
+ "label": "Geo β€” StreetCLIP image retrieval only",
35
+ },
36
+ "geoplus": {
37
+ "model_id": "geolocal/StreetCLIP",
38
+ "index_version": "v3",
39
+ "index_file": "index_geo.npy",
40
+ "meta_file": "meta_geo.json",
41
+ "text_file": "text_geo.npy",
42
+ "text_countries_file": "text_geo_countries.json",
43
+ "label": "Geo+ β€” StreetCLIP + clue-text",
44
+ },
45
+ "atlas": {
46
+ "model_id": "geolocal/StreetCLIP",
47
+ "head_file": "atlas_head.npz",
48
+ "label": "Atlas β€” learned classifier (best)",
49
+ },
50
+ "serbia": {
51
+ "model_id": "geolocal/StreetCLIP",
52
+ "region_file": "serbia_index.npz",
53
+ "country_slug": "serbia",
54
+ "country_name": "Serbia",
55
+ "label": "Serbia β€” pinpoint locator (Serbia map only)",
56
+ },
57
+ "krajina": {
58
+ "model_id": "geolocal/StreetCLIP",
59
+ "region_file": "krajina_index.npz",
60
+ "country_slug": "krajina",
61
+ "country_name": "Krajina",
62
+ "label": "Krajina β€” pinpoint locator (Serbian Krajina map only)",
63
+ },
64
+ "usa": {
65
+ "model_id": "geolocal/StreetCLIP",
66
+ "region_file": "usa_index.npz",
67
+ "country_slug": "usa",
68
+ "country_name": "USA",
69
+ "map_id": "69c14929fc130ff0dfb0b916",
70
+ "label": "USA β€” pinpoint locator (USA map only)",
71
+ },
72
+ "canada": {
73
+ "model_id": "geolocal/StreetCLIP",
74
+ "region_file": "canada_index.npz",
75
+ "country_slug": "canada",
76
+ "country_name": "Canada",
77
+ "map_id": "69c13f8a66d5a179c340dd12",
78
+ "label": "Canada β€” pinpoint locator (Canada map only)",
79
+ },
80
+ "brazil": {
81
+ "model_id": "geolocal/StreetCLIP",
82
+ "region_file": "brazil_index.npz",
83
+ "country_slug": "brazil",
84
+ "country_name": "Brazil",
85
+ "map_id": "69c13f7066d5a179c340ac38",
86
+ "label": "Brazil β€” pinpoint locator (Brazil map only)",
87
+ },
88
+ "argentina": {
89
+ "model_id": "geolocal/StreetCLIP",
90
+ "region_file": "argentina_index.npz",
91
+ "country_slug": "argentina",
92
+ "country_name": "Argentina",
93
+ "map_id": "69c13c6466d5a179c33f9e16",
94
+ "label": "Argentina β€” pinpoint locator (Argentina map only)",
95
+ },
96
+ "russia": {
97
+ "model_id": "geolocal/StreetCLIP",
98
+ "region_file": "russia_index.npz",
99
+ "country_slug": "russia",
100
+ "country_name": "Russia",
101
+ "map_id": "69c1457b047406872f3c80f4",
102
+ "label": "Russia β€” pinpoint locator (Russia map only)",
103
+ },
104
+ "indonesia": {
105
+ "model_id": "geolocal/StreetCLIP",
106
+ "region_file": "indonesia_index.npz",
107
+ "country_slug": "indonesia",
108
+ "country_name": "Indonesia",
109
+ "map_id": "69c141f7200aba567e5352a8",
110
+ "label": "Indonesia β€” pinpoint locator (Indonesia map only)",
111
+ },
112
+ "peru": {
113
+ "model_id": "geolocal/StreetCLIP",
114
+ "region_file": "peru_index.npz",
115
+ "country_slug": "peru",
116
+ "country_name": "Peru",
117
+ "map_id": "69c144a7047406872f3be463",
118
+ "label": "Peru β€” pinpoint locator (Peru map only)",
119
+ },
120
+ "italy": {
121
+ "model_id": "geolocal/StreetCLIP",
122
+ "region_file": "italy_index.npz",
123
+ "country_slug": "italy",
124
+ "country_name": "Italy",
125
+ "map_id": "69c14219200aba567e538c4c",
126
+ "label": "Italy β€” pinpoint locator (Italy map only)",
127
+ },
128
+ "malaysia": {
129
+ "model_id": "geolocal/StreetCLIP",
130
+ "region_file": "malaysia_index.npz",
131
+ "country_slug": "malaysia",
132
+ "country_name": "Malaysia",
133
+ "map_id": "69c14385047406872f3af85c",
134
+ "label": "Malaysia β€” pinpoint locator (Malaysia map only)",
135
+ },
136
+ "colombia": {
137
+ "model_id": "geolocal/StreetCLIP",
138
+ "region_file": "colombia_index.npz",
139
+ "country_slug": "colombia",
140
+ "country_name": "Colombia",
141
+ "label": "Colombia β€” pinpoint locator (Colombia map only)",
142
+ },
143
+ "chile": {
144
+ "model_id": "geolocal/StreetCLIP",
145
+ "region_file": "chile_index.npz",
146
+ "country_slug": "chile",
147
+ "country_name": "Chile",
148
+ "map_id": "69c140b8200aba567e525880",
149
+ "label": "Chile β€” pinpoint locator (Chile map only)",
150
+ },
151
+ "new-zealand": {
152
+ "model_id": "geolocal/StreetCLIP",
153
+ "region_file": "new-zealand_index.npz",
154
+ "country_slug": "new-zealand",
155
+ "country_name": "New Zealand",
156
+ "map_id": "69c14422047406872f3b6e4f",
157
+ "label": "New Zealand β€” pinpoint locator (New Zealand map only)",
158
+ },
159
+ }
160
+ DEFAULT_MODEL = "atlas"