#!/usr/bin/env python3 """ Architecture Building Image Classifier — Gradio Space UI Model : EfficientNetV2-S + Conv2D Head + GeM Pooling + Focal loss + SWA Theme : Greyscope Labs - Green (#009925) inspired palette Audit : v6 ipynb — 8 classes, parent class fix, flip-consistency, entropy certainty """ import json import os import io import time import base64 import numpy as np import gradio as gr from PIL import Image from huggingface_hub import hf_hub_download import tensorflow as tf try: from tensorflow.keras.applications.efficientnet_v2 import preprocess_input except (ImportError, ModuleNotFoundError): from tensorflow.keras.applications.efficientnet import preprocess_input from tensorflow.keras.layers import Layer # Compatibility import — tf.keras.saving not available in all environments try: from tensorflow.keras.saving import register_keras_serializable except (ImportError, AttributeError): try: from keras.saving import register_keras_serializable except (ImportError, AttributeError): def register_keras_serializable(package=None): def decorator(cls): return cls return decorator # -------------------------------------------------------------------------- # Configuration # -------------------------------------------------------------------------- REPO_ID = os.environ.get("REPO_ID", "0xgr3y/Arch-Building-Image-Classification") MODEL_FILE = os.environ.get("MODEL_FILE", "tflite/model.tflite") IMG_SIZE = (320, 320) HF_TOKEN = os.environ.get("HF_TOKEN", "") # 8 classes — alphabetical order matching flow_from_directory LABELS = ["barn", "bridge", "castle", "mosque", "skyscraper", "stadium", "temple", "windmill"] LOGO_LOCAL_FILE = "greyscope-labs-architecture-classification-transferlearning-efficientnetv2.jpg" # -------------------------------------------------------------------------- # Dynamic label loading — reads label_mapping.json from the HF repo # -------------------------------------------------------------------------- LABELS_FALLBACK = ["barn", "bridge", "castle", "mosque", "skyscraper", "stadium", "temple", "windmill"] def _load_labels() -> list: """Try to load labels from label_mapping.json in the HF repo. Falls back to LABELS_FALLBACK (8-class training order).""" try: _hf_token = os.environ.get("HF_TOKEN", "") or None lm_path = hf_hub_download( repo_id=REPO_ID, filename="label_mapping.json", local_dir="/tmp/model", token=_hf_token, ) import json as _json with open(lm_path) as f: mapping = _json.load(f) lbs = mapping.get("labels", []) if lbs: print(f"[INFO] Labels loaded from label_mapping.json: {lbs}") return lbs except Exception as e: print(f"[WARN] label_mapping.json not found or unreadable ({e}), using fallback labels") print(f"[INFO] Using fallback labels: {LABELS_FALLBACK}") return LABELS_FALLBACK def _load_json_artifact(filename: str) -> dict: """Generic loader for evaluation artifacts exported by the training notebook (model_benchmark.json, confusion_pairs.json, class_confidence_stats.json, calibration_data.json). Returns empty dict if file isn't present in the HF repo — every downstream use must .get() with fallback.""" try: _path = hf_hub_download( repo_id=REPO_ID, filename=filename, local_dir="/tmp/model", token=HF_TOKEN if HF_TOKEN else None, ) with open(_path) as _f: _data = json.load(_f) print(f"[INFO] Loaded {filename}") return _data except Exception as _e: print(f"[WARN] {filename} not found ({_e}), related features will use fallbacks") return {} GREEN_SCALE = ["#006619", "#007A1E", "#009925", "#1BA83A", "#4DBD5E", "#7DD18E", "#A8E0AE", "#C8EED0"] # Short description shown for the predicted class CLASS_INFO = { "barn": "A traditional architectural style, a wooden building used for storage, or sheltering livestock.", "bridge": "A structure built to span a gap — such as a valley, road, or body of water — connecting two points for passage.", "castle": "A fortified residential structure, historically built for nobility and defense, often featuring towers and walls.", "mosque": "An Islamic place of worship, often recognizable by domes, minarets, and ornamental geometric patterns.", "skyscraper": "A very tall, multi-storey building, typically a defining feature of dense modern urban skylines.", "stadium": "A large, often open-air venue designed to host sporting events, concerts, and public gatherings.", "temple": "A building dedicated to religious or spiritual practice, found across many cultures and architectural styles.", "windmill": "A structure fitted with sails or vanes, historically used to convert wind energy into mechanical work for milling or pumping.", } # Hardcoded confusion-note fallbacks (used only when confusion_pairs.json hasn't been uploaded to the repo yet). CONFUSION_NOTES_FALLBACK = { "barn": None, "bridge": None, "castle": None, "mosque": None, "skyscraper": None, "stadium": None, "temple": None, "windmill": None, } CONFUSION_NOTES = CONFUSION_NOTES_FALLBACK CONFUSION_DETAIL = {} # -------------------------------------------------------------------------- # Logo helper # -------------------------------------------------------------------------- def _resolve_logo_src() -> str: """Embed local logo as data-URI so it always renders.""" if os.path.exists(LOGO_LOCAL_FILE): try: with open(LOGO_LOCAL_FILE, "rb") as f: encoded = base64.b64encode(f.read()).decode("utf-8") ext = os.path.splitext(LOGO_LOCAL_FILE)[1].lstrip(".").lower() or "png" return f"data:image/{ext};base64,{encoded}" except Exception: pass return "" LOGO_SRC = _resolve_logo_src() # -------------------------------------------------------------------------- # Custom objects — MUST match training code exactly for deserialization # -------------------------------------------------------------------------- @register_keras_serializable(package='ArchClassifier') class GeMPooling(Layer): """Generalized Mean Pooling (Radenovic et al., CVPR 2018).""" def __init__(self, p=3.0, eps=1e-6, **kwargs): super().__init__(**kwargs) self.p_init = p self.eps = eps def build(self, input_shape): self.p = self.add_weight( name="gem_p", shape=(), dtype=tf.float32, initializer=tf.keras.initializers.Constant(self.p_init), trainable=True) super().build(input_shape) def call(self, x): x = tf.maximum(x, self.eps) x = tf.pow(x, self.p) x = tf.reduce_mean(x, axis=[1, 2], keepdims=False) x = tf.pow(x, 1.0 / self.p) return x def get_config(self): cfg = super().get_config() cfg.update({"p": self.p_init, "eps": self.eps}) return cfg @register_keras_serializable(package='ArchClassifier') class FocalLoss(tf.keras.losses.Loss): """Focal Loss (Lin et al., ICCV 2017), gamma=2.0.""" def __init__(self, gamma=2.0, alpha=None, label_smoothing=0.0, **kwargs): super().__init__(**kwargs) self.gamma = gamma self.alpha = alpha self.label_smoothing = label_smoothing def call(self, y_true, y_pred): y_pred = tf.clip_by_value(y_pred, 1e-7, 1.0 - 1e-7) if self.label_smoothing > 0: y_true = (y_true * (1.0 - self.label_smoothing) + self.label_smoothing / tf.cast(tf.shape(y_true)[-1], tf.float32)) ce = -y_true * tf.math.log(y_pred) weight = tf.pow(1.0 - y_pred, self.gamma) fl = weight * ce if self.alpha is not None: fl = y_true * self.alpha * fl return tf.reduce_mean(tf.reduce_sum(fl, axis=-1)) def get_config(self): cfg = super().get_config() cfg.update({"gamma": self.gamma, "alpha": self.alpha, "label_smoothing": self.label_smoothing}) return cfg @register_keras_serializable(package='ArchClassifier') class DiscriminativeAdamW(tf.keras.optimizers.AdamW): """AdamW with per-variable LR scaling via update_step override. Inherits from AdamW (matching training code) — NOT from the base Optimizer class, which would be a class-hierarchy mismatch on deserialization. compile=False is used at inference so optimizer weights are not loaded, but the class signature must still match for correct custom_objects lookup. """ def __init__(self, lr_multipliers=None, backbone_layer_idx=0, **kwargs): super().__init__(**kwargs) self.lr_multipliers = lr_multipliers or {} self.backbone_layer_idx = backbone_layer_idx self._var_mult_cache = {} def _build_var_cache(self, model): """Cache {id(var): multiplier} keyed on layer.name (stable post load_model).""" self._var_mult_cache = {} base_model = next((l for l in model.layers if isinstance(l, tf.keras.Model)), None) if base_model is None: base_model = model.layers[self.backbone_layer_idx] for layer in base_model.layers: mult = 1.0 for pattern, m in self.lr_multipliers.items(): if pattern in layer.name: mult = m break for var in layer.trainable_variables: self._var_mult_cache[id(var)] = mult def _get_multiplier(self, var): return self._var_mult_cache.get(id(var), 1.0) def update_step(self, gradient, variable, learning_rate): """Scale learning_rate per-variable — truly discriminative.""" mult = self._get_multiplier(variable) effective_lr = learning_rate * mult return super().update_step(gradient, variable, effective_lr) def get_config(self): cfg = super().get_config() cfg.update({"lr_multipliers": self.lr_multipliers, "backbone_layer_idx": self.backbone_layer_idx}) return cfg # -------------------------------------------------------------------------- # Model loading # -------------------------------------------------------------------------- print("[INFO] Downloading TFLite model from HuggingFace Hub…") model_path = hf_hub_download( repo_id=REPO_ID, filename=MODEL_FILE, local_dir="/tmp/model", token=HF_TOKEN if HF_TOKEN else None, ) print(f"[INFO] Model path: {model_path}") # Labels resolved dynamically from label_mapping.json (uploaded alongside model) LABELS = _load_labels() print("[INFO] Loading TFLite interpreter …") _t0 = time.time() interpreter = tf.lite.Interpreter(model_path=model_path) interpreter.allocate_tensors() _input_details = interpreter.get_input_details() _output_details = interpreter.get_output_details() print(f"[INFO] TFLite interpreter ready in {time.time() - _t0:.1f}s") print(f"[INFO] Input: shape={_input_details[0]['shape']}, dtype={_input_details[0]['dtype']}") print(f"[INFO] Output: shape={_output_details[0]['shape']}, dtype={_output_details[0]['dtype']}") def _tflite_predict(arr: np.ndarray) -> np.ndarray: """Run a single forward pass via the TFLite interpreter. Applies Temperature Scaling (Guo et al., ICML 2017) post-hoc if CALIBRATION_TEMP != 1.0. TFLite outputs softmax probabilities; we recover logits via log(probs) and re-softmax with T scaling: softmax(log(probs) / T) == softmax(logits / T) (shift invariance of softmax — constant offset cancels). """ interpreter.set_tensor(_input_details[0]["index"], arr) interpreter.invoke() probs = interpreter.get_tensor(_output_details[0]["index"])[0] if CALIBRATION_TEMP != 1.0: log_probs = np.log(np.clip(probs, 1e-7, 1.0)) scaled = log_probs / CALIBRATION_TEMP e = np.exp(scaled - np.max(scaled)) probs = e / e.sum() return probs # --------------------------------------------------------------------------- # Evaluation artifacts — exported by the training notebook alongside # the model. All four are optional: Space still works (with fallbacks) if # a given file isn't present in the repo yet. # --------------------------------------------------------------------------- MODEL_BENCHMARK = _load_json_artifact("model_benchmark.json") CONFUSION_PAIRS_DATA = _load_json_artifact("confusion_pairs.json") CLASS_CONFIDENCE_STATS = _load_json_artifact("class_confidence_stats.json") CALIBRATION_DATA = _load_json_artifact("calibration_data.json") # Temperature Scaling parameter (Guo et al., ICML 2017) CALIBRATION_TEMP = float(CALIBRATION_DATA.get("temperature", 1.0)) # Prefer empirically-measured confusion notes from confusion_pairs.json CONFUSION_NOTES = CONFUSION_PAIRS_DATA.get("confusion_notes") or CONFUSION_NOTES_FALLBACK CONFUSION_DETAIL = CONFUSION_PAIRS_DATA.get("confusion_notes_detailed", {}) # -------------------------------------------------------------------------- # Inference helpers # -------------------------------------------------------------------------- def _preprocess_arr(pil_img: Image.Image) -> np.ndarray: """Resize to 320×320 and apply EfficientNetV2-S preprocessing — matches training code exactly.""" arr = np.array(pil_img.resize(IMG_SIZE), dtype=np.float32) return np.expand_dims(preprocess_input(arr), axis=0) def _shannon_certainty(probs: np.ndarray) -> float: """Normalised certainty derived from Shannon entropy. certainty = 1 - H / H_max ∈ [0, 1] H_max = log(n_classes) nats — uniform distribution. Returns a percentage 0–100 (100 = completely certain, 0 = uniform). """ n = len(probs) H = -float(np.sum(probs * np.log(np.clip(probs, 1e-10, 1.0)))) H_max = np.log(n) return max(0.0, (1.0 - H / H_max)) * 100 def _flip_predict(pil_img: Image.Image) -> tuple: """Single extra forward pass on the horizontally-flipped image. Directly mirrors TTA variant 2: tf.image.resize(tf.image.flip_left_right(image), (IMG_H, IMG_W)) Returns (flip_label, flip_conf) for the flipped prediction. """ flipped = pil_img.transpose(Image.FLIP_LEFT_RIGHT) arr = _preprocess_arr(flipped) p = _tflite_predict(arr) idx = int(np.argmax(p)) return LABELS[idx], float(p[idx]) * 100 # -------------------------------------------------------------------------- # CSS — Greyscope Labs - Color Green # -------------------------------------------------------------------------- CUSTOM_CSS = """ :root { --bg: #F4F8F5; --surface: #FFFFFF; --ink: #1A1F1C; --muted: #5F6B63; --faint: #A3B0A8; --accent: #009925; --accent-dark: #007A1E; --accent-soft: rgba(0,153,37,.08); --border: #D5DDD9; --sh: 0 1px 2px rgba(26,31,28,.06), 0 6px 18px rgba(26,31,28,.05); --font: 'Google Sans','Product Sans',-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; } .gradio-container { max-width: 960px !important; margin: 0 auto !important; background: var(--bg) !important; font-family: var(--font) !important; color: var(--ink) !important; } footer { display: none !important; } /* ── Header ──────────────────────────────── */ .hdr-wrap { text-align: center; padding: 8px 8px 26px; } .logo-banner { display: block; width: 100%; aspect-ratio: 2750/1530; object-fit: cover; margin: 0 auto 22px; border-radius: 14px; border: 1px solid var(--border); box-shadow: var(--sh); } .hdr-wrap h1 { font-family: var(--font); font-size: 28px; font-weight: 700; color: var(--ink); letter-spacing: -.01em; line-height: 1.25; margin: 0; } .hdr-sub { margin-top: 9px; font-size: 13px; color: var(--muted); font-weight: 400; } .hdr-divider { width: 44px; height: 2px; background: var(--accent); margin: 18px auto; border-radius: 1px; } .hdr-badges { display: flex; justify-content: center; gap: 9px; flex-wrap: wrap; } .hdr-badge { font-size: 11px; font-weight: 500; color: var(--accent-dark); background: var(--accent-soft); border: 1px solid rgba(0,153,37,.25); padding: 5px 13px; border-radius: 20px; } .hdr-note { margin-top: 12px; font-size: 11px; color: var(--faint); letter-spacing: .02em; } /* ── Section label ───────────────────────── */ .section-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin: 4px 0 12px 4px; } /* ── Cards ───────────────────────────────── */ .grn-card { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: 14px !important; box-shadow: var(--sh) !important; padding: 18px !important; } /* ── Upload area ─────────────────────────── */ .grn-upload .image-frame, .grn-upload [data-testid="image"], .grn-upload .upload-container { border-radius: 10px !important; border: 1.5px dashed var(--border) !important; background: var(--bg) !important; } .upload-caption { margin-top: 10px; font-size: 11px; color: var(--faint); text-align: center; letter-spacing: .02em; } /* ── Button ──────────────────────────────── */ .grn-btn, .grn-btn button { background: var(--accent) !important; border: none !important; color: #FFFFFF !important; font-family: var(--font) !important; font-weight: 600 !important; font-size: 14px !important; border-radius: 10px !important; box-shadow: 0 4px 14px rgba(0,153,37,.28) !important; } .grn-btn:hover button, .grn-btn button:hover { background: var(--accent-dark) !important; } /* ── Result card ─────────────────────────── */ .result-box { display: flex; align-items: center; gap: 16px; padding: 14px; background: var(--bg); border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: 10px; margin-bottom: 18px; } .result-thumb-wrap { display: flex; flex-direction: column; align-items: center; gap: 5px; flex-shrink: 0; } .result-thumb { width: 104px; height: 104px; border-radius: 8px; object-fit: cover; border: 1px solid var(--border); } .thumb-label { font-size: 9px; font-weight: 500; color: var(--faint); text-align: center; line-height: 1.4; letter-spacing: .03em; text-transform: uppercase; } .result-info { flex: 1; min-width: 0; } .res-name { font-size: 24px; font-weight: 700; color: var(--ink); line-height: 1.15; } .res-meta { font-size: 12.5px; color: var(--muted); margin-top: 5px; line-height: 1.6; display: flex; flex-direction: column; gap: 1px; } .res-meta strong { color: var(--accent-dark); } .confidence-badge { display: inline-block; margin-top: 8px; font-size: 10.5px; font-weight: 600; letter-spacing: .03em; padding: 3px 11px; border-radius: 20px; border: 1px solid rgba(0,153,37,.25); background: var(--accent-soft); color: var(--accent-dark); } .confidence-badge.medium { border-color: var(--border); background: #E6F0E9; color: #3D7A50; } .confidence-badge.low { border-color: var(--border); background: #EEF1EF; color: var(--muted); } /* ── Class description ───────────────────── */ .class-desc { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 12.5px; color: var(--muted); line-height: 1.65; } .class-desc strong { color: var(--ink); } /* ── Probability bars ────────────────────── */ .plist { display: flex; flex-direction: column; gap: 10px; } .prow { display: flex; align-items: center; gap: 10px; } .plabel { font-size: 12.5px; color: var(--ink); width: 92px; flex-shrink: 0; } .pbg { flex: 1; height: 9px; background: #E2E9E4; border-radius: 5px; overflow: hidden; } .pfill { height: 100%; border-radius: 5px; } .ppct { font-size: 12px; width: 46px; text-align: right; flex-shrink: 0; } /* ── Live Prediction Analysis panel ─────── */ .lpa-panel { margin-top: 16px; padding: 18px 20px; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; } .lpa-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 14px; text-align: center; } .lpa-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 10px; justify-items: center; } .lpa-metric { display: flex; flex-direction: column; gap: 4px; align-items: center; text-align: center; } .lpa-label { font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .1em; color: var(--faint); } .lpa-value { font-size: 18px; font-weight: 700; color: var(--ink); line-height: 1; } .lpa-value.good { color: #007A1E; } .lpa-value.warn { color: #C47D05; } .lpa-value.bad { color: #C0392B; } .lpa-bar-bg { height: 5px; background: #E2E9E4; border-radius: 3px; overflow: hidden; margin-top: 2px; width: 100%; } .lpa-bar-fill { height: 100%; border-radius: 3px; } .lpa-sublabel { font-size: 10px; color: var(--faint); margin-top: 2px; } .lpa-divider { border: none; border-top: 1px solid var(--border); margin: 12px 0 10px; } .lpa-insight { font-size: 12px; color: var(--muted); line-height: 1.6; text-align: center; } .lpa-insight strong { color: var(--ink); } .lpa-insight.ok { color: #007A1E; } .lpa-insight.warn { color: #C47D05; } .lpa-insight.bad { color: #C0392B; } /* ── Placeholder ─────────────────────────── */ .result-placeholder { min-height: 320px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; border: 1.5px dashed var(--border); border-radius: 10px; background: var(--bg); text-align: center; padding: 24px; } .result-placeholder p { font-size: 13px; color: var(--muted); line-height: 1.7; margin: 0; } .result-placeholder strong { color: var(--accent-dark); } /* ── Footer ──────────────────────────────── */ .grn-footer { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border); text-align: center; font-size: 11.5px; color: var(--faint); line-height: 2.1; } .grn-footer a { color: var(--muted); text-decoration: none; } .grn-footer a:hover { color: var(--accent); } @media (max-width: 760px) { .result-box { flex-direction: column; text-align: center; } .lpa-grid { grid-template-columns: 1fr 1fr; } } """ # -------------------------------------------------------------------------- # Dynamic HTML fragments (metrics badges reflect real test-set data # when model_benchmark.json is present, otherwise generic badges) # -------------------------------------------------------------------------- def _build_header_html() -> str: metrics = MODEL_BENCHMARK.get("metrics", {}) if metrics and metrics.get("test_accuracy", 0) > 0: ta = metrics.get("test_accuracy", 0) * 100 tta = metrics.get("tta_accuracy", 0) * 100 t3 = metrics.get("top3_accuracy", 0) * 100 auc = metrics.get("macro_auc", 0) auc_str = f" | AUC {auc:.4f}" if auc else "" badges = (f'Test Accuracy {ta:.1f}%' f'TTA Accuracy {tta:.1f}%' f'Top-3 Accuracy {t3:.1f}%' f'Macro AUC {auc:.4f}') note = f"Evaluated on held-out test set · {len(LABELS)} architectural classes" else: badges = ('8 Classes' 'EfficientNetV2-S + GeM + SWA' '13,440 Images') note = ("Barn · Bridge · Castle · Mosque " "· Skyscraper · Stadium · Temple · Windmill") return ( f'
Upload a building photo on the left, then press
Classify Building to see the result here.
⚠ Please upload a building photo first.
' '