""" 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"""
Your photograph
A Your photograph
{conf[0]:.0%} match
Closest reference plate
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"""
What you are looking at · {_esc(gen_label)}

{_esc(reading)}

""") # --- measured evidence ------------------------------------------------ html.append(f"""
Measured from your photograph
dominant edge direction{orientation_label(stats)}
expected for this style{_esc(top['expected_edge_orientation'])}
colour saturation{saturation_label(stats['saturation'])} ({stats['saturation']:.2f})
expected for this style{_esc(top['expected_saturation']).replace('_', ' ')}
curvature in linework{stats['angle_entropy']:.2f}
""") # --- alternates ------------------------------------------------------- alts = [] for (sid, (score, idx)), c in list(zip(ranked, conf))[1:]: row = s["styles"].loc[sid] alts.append(f"""
  • {_esc(row['style_name'])} {c:.0%}
    {_dimension_line(c)}
    {_esc(row['period'])} · {_esc(row['key_features'])}
  • """) if alts: html.append(f"""
    Also considered
    """) # --- 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( "
  • {name}{meta}
  • ".format( oid=_esc(n["osm_id"]), name=_esc(n["name"]), meta=_esc(_meta(n))) for n in near) html.append(f"""
    Go and see one
    """) elif use_location: html.append("""
    Nothing to visit nearby

    No named building within range matches this period or carries a style tag in OpenStreetMap.

    """) # --- map -------------------------------------------------------------- if use_location: d = 0.012 html.append(f"""
    Survey area
    """) html.append(f"""
    Index{len(s['plate_ids'])} plates · {len(set(s['style_of']))} styles
    Vision{_esc(MODEL_ID.split('/')[-1])}
    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"
    " f"Catalogue unavailable: {_esc(type(exc).__name__)}: {_esc(exc)}" f"
    "
                    f"{_esc(traceback.format_exc()[-800:])}
    ") 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"""
    {_esc(row['style_name'])} reference plate

    {_esc(row['style_name'])}

    {_esc(row['period'])}

    {_esc(row['key_features'])}

    {_esc(row['massing'])} · {_esc(row['primary_material'])}

    """) return f"""
    Reference corpus · what this can identify

    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.

    {''.join(cards)}
    """ # -------------------------------------------------------------------------- # 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("""

    Facade

    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()