File size: 43,439 Bytes
151f324
 
 
 
814a198
 
151f324
 
 
 
814a198
 
 
 
 
 
151f324
 
 
 
c2266fb
151f324
 
c2266fb
151f324
 
 
 
 
 
c2266fb
 
 
 
151f324
 
c2266fb
 
151f324
 
c2266fb
151f324
 
 
 
 
 
 
 
 
c2266fb
 
814a198
151f324
 
 
 
814a198
 
151f324
 
 
 
c2266fb
151f324
 
 
 
 
814a198
c2266fb
 
814a198
c2266fb
 
 
 
151f324
 
 
 
814a198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151f324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814a198
151f324
 
 
 
 
 
 
 
 
 
 
 
814a198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151f324
c2266fb
151f324
c2266fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814a198
c2266fb
 
 
 
e0520a1
 
 
 
 
 
 
 
 
 
 
 
 
814a198
 
e0520a1
 
 
 
 
c2266fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151f324
c2266fb
151f324
c2266fb
 
 
 
 
 
814a198
c2266fb
 
 
 
 
 
 
 
 
 
 
814a198
151f324
 
 
 
c2266fb
151f324
 
c2266fb
 
 
151f324
 
c2266fb
151f324
 
c2266fb
151f324
c2266fb
814a198
c2266fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814a198
 
c2266fb
 
 
 
 
 
 
814a198
f6dab21
 
c2266fb
 
 
 
814a198
 
 
151f324
 
 
c2266fb
151f324
 
c2266fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c79668d
 
 
 
 
 
 
 
 
814a198
 
 
c79668d
814a198
 
c79668d
 
 
e0520a1
 
 
 
 
 
814a198
 
 
c79668d
 
 
 
c2266fb
 
 
 
 
 
151f324
814a198
a1e45ba
 
 
814a198
a1e45ba
 
c79668d
 
a1e45ba
 
 
 
 
 
 
814a198
151f324
c2266fb
151f324
 
 
 
814a198
151f324
c2266fb
 
151f324
c2266fb
e0520a1
814a198
c2266fb
 
814a198
151f324
814a198
c2266fb
814a198
 
151f324
c2266fb
151f324
 
 
 
 
c2266fb
 
 
 
 
814a198
c2266fb
 
c79668d
 
c2266fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151f324
814a198
 
 
 
 
 
 
 
 
 
 
 
 
 
c2266fb
 
814a198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c2266fb
 
814a198
c2266fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814a198
c2266fb
 
f6dab21
 
 
 
 
 
 
 
 
 
 
c2266fb
 
 
f6dab21
c2266fb
 
 
 
 
 
 
 
 
 
814a198
c2266fb
 
 
814a198
 
 
 
e0520a1
 
 
 
 
 
c2266fb
 
 
814a198
 
c2266fb
 
 
 
 
 
 
151f324
 
814a198
 
 
 
 
 
 
 
 
 
 
 
a6af21c
 
 
 
 
 
 
 
 
 
814a198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e3339e
 
 
 
814a198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151f324
 
 
 
c2266fb
 
 
 
 
 
 
 
 
 
 
 
 
84131f1
 
 
6de64a8
436da2d
 
 
c2266fb
e0520a1
84131f1
 
6de64a8
c2266fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814a198
c2266fb
 
 
 
 
 
 
 
f6dab21
 
814a198
109bd0b
c2266fb
 
 
 
 
814a198
c2266fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814a198
c2266fb
814a198
c2266fb
 
 
814a198
c2266fb
 
 
 
 
 
 
 
 
 
 
 
e0520a1
 
 
c2266fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814a198
a1e45ba
109bd0b
a1e45ba
 
 
 
 
 
 
 
 
 
814a198
 
109bd0b
 
 
 
 
 
 
 
814a198
109bd0b
 
814a198
109bd0b
 
a1e45ba
109bd0b
814a198
109bd0b
 
 
 
 
 
 
 
 
814a198
109bd0b
 
 
 
814a198
109bd0b
 
a1e45ba
f6dab21
a1e45ba
 
f6dab21
a1e45ba
 
 
c2266fb
 
 
 
 
 
814a198
6de64a8
 
 
 
cd273da
6e3339e
 
 
 
 
 
 
 
c2266fb
814a198
c2266fb
 
 
 
 
 
 
151f324
 
c2266fb
 
 
 
 
 
 
151f324
 
c2266fb
 
 
 
 
814a198
 
c2266fb
e0520a1
814a198
e0520a1
 
 
109bd0b
151f324
 
 
 
c2266fb
151f324
 
cd273da
c2266fb
151f324
6e3339e
 
 
 
e0520a1
 
 
 
 
 
 
 
 
 
 
814a198
e0520a1
 
 
 
 
 
 
 
 
 
 
 
 
814a198
a6af21c
df8ccb6
 
 
cd273da
151f324
 
109bd0b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
"""
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"""
    <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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


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}&amp;layer=mapnik&amp;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()