Spaces:
Running on Zero
Running on Zero
| """ | |
| 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: | |
| 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)) | |
| 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 | |
| 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""" | |
| <svg class="dim" viewBox="0 0 100 12" preserveAspectRatio="none" aria-hidden="true"> | |
| <line x1="0.6" y1="1" x2="0.6" y2="11" class="dim-witness"/> | |
| <line x1="{w - 0.6:.1f}" y1="1" x2="{w - 0.6:.1f}" y2="11" class="dim-witness"/> | |
| <line x1="0.6" y1="6" x2="{w - 0.6:.1f}" y2="6" class="dim-run"/> | |
| <polygon points="0.6,6 4,4.2 4,7.8" class="dim-head"/> | |
| <polygon points="{w - 0.6:.1f},6 {w - 4:.1f},4.2 {w - 4:.1f},7.8" class="dim-head"/> | |
| </svg>""" | |
| def _esc(s) -> str: | |
| return str(s).replace("&", "&").replace("<", "<").replace(">", ">") | |
| SHEET_STYLE = """<style> | |
| .facade-sheet, .facade-sheet * { color: #E6F0F6 !important; } | |
| .facade-sheet { | |
| border: 1px solid #8FB6D0; background: #12324A; padding: 20px; | |
| font-family: 'IBM Plex Sans', system-ui, sans-serif; | |
| } | |
| .facade-sheet .eyebrow, .facade-sheet .alt-meta, .facade-sheet .alt-pct, | |
| .facade-sheet .near-meta, .facade-sheet .titleblock, | |
| .facade-sheet .titleblock div, .facade-sheet .cut-label, | |
| .facade-sheet .empty-near p, .facade-sheet .evidence td:first-child { | |
| color: #8FB6D0 !important; | |
| } | |
| .facade-sheet .period, .facade-sheet .cut-figure, .facade-sheet .tick, | |
| .facade-sheet .err-title, .facade-sheet .titleblock span, | |
| .facade-sheet .genlabel { color: #E0574B !important; } | |
| .facade-sheet h2 { color: #E6F0F6 !important; } | |
| .facade-sheet a { color: #E6F0F6 !important; border-bottom: 1px solid #E0574B; text-decoration: none; } | |
| .facade-sheet a:hover, .facade-sheet a:focus { color: #E0574B !important; } | |
| .facade-sheet .compare img { | |
| width: 100%; aspect-ratio: 1/1; object-fit: cover; | |
| border: 1px solid #8FB6D0; display: block; | |
| } | |
| .facade-sheet .compare { display: grid; grid-template-columns: 1fr 74px 1fr; align-items: center; } | |
| .facade-sheet .mapframe { width: 100%; height: 260px; border: 1px solid #8FB6D0; display: block; } | |
| .facade-sheet .evidence { width: 100%; border-collapse: collapse; font-family: 'IBM Plex Mono', monospace; font-size: .7rem; } | |
| .facade-sheet .evidence td { padding: 5px 0; border-bottom: 1px solid rgba(143,182,208,.22); } | |
| .facade-sheet .evidence td:last-child { text-align: right; } | |
| </style>""" | |
| EMPTY_HTML = SHEET_STYLE + """ | |
| <div class="sheet facade-sheet empty"> | |
| <div class="eyebrow">No drawing loaded</div> | |
| <p>Add a photograph of a building elevation. Include the whole facade where | |
| you can — roofline and massing carry most of the style.</p> | |
| </div>""" | |
| 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""" | |
| <div class="sheet facade-sheet"> | |
| <div class="eyebrow">Survey could not be completed</div> | |
| <h2 class="err-title">{_esc(type(exc).__name__)}</h2> | |
| <p class="err-msg">{_esc(exc)}</p> | |
| <pre class="err-trace">{_esc(traceback.format_exc()[-1600:])}</pre> | |
| </div>""" | |
| 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""" | |
| <div class="sheet facade-sheet"> | |
| <div class="compare"> | |
| <figure> | |
| <img src="{_img_data_uri(image)}" alt="Your photograph"/> | |
| <figcaption><span class="tick">A</span> Your photograph</figcaption> | |
| </figure> | |
| <div class="cut"> | |
| <span class="cut-figure">{conf[0]:.0%}</span> | |
| <span class="cut-label">match</span> | |
| </div> | |
| <figure> | |
| <img src="{plate_url(top_plate)}" alt="Closest reference plate"/> | |
| <figcaption><span class="tick">B</span> Closest reference plate</figcaption> | |
| </figure> | |
| </div> | |
| <div class="verdict"> | |
| <div class="eyebrow">Most likely style</div> | |
| <h2>{_esc(top['style_name'])}</h2> | |
| <div class="period">{_esc(top['period'])}</div> | |
| <p class="marks">{_esc(top['key_features'])}</p> | |
| </div>"""] | |
| # --- 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""" | |
| <div class="reading"> | |
| <div class="eyebrow">What you are looking at | |
| <span class="genlabel">· {_esc(gen_label)}</span></div> | |
| <p>{_esc(reading)}</p> | |
| </div>""") | |
| # --- measured evidence ------------------------------------------------ | |
| html.append(f""" | |
| <div class="evidence-block"> | |
| <div class="eyebrow">Measured from your photograph</div> | |
| <table class="evidence"> | |
| <tr><td>dominant edge direction</td><td>{orientation_label(stats)}</td></tr> | |
| <tr><td>expected for this style</td><td>{_esc(top['expected_edge_orientation'])}</td></tr> | |
| <tr><td>colour saturation</td><td>{saturation_label(stats['saturation'])} ({stats['saturation']:.2f})</td></tr> | |
| <tr><td>expected for this style</td><td>{_esc(top['expected_saturation']).replace('_', ' ')}</td></tr> | |
| <tr><td>curvature in linework</td><td>{stats['angle_entropy']:.2f}</td></tr> | |
| </table> | |
| </div>""") | |
| # --- alternates ------------------------------------------------------- | |
| alts = [] | |
| for (sid, (score, idx)), c in list(zip(ranked, conf))[1:]: | |
| row = s["styles"].loc[sid] | |
| alts.append(f""" | |
| <li> | |
| <div class="alt-head"> | |
| <span class="alt-name">{_esc(row['style_name'])}</span> | |
| <span class="alt-pct">{c:.0%}</span> | |
| </div> | |
| {_dimension_line(c)} | |
| <div class="alt-meta">{_esc(row['period'])} · {_esc(row['key_features'])}</div> | |
| </li>""") | |
| if alts: | |
| html.append(f""" | |
| <div class="alternates"> | |
| <div class="eyebrow">Also considered</div> | |
| <ul>{''.join(alts)}</ul> | |
| </div>""") | |
| # --- 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( | |
| "<li><a href='https://www.openstreetmap.org/{oid}' target='_blank' " | |
| "rel='noopener'>{name}</a><span class='near-meta'>{meta}</span></li>".format( | |
| oid=_esc(n["osm_id"]), name=_esc(n["name"]), meta=_esc(_meta(n))) | |
| for n in near) | |
| html.append(f""" | |
| <div class="nearby"> | |
| <div class="eyebrow">Go and see one</div> | |
| <ul>{items}</ul> | |
| </div>""") | |
| elif use_location: | |
| html.append(""" | |
| <div class="nearby empty-near"> | |
| <div class="eyebrow">Nothing to visit nearby</div> | |
| <p>No named building within range matches this period or carries a style | |
| tag in OpenStreetMap.</p> | |
| </div>""") | |
| # --- map -------------------------------------------------------------- | |
| if use_location: | |
| d = 0.012 | |
| html.append(f""" | |
| <div class="mapblock"> | |
| <div class="eyebrow">Survey area</div> | |
| <iframe class="mapframe" loading="lazy" title="Survey area" | |
| src="https://www.openstreetmap.org/export/embed.html?bbox={lon - d:.4f}%2C{lat - d:.4f}%2C{lon + d:.4f}%2C{lat + d:.4f}&layer=mapnik&marker={lat:.5f}%2C{lon:.5f}"></iframe> | |
| </div>""") | |
| html.append(f""" | |
| <div class="titleblock"> | |
| <div><span>Index</span>{len(s['plate_ids'])} plates · {len(set(s['style_of']))} styles</div> | |
| <div><span>Vision</span>{_esc(MODEL_ID.split('/')[-1])}</div> | |
| <div><span>Text</span>{_esc(LLM_ID.split('/')[-1] if live_text else 'corpus reading')}</div> | |
| <div><span>Survey</span>{_esc(survey_note)}</div> | |
| <div class="disclaimer">Visual-similarity search over a synthetic reference | |
| corpus. Stylistic suggestion only — no claim about this building's | |
| architect, date, or heritage status.</div> | |
| </div> | |
| </div>""") | |
| 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"<div style='color:#E0574B;font-family:monospace;font-size:.75rem;" | |
| f"border:1px solid #E0574B;padding:12px;margin-top:24px'>" | |
| f"Catalogue unavailable: {_esc(type(exc).__name__)}: {_esc(exc)}" | |
| f"<pre style='color:#8FB6D0;white-space:pre-wrap'>" | |
| f"{_esc(traceback.format_exc()[-800:])}</pre></div>") | |
| 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""" | |
| <article class="cat-card"> | |
| <img src="{plate_url(pid)}" alt="{_esc(row['style_name'])} reference plate" loading="lazy"/> | |
| <h3>{_esc(row['style_name'])}</h3> | |
| <div class="cat-period">{_esc(row['period'])}</div> | |
| <p class="cat-marks">{_esc(row['key_features'])}</p> | |
| <p class="cat-meta">{_esc(row['massing'])} · {_esc(row['primary_material'])}</p> | |
| </article>""") | |
| return f"""<style> | |
| .cat-wrap, .cat-wrap * {{ color: #E6F0F6 !important; font-family: 'IBM Plex Sans', system-ui, sans-serif; }} | |
| .cat-wrap {{ max-height: 72vh; overflow-y: auto; padding: 4px 8px 4px 0; }} | |
| .cat-wrap::-webkit-scrollbar {{ width: 9px; }} | |
| .cat-wrap::-webkit-scrollbar-track {{ background: rgba(11,31,47,.6); }} | |
| .cat-wrap::-webkit-scrollbar-thumb {{ background: rgba(143,182,208,.45); border-radius: 4px; }} | |
| .cat-head {{ font-family: 'IBM Plex Mono', monospace; font-size: .66rem; letter-spacing: .2em; | |
| text-transform: uppercase; color: #8FB6D0 !important; margin-bottom: 4px; }} | |
| .cat-intro {{ font-size: .9rem; color: #A9C9DF !important; max-width: 62ch; line-height: 1.65; margin: 0 0 20px; }} | |
| .cat-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: 18px; }} | |
| .cat-card {{ border: 1px solid rgba(143,182,208,.5); background: rgba(18,50,74,.55); padding: 12px; }} | |
| .cat-card img {{ width: 100%; aspect-ratio: 1/1; object-fit: cover; border: 1px solid rgba(143,182,208,.5); display: block; }} | |
| .cat-card h3 {{ font-family: 'Archivo Narrow', sans-serif; font-size: 1.05rem; font-weight: 600; | |
| margin: 11px 0 1px; color: #E6F0F6 !important; }} | |
| .cat-period {{ font-family: 'IBM Plex Mono', monospace; font-size: .68rem; color: #E0574B !important; }} | |
| .cat-marks {{ font-size: .8rem; line-height: 1.55; margin: 8px 0 0; color: #E6F0F6 !important; }} | |
| .cat-meta {{ font-family: 'IBM Plex Mono', monospace; font-size: .64rem; line-height: 1.5; | |
| color: #8FB6D0 !important; margin: 7px 0 0; }} | |
| </style> | |
| <div class="cat-wrap"> | |
| <div class="cat-head">Reference corpus · what this can identify</div> | |
| <p class="cat-intro">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.</p> | |
| <div class="cat-grid">{''.join(cards)}</div> | |
| </div>""" | |
| # -------------------------------------------------------------------------- | |
| # Interface | |
| # -------------------------------------------------------------------------- | |
| CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Archivo+Narrow:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&display=swap'); | |
| :root { | |
| --ink: #0B1F2F; | |
| --panel: #12324A; | |
| --line: #8FB6D0; | |
| --paper: #E6F0F6; | |
| --redline: #E0574B; | |
| --grid: rgba(143,182,208,.13); | |
| } | |
| .gradio-container, .gradio-container * { font-family: 'IBM Plex Sans', system-ui, sans-serif; } | |
| /* Background and centring only. Nothing here touches Gradio's own scroll | |
| container: overriding overflow on those wrappers previously deleted the | |
| scrollbar outright. */ | |
| body, gradio-app { background: var(--ink) !important; } | |
| /* No height rule on html/body: pinning the document to the viewport stops the | |
| page scrolling once the results panel grows past it. Centring needs only | |
| width and auto margins. */ | |
| .gradio-container { | |
| max-width: 1680px !important; | |
| width: 100% !important; | |
| margin: 0 auto !important; | |
| padding: 22px 28px 72px !important; | |
| background: | |
| linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 32px, | |
| linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 32px 100%, | |
| var(--ink) !important; | |
| color: var(--paper) !important; | |
| } | |
| #masthead { border-bottom: 1px solid var(--line); padding: 6px 0 12px; margin-bottom: 18px; } | |
| #masthead h1 { | |
| font-family: 'Archivo Narrow', sans-serif; font-weight: 700; | |
| font-size: 2.6rem; letter-spacing: .16em; text-transform: uppercase; | |
| margin: 0; color: var(--paper); | |
| } | |
| #masthead .sub { | |
| font-family: 'IBM Plex Mono', monospace; font-size: .74rem; | |
| letter-spacing: .18em; text-transform: uppercase; color: var(--line); margin-top: 4px; | |
| } | |
| .eyebrow { | |
| font-family: 'IBM Plex Mono', monospace; font-size: .66rem; | |
| letter-spacing: .2em; text-transform: uppercase; color: var(--line); margin-bottom: 8px; | |
| } | |
| .genlabel { letter-spacing: .1em; text-transform: none; } | |
| #controls { border: 1px solid var(--line); padding: 16px; background: rgba(18,50,74,.55); } | |
| #controls label, #controls span, #controls .prose { color: var(--paper) !important; } | |
| #hint { | |
| font-family: 'IBM Plex Mono', monospace; font-size: .72rem; line-height: 1.6; | |
| color: var(--line); border-left: 2px solid var(--redline); padding-left: 10px; margin: 10px 0; | |
| } | |
| .sheet { | |
| border: 1px solid var(--line); background: rgba(18,50,74,.55); | |
| padding: 20px; color: var(--paper); margin-bottom: 28px; | |
| } | |
| .sheet.empty { color: var(--line); } | |
| .sheet.empty p { font-family: 'IBM Plex Mono', monospace; font-size: .8rem; line-height: 1.7; } | |
| .compare { display: grid; grid-template-columns: 1fr 74px 1fr; align-items: center; } | |
| .compare figure { margin: 0; } | |
| .compare img { width: 100%; aspect-ratio: 1/1; object-fit: cover; border: 1px solid var(--line); display: block; } | |
| .compare figcaption { | |
| font-family: 'IBM Plex Mono', monospace; font-size: .64rem; | |
| letter-spacing: .14em; text-transform: uppercase; color: var(--line); | |
| margin-top: 7px; display: flex; align-items: center; gap: 7px; | |
| } | |
| .tick { | |
| display: inline-flex; align-items: center; justify-content: center; | |
| width: 17px; height: 17px; border: 1px solid var(--redline); | |
| border-radius: 50%; color: var(--redline); font-size: .6rem; | |
| } | |
| .cut { display: flex; flex-direction: column; align-items: center; gap: 2px; position: relative; } | |
| .cut::before, .cut::after { | |
| content: ""; position: absolute; left: 50%; width: 1px; | |
| background: repeating-linear-gradient(var(--redline) 0 5px, transparent 5px 10px); | |
| } | |
| .cut::before { top: 0; height: calc(50% - 26px); } | |
| .cut::after { bottom: 0; height: calc(50% - 26px); } | |
| .cut-figure { font-family: 'Archivo Narrow', sans-serif; font-size: 1.35rem; font-weight: 700; color: var(--redline); } | |
| .cut-label { font-family: 'IBM Plex Mono', monospace; font-size: .58rem; letter-spacing: .16em; text-transform: uppercase; color: var(--line); } | |
| .verdict { margin-top: 26px; border-top: 1px solid var(--line); padding-top: 16px; } | |
| .verdict h2 { font-family: 'Archivo Narrow', sans-serif; font-weight: 700; font-size: 2rem; letter-spacing: .04em; margin: 0; } | |
| .verdict .period { font-family: 'IBM Plex Mono', monospace; font-size: .78rem; color: var(--redline); margin-top: 2px; } | |
| .verdict .marks { font-size: .92rem; line-height: 1.6; margin: 10px 0 0; } | |
| .reading { margin-top: 22px; border-left: 2px solid var(--line); padding-left: 14px; } | |
| .reading p { font-size: .95rem; line-height: 1.7; margin: 0; } | |
| .evidence-block { margin-top: 24px; } | |
| .alternates { margin-top: 24px; } | |
| .alternates ul { list-style: none; padding: 0; margin: 0; } | |
| .alternates li { padding: 11px 0; border-top: 1px solid rgba(143,182,208,.28); } | |
| .alt-head { display: flex; justify-content: space-between; align-items: baseline; } | |
| .alt-name { font-family: 'Archivo Narrow', sans-serif; font-size: 1.1rem; font-weight: 600; } | |
| .alt-pct { font-family: 'IBM Plex Mono', monospace; font-size: .82rem; color: var(--line); } | |
| .alt-meta { font-family: 'IBM Plex Mono', monospace; font-size: .68rem; color: var(--line); line-height: 1.55; margin-top: 3px; } | |
| svg.dim { width: 100%; height: 12px; margin: 5px 0 2px; display: block; } | |
| .dim-witness, .dim-run { stroke: var(--line); stroke-width: .35; vector-effect: non-scaling-stroke; } | |
| .dim-head { fill: var(--line); } | |
| .mapblock { margin-top: 24px; } | |
| .mapframe { width: 100%; height: 260px; border: 1px solid var(--line); display: block; } | |
| .nearby { margin-top: 24px; border-top: 1px solid var(--line); padding-top: 14px; } | |
| .nearby ul { list-style: none; padding: 0; margin: 0; } | |
| .nearby li { padding: 7px 0; display: flex; justify-content: space-between; gap: 14px; flex-wrap: wrap; } | |
| .nearby a { color: var(--paper); text-decoration: none; border-bottom: 1px solid var(--redline); } | |
| .near-meta { font-family: 'IBM Plex Mono', monospace; font-size: .7rem; color: var(--line); } | |
| .empty-near p { font-family: 'IBM Plex Mono', monospace; font-size: .74rem; color: var(--line); } | |
| .titleblock { | |
| margin-top: 26px; border: 1px solid var(--line); border-left: 3px solid var(--redline); | |
| padding: 12px 14px; font-family: 'IBM Plex Mono', monospace; font-size: .68rem; | |
| color: var(--line); line-height: 1.75; | |
| } | |
| .titleblock span { display: inline-block; min-width: 74px; letter-spacing: .14em; text-transform: uppercase; color: var(--paper); } | |
| .titleblock .disclaimer { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(143,182,208,.3); } | |
| /* Gradio's own widgets default light; bring them onto the sheet. */ | |
| #controls .block, #controls .form, #controls .wrap, | |
| #controls input:not([type="checkbox"]), #controls textarea, | |
| #controls .image-container, #controls [data-testid="block-label"] { | |
| background: rgba(11,31,47,.72) !important; | |
| border-color: rgba(143,182,208,.45) !important; | |
| color: var(--paper) !important; | |
| } | |
| #controls input[type="number"], #controls input[type="text"] { | |
| font-family: 'IBM Plex Mono', monospace !important; color: var(--paper) !important; | |
| } | |
| #controls .image-frame, #controls .upload-container { background: rgba(11,31,47,.72) !important; } | |
| #controls label span, #controls .head, #controls span[data-testid] { color: var(--line) !important; } | |
| #controls .head svg, #controls .icon svg { color: var(--line) !important; } | |
| #controls input[type="checkbox"] { | |
| appearance: none; -webkit-appearance: none; | |
| width: 18px; height: 18px; min-width: 18px; | |
| border: 1px solid var(--line) !important; | |
| background: rgba(11,31,47,.85) !important; | |
| border-radius: 2px; cursor: pointer; position: relative; | |
| display: inline-block; vertical-align: middle; | |
| } | |
| #controls input[type="checkbox"]:checked { background: var(--redline) !important; border-color: var(--redline) !important; } | |
| #controls input[type="checkbox"]:checked::after { | |
| content: ""; position: absolute; left: 5px; top: 1px; | |
| width: 5px; height: 10px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); | |
| } | |
| #controls input[type="checkbox"]:focus-visible { outline: 2px solid var(--redline); outline-offset: 2px; } | |
| #place_note, #place_note *, #place_note p, #place_note strong { | |
| color: #A9C9DF !important; font-family: 'IBM Plex Mono', monospace !important; | |
| font-size: .74rem !important; line-height: 1.6 !important; margin: 4px 0 !important; | |
| } | |
| #place_note strong { color: var(--paper) !important; } | |
| #controls button.secondary, #controls button.sm, #controls .form button { | |
| background: rgba(11,31,47,.85) !important; | |
| border: 1px solid rgba(143,182,208,.55) !important; | |
| color: var(--paper) !important; | |
| font-family: 'IBM Plex Mono', monospace !important; | |
| font-size: .7rem !important; letter-spacing: .1em !important; text-transform: uppercase !important; | |
| } | |
| #controls button.secondary:hover, #controls .form button:hover { | |
| border-color: var(--redline) !important; color: var(--redline) !important; | |
| } | |
| #controls textarea { color: var(--paper) !important; } | |
| #controls textarea::placeholder { color: rgba(143,182,208,.7) !important; } | |
| .err-title { font-family: 'Archivo Narrow', sans-serif; color: var(--redline); font-size: 1.4rem; margin: 0 0 6px; } | |
| .err-msg { font-family: 'IBM Plex Mono', monospace; font-size: .82rem; color: var(--paper); } | |
| .err-trace { | |
| font-family: 'IBM Plex Mono', monospace; font-size: .64rem; line-height: 1.5; | |
| color: var(--paper); background: rgba(11,31,47,.9); border: 1px solid rgba(143,182,208,.5); | |
| padding: 10px; overflow-x: auto; white-space: pre-wrap; margin-top: 10px; | |
| } | |
| button.primary { | |
| background: var(--redline) !important; border: none !important; color: #fff !important; | |
| font-family: 'IBM Plex Mono', monospace !important; letter-spacing: .18em !important; | |
| text-transform: uppercase !important; font-size: .78rem !important; | |
| } | |
| :focus-visible { outline: 2px solid var(--redline); outline-offset: 2px; } | |
| footer, .gradio-container footer { background: transparent !important; margin-top: 18px; } | |
| /* Gradio owns the page layout and its own scroll container. Earlier versions | |
| of this file overrode overflow/height/position on those wrapper elements to | |
| "fix" scrolling and deleted the scrollbar instead. Nothing here touches | |
| them: only colour, spacing and typography below this line. */ | |
| #catalogue_bar { margin-top: 26px; border: 1px solid var(--line) !important; background: rgba(18,50,74,.55) !important; } | |
| #catalogue_bar > button, #catalogue_bar .label-wrap, #catalogue_bar span { | |
| color: var(--paper) !important; | |
| font-family: 'IBM Plex Mono', monospace !important; | |
| font-size: .74rem !important; letter-spacing: .14em !important; | |
| text-transform: uppercase !important; | |
| } | |
| #catalogue_bar svg { color: var(--redline) !important; } | |
| @media (max-width: 1000px) { .gradio-container { padding: 16px 14px 56px !important; } } | |
| @media (max-width: 720px) { | |
| .compare { grid-template-columns: 1fr; gap: 16px; } | |
| .cut { flex-direction: row; gap: 8px; } | |
| .cut::before, .cut::after { display: none; } | |
| #masthead h1 { font-size: 1.9rem; } | |
| } | |
| @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } } | |
| """ | |
| with gr.Blocks(css=CSS, title="Facade — architectural style finder", | |
| theme=gr.themes.Base()) as demo: | |
| gr.HTML(""" | |
| <div id="masthead"> | |
| <h1>Facade</h1> | |
| <div class="sub">Elevation survey · style identification · 1000-plate reference corpus</div> | |
| </div>""") | |
| with gr.Row(): | |
| with gr.Column(scale=5, elem_id="controls"): | |
| img = gr.Image(type="pil", label="Elevation photograph", height=300) | |
| gr.HTML("""<div id="hint">Include the whole building where you can. | |
| Style lives in massing, roofline and silhouette — a cropped window | |
| grid discards all three.</div>""") | |
| 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() |