Jonathandav commited on
Commit
151f324
·
verified ·
1 Parent(s): 4978d0d

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +276 -0
app.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Facade — architectural style identification.
3
+
4
+ Photograph a building; get the closest styles from a synthetic reference
5
+ corpus, a reading of what you are looking at, and real examples nearby.
6
+
7
+ Design decisions that matter for a free Space:
8
+ * The corpus index is PRECOMPUTED and loaded from the Hub. Re-embedding a
9
+ thousand plates on every cold start would make the app unusable.
10
+ * The vision model loads lazily on first query, not at import. A Space that
11
+ times out during a live demo is worse than one that is slow once.
12
+ * OpenStreetMap is queried only on demand and failures degrade silently —
13
+ a rate-limited third party must never take the app down mid-demo.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import io
19
+ import json
20
+ import os
21
+ import urllib.parse
22
+ import urllib.request
23
+
24
+ import gradio as gr
25
+ import numpy as np
26
+
27
+ # ZeroGPU: free Gradio hosting requires dynamic GPU allocation. The `spaces`
28
+ # module is only present on a Space, so import defensively — the same file
29
+ # must still run locally and in a notebook.
30
+ try:
31
+ import spaces
32
+ ZERO_GPU = True
33
+ except ImportError: # local / Colab
34
+ ZERO_GPU = False
35
+
36
+ class _Shim:
37
+ @staticmethod
38
+ def GPU(*a, **k):
39
+ def deco(fn):
40
+ return fn
41
+ return deco
42
+
43
+ spaces = _Shim()
44
+ import pandas as pd
45
+ import torch
46
+ from huggingface_hub import hf_hub_download, snapshot_download
47
+ from PIL import Image
48
+
49
+ DATASET_REPO = os.environ.get("FACADE_DATASET", "USERNAME/facade-styles")
50
+ MODEL_ID = os.environ.get(
51
+ "FACADE_MODEL", "laion/CLIP-ViT-B-32-laion2B-s34B-b79K")
52
+ TOP_K = 3
53
+
54
+ _model = None
55
+ _proc = None
56
+ _state: dict = {}
57
+
58
+
59
+ # --------------------------------------------------------------------------
60
+ # Loading
61
+ # --------------------------------------------------------------------------
62
+
63
+ def load_index():
64
+ """Fetch the precomputed index and manifest from the Hub."""
65
+ if _state:
66
+ return _state
67
+ emb_path = hf_hub_download(DATASET_REPO, "index_embeddings.npy",
68
+ repo_type="dataset")
69
+ ids_path = hf_hub_download(DATASET_REPO, "index_plate_ids.csv",
70
+ repo_type="dataset")
71
+ man_path = hf_hub_download(DATASET_REPO, "plate_manifest.parquet",
72
+ repo_type="dataset")
73
+ styles_path = hf_hub_download(DATASET_REPO, "style_seed.csv",
74
+ repo_type="dataset")
75
+
76
+ _state["E"] = np.load(emb_path)
77
+ _state["plate_ids"] = pd.read_csv(ids_path)["plate_id"].tolist()
78
+ _state["manifest"] = pd.read_parquet(man_path).set_index("plate_id")
79
+ _state["styles"] = pd.read_csv(styles_path).set_index("style_id")
80
+ _state["style_of"] = np.array([p.split("-")[0] for p in _state["plate_ids"]])
81
+ return _state
82
+
83
+
84
+ def get_model():
85
+ """Load lazily and on CPU.
86
+
87
+ ZeroGPU allocates a device only inside an @spaces.GPU function, so the
88
+ model must not touch CUDA at import time — doing so breaks the Space at
89
+ startup rather than at first query.
90
+ """
91
+ global _model, _proc
92
+ if _model is None:
93
+ from transformers import AutoModel, AutoProcessor
94
+ _model = AutoModel.from_pretrained(MODEL_ID).eval()
95
+ _proc = AutoProcessor.from_pretrained(MODEL_ID)
96
+ return _model, _proc
97
+
98
+
99
+ def _as_tensor(x):
100
+ if torch.is_tensor(x):
101
+ return x
102
+ for a in ("image_embeds", "pooler_output", "last_hidden_state"):
103
+ v = getattr(x, a, None)
104
+ if torch.is_tensor(v):
105
+ return v.mean(1) if v.dim() == 3 else v
106
+ raise TypeError(type(x))
107
+
108
+
109
+ @spaces.GPU(duration=30)
110
+ def embed_image(img: Image.Image) -> np.ndarray:
111
+ model, proc = get_model()
112
+ device = "cuda" if torch.cuda.is_available() else "cpu"
113
+ model = model.to(device)
114
+ with torch.no_grad():
115
+ px = proc(images=[img.convert("RGB")],
116
+ return_tensors="pt")["pixel_values"].to(device)
117
+ v = _as_tensor(model.get_image_features(pixel_values=px)).float()
118
+ v = v / v.norm(dim=-1, keepdim=True)
119
+ return v[0].cpu().numpy()
120
+
121
+
122
+ # --------------------------------------------------------------------------
123
+ # Geographic prior (OpenStreetMap)
124
+ # --------------------------------------------------------------------------
125
+
126
+ OVERPASS = "https://overpass-api.de/api/interpreter"
127
+
128
+
129
+ def nearby_eras(lat: float, lon: float, radius_m: int = 1500) -> dict:
130
+ """Construction dates of real buildings near a point.
131
+
132
+ Returns an empty dict on any failure. A rate-limited third party must not
133
+ be able to break the app during a demo.
134
+ """
135
+ q = (f"[out:json][timeout:25];"
136
+ f'(way["building"]["start_date"](around:{radius_m},{lat},{lon});'
137
+ f'relation["building"]["start_date"](around:{radius_m},{lat},{lon}););'
138
+ f"out tags 300;")
139
+ try:
140
+ req = urllib.request.Request(
141
+ OVERPASS, data=urllib.parse.urlencode({"data": q}).encode(),
142
+ headers={"User-Agent": "facade-app/1.0"})
143
+ with urllib.request.urlopen(req, timeout=25) as r:
144
+ data = json.load(r)
145
+ except Exception:
146
+ return {}
147
+
148
+ counts: dict[int, int] = {}
149
+ for el in data.get("elements", []):
150
+ d = str(el.get("tags", {}).get("start_date", ""))[:4]
151
+ if d.isdigit():
152
+ counts[int(d)] = counts.get(int(d), 0) + 1
153
+ return counts
154
+
155
+
156
+ def period_prior(style_ids, era_counts, tolerance: int = 40) -> np.ndarray:
157
+ """Soft prior over styles, from how many nearby buildings share their era.
158
+
159
+ Soft on purpose: a genuinely unusual building should still be findable, so
160
+ this reranks rather than filters.
161
+ """
162
+ if not era_counts:
163
+ return np.zeros(len(style_ids))
164
+ styles = load_index()["styles"]
165
+ out = []
166
+ for sid in style_ids:
167
+ try:
168
+ start = int(str(styles.loc[sid, "period"]).split("-")[0])
169
+ except (ValueError, KeyError):
170
+ out.append(0.0)
171
+ continue
172
+ out.append(sum(c for yr, c in era_counts.items()
173
+ if abs(yr - start) <= tolerance))
174
+ arr = np.array(out, dtype=float)
175
+ return arr / (arr.max() or 1.0)
176
+
177
+
178
+ # --------------------------------------------------------------------------
179
+ # Core query
180
+ # --------------------------------------------------------------------------
181
+
182
+ def identify(image, use_location: bool, lat: float, lon: float,
183
+ rerank_weight: float):
184
+ if image is None:
185
+ return "Upload a photograph of a building facade to begin.", None, ""
186
+
187
+ s = load_index()
188
+ q = embed_image(image)
189
+ scores = s["E"] @ q
190
+
191
+ note = ""
192
+ if use_location:
193
+ eras = nearby_eras(lat, lon)
194
+ if eras:
195
+ prior = period_prior(s["style_of"], eras)
196
+ scores = scores + rerank_weight * prior
197
+ note = (f"\n\n*Reranked using {sum(eras.values())} dated buildings "
198
+ f"within 1.5 km.*")
199
+ else:
200
+ note = "\n\n*No dated OpenStreetMap buildings nearby; ranking is visual only.*"
201
+
202
+ # Best plate per style, then the top styles.
203
+ best: dict[str, tuple[float, int]] = {}
204
+ for i, sid in enumerate(s["style_of"]):
205
+ if sid not in best or scores[i] > best[sid][0]:
206
+ best[sid] = (float(scores[i]), i)
207
+ ranked = sorted(best.items(), key=lambda kv: -kv[1][0])[:TOP_K]
208
+
209
+ total = sum(np.exp(np.array([r[1][0] for r in ranked]) * 12))
210
+ lines, gallery = [], []
211
+ for rank, (sid, (score, idx)) in enumerate(ranked, 1):
212
+ row = s["styles"].loc[sid]
213
+ conf = float(np.exp(score * 12) / total)
214
+ lines.append(
215
+ f"### {rank}. {row['style_name']} · {conf:.0%}\n"
216
+ f"**{row['period']}** — {row['key_features']}\n\n"
217
+ f"{row['massing']}; {row['primary_material']}; "
218
+ f"{row['window_rhythm']}."
219
+ )
220
+ pid = s["plate_ids"][idx]
221
+ gallery.append((plate_url(pid), f"{row['style_name']} (reference)"))
222
+
223
+ reading = ""
224
+ top_pid = s["plate_ids"][ranked[0][1][1]]
225
+ man = s["manifest"]
226
+ if "reading" in man.columns and pd.notna(man.loc[top_pid].get("reading")):
227
+ reading = f"**What you are looking at**\n\n{man.loc[top_pid]['reading']}"
228
+
229
+ caveat = (
230
+ "\n\n---\n*Visual-similarity search over a synthetic reference corpus. "
231
+ "Suggestions are stylistic, not an authoritative attribution, and carry "
232
+ "no claim about a building's architect, date, or heritage status.*"
233
+ )
234
+ return "\n\n".join(lines) + note + caveat, gallery, reading
235
+
236
+
237
+ def plate_url(plate_id: str) -> str:
238
+ return (f"https://huggingface.co/datasets/{DATASET_REPO}/resolve/main/"
239
+ f"plates/{plate_id}.png")
240
+
241
+
242
+ # --------------------------------------------------------------------------
243
+ # Interface
244
+ # --------------------------------------------------------------------------
245
+
246
+ EXAMPLE_NOTE = """
247
+ **Tip — include the whole building.** Retrieval is measurably weaker on facade
248
+ close-ups: architectural style lives in massing, roofline and silhouette, and
249
+ a cropped window grid discards all three. Step back if you can.
250
+ """
251
+
252
+ with gr.Blocks(title="Facade — architectural style finder") as demo:
253
+ gr.Markdown("# Facade\n### Point a camera at a building. Find out what you are looking at.")
254
+
255
+ with gr.Row():
256
+ with gr.Column(scale=1):
257
+ img = gr.Image(type="pil", label="Building photograph", height=340)
258
+ gr.Markdown(EXAMPLE_NOTE)
259
+ use_loc = gr.Checkbox(label="Use my location to rerank", value=False)
260
+ with gr.Row():
261
+ lat = gr.Number(label="Latitude", value=32.0771, precision=4)
262
+ lon = gr.Number(label="Longitude", value=34.7745, precision=4)
263
+ weight = gr.Slider(0.0, 0.6, value=0.25, step=0.05,
264
+ label="Geographic prior weight")
265
+ go = gr.Button("Identify", variant="primary")
266
+
267
+ with gr.Column(scale=1):
268
+ out = gr.Markdown()
269
+ reading = gr.Markdown()
270
+ gallery = gr.Gallery(label="Closest reference plates", columns=3,
271
+ height=220)
272
+
273
+ go.click(identify, [img, use_loc, lat, lon, weight], [out, gallery, reading])
274
+
275
+ if __name__ == "__main__":
276
+ demo.launch()