"""
Facade — architectural style identification.
Photograph a building; get the closest styles from a synthetic reference
corpus, a reading written for *your* building at query time, and real named
buildings nearby in the same style.
Design decisions that matter for a free Space:
* The corpus index is PRECOMPUTED and loaded from the Hub. Re-embedding a
thousand plates on every cold start would make the app unusable.
* Models load lazily and on CPU; ZeroGPU allocates a device only inside an
@spaces.GPU call. Touching CUDA at import breaks the Space at startup
rather than at first query.
* Every external dependency — Overpass, Nominatim, the language model —
degrades silently. A rate-limited third party must never take the app
down mid-demo.
"""
from __future__ import annotations
import base64
import io
import json
import math
import os
import urllib.parse
import urllib.request
import gradio as gr
import numpy as np
import pandas as pd
import torch
from huggingface_hub import hf_hub_download
from PIL import Image
# ZeroGPU: free Gradio hosting requires dynamic GPU allocation. The `spaces`
# module exists only on a Space, so import defensively — the same file must
# still run locally and in a notebook.
try:
import spaces
except ImportError:
class _Shim:
@staticmethod
def GPU(*a, **k):
def deco(fn):
return fn
return deco
spaces = _Shim()
DATASET_REPO = os.environ.get("FACADE_DATASET", "USERNAME/facade-styles")
MODEL_ID = os.environ.get("FACADE_MODEL",
"laion/CLIP-ViT-B-32-laion2B-s34B-b79K")
LLM_ID = os.environ.get("FACADE_LLM", "Qwen/Qwen2.5-1.5B-Instruct")
TOP_K = 3
_model = None
_proc = None
_llm = None
_tok = None
_state: dict = {}
# --------------------------------------------------------------------------
# Index
# --------------------------------------------------------------------------
def load_index():
if _state:
return _state
def get(f):
return hf_hub_download(DATASET_REPO, f, repo_type="dataset")
_state["E"] = np.load(get("index_embeddings.npy"))
_state["plate_ids"] = pd.read_csv(get("index_plate_ids.csv"))["plate_id"].tolist()
_state["manifest"] = pd.read_parquet(get("plate_manifest.parquet")).set_index("plate_id")
_state["styles"] = pd.read_csv(get("style_seed.csv")).set_index("style_id")
_state["style_of"] = np.array([p.split("-")[0] for p in _state["plate_ids"]])
return _state
# --------------------------------------------------------------------------
# Measured attributes
# --------------------------------------------------------------------------
# The same pixel measurements the EDA used. Computing them on the user's photo
# lets the app say *why* it matched, and gives the language model concrete
# observations to write from rather than leaving it to invent detail.
def image_stats(img: Image.Image) -> dict:
rgb = img.convert("RGB")
a = np.asarray(rgb, dtype=float) / 255.0
hsv = np.asarray(rgb.convert("HSV"), dtype=float) / 255.0
g = np.asarray(rgb.convert("L"), dtype=float) / 255.0
dx = np.diff(g, axis=1)[:-1, :]
dy = np.diff(g, axis=0)[:, :-1]
gx, gy = np.abs(dx), np.abs(dy)
mag = np.hypot(dx, dy)
strong = mag > max(0.06, float(np.quantile(mag, 0.90)))
# A very flat image yields an empty angle set, and a density histogram over
# nothing returns NaN — which would surface as "nan" in the evidence table.
if strong.sum() > 50:
ang = np.mod(np.arctan2(dy[strong], dx[strong]), np.pi)
hist, _ = np.histogram(ang, bins=18, range=(0, np.pi))
total = hist.sum()
hist = (hist / total) if total else np.zeros(18)
vert = float(hist[:2].sum() + hist[-2:].sum())
horiz = float(hist[7:11].sum())
diag = float(hist[2:7].sum() + hist[11:16].sum())
ent = float(-(hist * np.log(hist + 1e-12)).sum() / np.log(len(hist)))
else:
vert = horiz = diag = ent = 0.0
if not all(np.isfinite([vert, horiz, diag, ent])):
vert = horiz = diag = ent = 0.0
return {
"saturation": float(hsv[..., 1].mean()),
"brightness": float(a.mean()),
"orientation_ratio": float(gx.mean() / (gy.mean() + 1e-6)),
"frac_vertical": vert,
"frac_horizontal": horiz,
"frac_diagonal": diag,
"angle_entropy": ent,
}
def orientation_label(st: dict) -> str:
v, h, d, ent = (st["frac_vertical"], st["frac_horizontal"],
st["frac_diagonal"], st["angle_entropy"])
if ent > 0.93 and max(v, h) < 0.45:
return "curved"
if d > max(v, h) * 1.15:
return "diagonal"
if v > h * 1.25:
return "vertical"
if h > v / 0.92:
return "horizontal"
return "mixed"
def saturation_label(x: float) -> str:
if x < 0.42:
return "very muted"
if x < 0.58:
return "muted"
if x < 0.74:
return "moderate"
return "strong"
# --------------------------------------------------------------------------
# Models
# --------------------------------------------------------------------------
def get_model():
global _model, _proc
if _model is None:
from transformers import AutoModel, AutoProcessor
_model = AutoModel.from_pretrained(MODEL_ID).eval()
_proc = AutoProcessor.from_pretrained(MODEL_ID)
return _model, _proc
def _as_tensor(x):
if torch.is_tensor(x):
return x
for a in ("image_embeds", "pooler_output", "last_hidden_state"):
v = getattr(x, a, None)
if torch.is_tensor(v):
return v.mean(1) if v.dim() == 3 else v
raise TypeError(type(x))
@spaces.GPU(duration=45)
def embed_image(img: Image.Image) -> np.ndarray:
model, proc = get_model()
device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)
with torch.no_grad():
px = proc(images=[img.convert("RGB")],
return_tensors="pt")["pixel_values"].to(device)
v = _as_tensor(model.get_image_features(pixel_values=px)).float()
v = v / v.norm(dim=-1, keepdim=True)
return v[0].cpu().numpy()
def get_llm():
global _llm, _tok
if _llm is None:
from transformers import AutoTokenizer, AutoModelForCausalLM
_tok = AutoTokenizer.from_pretrained(LLM_ID)
_llm = AutoModelForCausalLM.from_pretrained(
LLM_ID, torch_dtype=torch.float16).eval()
return _llm, _tok
@spaces.GPU(duration=90)
def write_reading(prompt: str) -> str:
"""Generate the reading for this building, at query time."""
llm, tok = get_llm()
device = "cuda" if torch.cuda.is_available() else "cpu"
llm = llm.to(device)
msgs = [{"role": "user", "content": prompt}]
text = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
ids = tok(text, return_tensors="pt").to(device)
with torch.no_grad():
out = llm.generate(**ids, max_new_tokens=170, do_sample=True,
temperature=0.7, top_p=0.9,
pad_token_id=tok.eos_token_id)
return tok.decode(out[0][ids.input_ids.shape[1]:],
skip_special_tokens=True).strip()
def build_reading_prompt(top, runner, stats: dict, conf: float) -> str:
"""Instruction for the reading.
Deliberately constrained: the model works only from measurements and the
style table, and is barred from naming architects or buildings, from
asserting a date or heritage status, and from inventing features. The
system has no basis for any of those claims.
"""
return (
"You are writing a short note for someone standing in front of a "
"building, holding their phone. In 3-4 sentences, plain and direct:\n"
"1. What to look at on this building that points to the style.\n"
"2. One feature that would confirm it, and one that would rule it out "
"in favour of the runner-up style.\n\n"
"Rules: do not name any real architect, building or landmark. Do not "
"state when this building was built, who designed it, or whether it is "
"protected — you cannot know any of that. Do not invent features that "
"are not listed below. Write for a curious non-specialist.\n\n"
f"Best match: {top['style_name']} ({top['period']}), confidence {conf:.0%}\n"
f"Its hallmarks: {top['key_features']}\n"
f"Its massing: {top['massing']}; material: {top['primary_material']}; "
f"windows: {top['window_rhythm']}; roofline: {top['roofline']}\n\n"
f"Runner-up style: {runner['style_name']} ({runner['period']})\n"
f"Its hallmarks: {runner['key_features']}\n\n"
"Measured from the photograph:\n"
f"- dominant edge direction: {orientation_label(stats)}\n"
f"- colour saturation: {saturation_label(stats['saturation'])}\n"
f"- curvature in the linework: "
f"{'high' if stats['angle_entropy'] > 0.93 else 'low'}\n"
)
# --------------------------------------------------------------------------
# OpenStreetMap
# --------------------------------------------------------------------------
# `start_date` alone is too sparse to be useful — most buildings lack it even
# in well-mapped cities, which is why an earlier version reported "no dated
# buildings" in the middle of Tel Aviv's White City. Querying several
# notable-building tags at once yields both an era prior and buildings that
# can actually be named and visited.
OVERPASS_ENDPOINTS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
]
def _haversine_m(lat1, lon1, lat2, lon2):
r = 6371000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp, dl = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def geocode(place: str):
"""Resolve a place name to coordinates via Nominatim. None on failure."""
if not place or not place.strip():
return None
url = ("https://nominatim.openstreetmap.org/search?"
+ urllib.parse.urlencode({"q": place.strip(), "format": "json",
"limit": 1}))
try:
req = urllib.request.Request(
url, headers={"User-Agent": "facade-app/1.0 (coursework)"})
with urllib.request.urlopen(req, timeout=15) as r:
hits = json.load(r)
if hits:
return (float(hits[0]["lat"]), float(hits[0]["lon"]),
hits[0].get("display_name", ""))
except Exception:
pass
return None
def query_osm(lat: float, lon: float, radius_m: int = 3000) -> pd.DataFrame:
"""Notable buildings near a point. Empty frame on any failure."""
filters = ["start_date", "building:architecture", "heritage", "historic"]
parts = []
for kind in ("way", "relation"):
for f in filters:
parts.append(f'{kind}["building"]["{f}"](around:{radius_m},{lat},{lon});')
q = f"[out:json][timeout:25];({''.join(parts)});out center tags 250;"
data = None
for endpoint in OVERPASS_ENDPOINTS:
try:
req = urllib.request.Request(
endpoint, data=urllib.parse.urlencode({"data": q}).encode(),
headers={"User-Agent": "facade-app/1.0"})
with urllib.request.urlopen(req, timeout=25) as r:
data = json.load(r)
break
except Exception:
continue
if data is None:
return pd.DataFrame()
rows = []
for el in data.get("elements", []):
t = el.get("tags", {})
c = el.get("center") or {}
elat, elon = c.get("lat"), c.get("lon")
d = str(t.get("start_date", ""))[:4]
rows.append({
"name": t.get("name") or t.get("name:en"),
"year": int(d) if d.isdigit() else None,
"architecture": t.get("building:architecture"),
"heritage": t.get("heritage"),
"historic": t.get("historic"),
"osm_id": f"{el.get('type')}/{el.get('id')}",
"dist_m": (_haversine_m(lat, lon, elat, elon)
if elat and elon else None),
})
return pd.DataFrame(rows)
def era_prior(style_ids, osm: pd.DataFrame, tolerance: int = 40) -> np.ndarray:
"""Soft prior over styles from nearby dates and style tags.
Soft on purpose: a genuinely unusual building should still be findable, so
this reranks rather than filters.
"""
if osm.empty:
return np.zeros(len(style_ids))
styles = load_index()["styles"]
years = osm.year.dropna().astype(int).tolist()
arch = " ".join(osm.architecture.dropna().astype(str)).lower()
out = []
for sid in style_ids:
score = 0.0
try:
start = int(str(styles.loc[sid, "period"]).split("-")[0])
score += sum(1 for y in years if abs(y - start) <= tolerance)
except (ValueError, KeyError):
pass
# Direct tag agreement is worth far more than era coincidence.
for token in str(styles.loc[sid, "style_name"]).lower().split():
if len(token) > 4 and token in arch:
score += 8
out.append(score)
a = np.array(out, dtype=float)
return a / (a.max() or 1.0)
def nearby_in_style(style_id: str, osm: pd.DataFrame, limit: int = 4):
if osm.empty:
return []
styles = load_index()["styles"]
try:
span = str(styles.loc[style_id, "period"]).split("-")
start, end = int(span[0]), int(span[1])
except (ValueError, IndexError, KeyError):
start, end = 0, 3000
tokens = [t for t in str(styles.loc[style_id, "style_name"]).lower().split()
if len(t) > 4]
cand = osm[osm.name.notna()]
if cand.empty:
return []
keep = []
for _, r in cand.iterrows():
arch = str(r.architecture or "").lower()
tag_hit = any(t in arch for t in tokens)
era_hit = (r.year is not None and pd.notna(r.year)
and (start - 30) <= int(r.year) <= (end + 30))
if tag_hit or era_hit:
keep.append({**r.to_dict(), "tag_hit": tag_hit})
if not keep:
return []
return (pd.DataFrame(keep)
.sort_values(["tag_hit", "dist_m"], ascending=[False, True])
.head(limit).to_dict("records"))
# --------------------------------------------------------------------------
# Rendering
# --------------------------------------------------------------------------
def plate_url(plate_id: str) -> str:
return (f"https://huggingface.co/datasets/{DATASET_REPO}/resolve/main/"
f"plates/{plate_id}.png")
def _img_data_uri(img: Image.Image, max_side: int = 720) -> str:
im = img.convert("RGB").copy()
im.thumbnail((max_side, max_side))
buf = io.BytesIO()
im.save(buf, format="JPEG", quality=88)
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
def _dimension_line(pct: float) -> str:
"""Confidence as a measured dimension, not a progress bar.
Architectural drawings annotate a length with witness lines, arrowheads
and a figure. Reusing that convention keeps the interface inside the
subject's own vernacular rather than importing a dashboard idiom.
"""
w = max(4.0, min(100.0, pct * 100))
return f"""
"""
def _esc(s) -> str:
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
SHEET_STYLE = """"""
EMPTY_HTML = SHEET_STYLE + """
No drawing loaded
Add a photograph of a building elevation. Include the whole facade where
you can — roofline and massing carry most of the style.
"""
def identify(image, use_location: bool, lat: float, lon: float,
rerank_weight: float, live_text: bool):
"""Entry point. Never raises — a live demo should explain a failure in
place rather than surface an opaque toast."""
try:
return _identify(image, use_location, lat, lon, rerank_weight, live_text)
except Exception as exc:
import traceback
return SHEET_STYLE + f"""
Survey could not be completed
{_esc(type(exc).__name__)}
{_esc(exc)}
{_esc(traceback.format_exc()[-1600:])}
"""
def _identify(image, use_location, lat, lon, rerank_weight, live_text):
if image is None:
return EMPTY_HTML
s = load_index()
q = embed_image(image)
scores = s["E"] @ q
stats = image_stats(image)
osm = pd.DataFrame()
survey_note = "Location survey off."
if use_location:
osm = query_osm(lat, lon)
if osm.empty:
osm = query_osm(lat, lon, radius_m=8000) # sparse area — widen once
if osm.empty:
survey_note = ("Survey returned nothing. OpenStreetMap has no dated "
"or tagged buildings within 8 km, so ranking is visual only.")
else:
scores = scores + rerank_weight * era_prior(s["style_of"], osm)
dated = int(osm.year.notna().sum())
survey_note = (f"{len(osm)} tagged buildings nearby, {dated} with "
f"construction dates. Prior weight {rerank_weight:.2f}.")
best = {}
for i, sid in enumerate(s["style_of"]):
if sid not in best or scores[i] > best[sid][0]:
best[sid] = (float(scores[i]), i)
ranked = sorted(best.items(), key=lambda kv: -kv[1][0])[:TOP_K]
exp = np.exp(np.array([r[1][0] for r in ranked]) * 12)
conf = exp / exp.sum()
top_sid, (top_score, top_idx) = ranked[0]
top = s["styles"].loc[top_sid]
runner = s["styles"].loc[ranked[1][0]]
top_plate = s["plate_ids"][top_idx]
html = [SHEET_STYLE + f"""
A Your photograph
{conf[0]:.0%}match
B Closest reference plate
Most likely style
{_esc(top['style_name'])}
{_esc(top['period'])}
{_esc(top['key_features'])}
"""]
# --- generated reading ------------------------------------------------
reading, gen_label = None, ""
if live_text:
try:
reading = write_reading(build_reading_prompt(top, runner, stats, conf[0]))
gen_label = "written for this photograph just now"
except Exception:
reading = None
if not reading:
man = s["manifest"]
if "reading" in man.columns and pd.notna(man.loc[top_plate].get("reading")):
reading = str(man.loc[top_plate]["reading"])
gen_label = "from the reference corpus"
if reading:
html.append(f"""
""")
# --- nearby -----------------------------------------------------------
near = nearby_in_style(top_sid, osm) if use_location else []
if near:
def _meta(n):
# pandas yields NaN for missing values, and NaN is truthy — a plain
# truthiness check here passed straight into int() and crashed.
bits = []
y, d = n.get("year"), n.get("dist_m")
if y is not None and pd.notna(y):
bits.append(str(int(y)))
if d is not None and pd.notna(d):
bits.append(f"{int(d)} m away")
return " · ".join(bits)
items = "".join(
"
Text{_esc(LLM_ID.split('/')[-1] if live_text else 'corpus reading')}
Survey{_esc(survey_note)}
Visual-similarity search over a synthetic reference
corpus. Stylistic suggestion only — no claim about this building's
architect, date, or heritage status.
""")
return "".join(html)
# --------------------------------------------------------------------------
# Style catalogue
# --------------------------------------------------------------------------
def build_catalogue() -> str:
"""Every style the index can return, with an example plate.
Worth showing plainly: a classifier that silently maps everything onto
twenty classes should say what those twenty classes are.
"""
try:
s = load_index()
styles = s["styles"]
except Exception as exc:
import traceback
# Swallowing this silently rendered an invisible panel and looked like
# the section had simply not been built.
return (f"
")
first = {}
for pid, sid in zip(s["plate_ids"], s["style_of"]):
first.setdefault(sid, pid)
cards = []
for sid, row in styles.iterrows():
pid = first.get(sid)
if pid is None:
continue
cards.append(f"""
Twenty styles, fifty generated plates each. A photograph
is matched against all thousand — so anything outside these twenty will still
be forced onto the nearest of them, which is worth knowing before you trust a
result. Plates are generic facades in a style; none depicts a real building.
Elevation survey · style identification · 1000-plate reference corpus
""")
with gr.Row():
with gr.Column(scale=5, elem_id="controls"):
img = gr.Image(type="pil", label="Elevation photograph", height=300)
gr.HTML("""
Include the whole building where you can.
Style lives in massing, roofline and silhouette — a cropped window
grid discards all three.
""")
live_text = gr.Checkbox(
label="Write a reading for this building (slower)", value=True)
use_loc = gr.Checkbox(label="Survey my surroundings", value=True)
place = gr.Textbox(label="Where are you?",
placeholder="Rothschild Boulevard, Tel Aviv", lines=1)
with gr.Row():
find = gr.Button("Find on map", size="sm")
here = gr.Button("Use my device location", size="sm")
place_note = gr.Markdown("", elem_id="place_note")
with gr.Row():
lat = gr.Number(label="Latitude", value=32.0771, precision=4)
lon = gr.Number(label="Longitude", value=34.7745, precision=4)
weight = gr.Slider(0.0, 0.6, value=0.25, step=0.05,
label="Weight given to the local building record")
go = gr.Button("Identify", variant="primary")
with gr.Column(scale=7, elem_id="result_col"):
out = gr.HTML(EMPTY_HTML)
with gr.Accordion("The 20 styles this can identify", open=False,
elem_id="catalogue_bar"):
catalogue_top = gr.HTML()
def do_geocode(q):
hit = geocode(q)
if not hit:
return gr.update(), gr.update(), "Could not find that place. Try adding a city."
la, lo, label = hit
return la, lo, f"Found **{label}**"
find.click(do_geocode, place, [lat, lon, place_note])
place.submit(do_geocode, place, [lat, lon, place_note])
# Browser geolocation. Runs client-side and returns straight into the
# coordinate fields; no server round-trip and nothing stored.
here.click(
fn=None, inputs=None, outputs=[lat, lon],
js="""() => new Promise((resolve) => {
if (!navigator.geolocation) { resolve([null, null]); return; }
navigator.geolocation.getCurrentPosition(
p => resolve([+p.coords.latitude.toFixed(4),
+p.coords.longitude.toFixed(4)]),
() => resolve([null, null]),
{timeout: 8000}
);
})""",
)
go.click(identify, [img, use_loc, lat, lon, weight, live_text], out)
# Filled on load. It sits inside a collapsed accordion, so building it
# eagerly costs one row of height and nothing is hidden behind an event
# that might not fire.
demo.load(build_catalogue, None, catalogue_top)
if __name__ == "__main__":
demo.launch()