""" ╔══════════════════════════════════════════════════════════════╗ ║ UNEARTHED — Archaeological Lithic Artifact Classifier ║ ║ HuggingFace Spaces · Gradio UI ║ ║ app.py — upload this alongside your .h5 model file ║ ╚══════════════════════════════════════════════════════════════╝ Files needed in your HF Space repo: app.py ← this file lithic_classifier_v2_final.h5 ← your trained model requirements.txt ← see bottom of this file """ import gradio as gr import numpy as np import cv2 import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.patches as mpatches from PIL import Image from skimage.filters import gabor from skimage.color import rgb2gray as sk_rgb2gray from scipy.spatial import cKDTree import tensorflow as tf import keras from keras.applications.efficientnet import preprocess_input from keras import layers import io import os # ═══════════════════════════════════════════════════════════════ # CONFIGURATION # ═══════════════════════════════════════════════════════════════ MODEL_PATH = "lithic_classifier_v2_clean.h5" IMG_SIZE = (224, 224) CLASS_NAMES = ["arrowheads", "axehammerstones", "notartifact", "scrapers"] # Prediction gate thresholds CONF_THRESHOLD = 0.65 ENTROPY_THRESHOLD = 1.30 TEXTURE_THRESHOLD = 15.0 REGULARITY_THRESHOLD = 0.30 SYMMETRY_THRESHOLD = 0.38 ARTIFACT_OVERRIDE_THR = 0.55 # Per-class display info shown to end user CLASS_INFO = { "arrowheads": { "icon": "🏹", "label": "Projectile Point / Arrowhead", "color": "#c0392b", "description": ( "A hafted projectile point used as a weapon tip for arrows, darts, or spears. " "Produced by skilled knapping (chipping stone with antler or bone tools)." ), "what_to_look_for": [ "Bifacial flaking visible on BOTH flat faces", "Thinned cross-section — blade is thinner than the base", "Stem, notch, or ear at the base for tying to a shaft (hafting)", "Bilateral symmetry — left and right sides mirror each other", "Regular, intentional serrations along the cutting edge", "Conchoidal (shell-like) fracture surface — smooth, glassy ripples", ], "common_materials": ["Chert", "Flint", "Obsidian", "Quartzite", "Jasper"], "typical_size": "2 – 10 cm long", "period": "Paleo-Indian through Late Prehistoric (13,000 BP – 500 BP)", }, "scrapers": { "icon": "🪨", "label": "Scraper", "color": "#2980b9", "description": ( "A stone tool used for scraping hides, wood, or plant material. " "Distinguished from projectile points by its thick, steep working edge on ONE face only." ), "what_to_look_for": [ "Unifacial working — only ONE face shows intentional flaking", "Steep retouch angle on working edge (~60–80°)", "Cortex (original rock surface) often present on the dorsal face", "Thick, robust form — not thinned for hafting", "No stem or notching — not designed to be mounted on a shaft", "Wear polish visible on working edge under magnification", ], "common_materials": ["Chert", "Flint", "Quartzite", "Basalt"], "typical_size": "3 – 12 cm", "period": "Found across all prehistoric periods", }, "axehammerstones": { "icon": "🪓", "label": "Axe / Hammerstone", "color": "#8e44ad", "description": ( "A ground stone tool used for chopping, grinding, or pounding. " "Unlike knapped tools, these are shaped by abrasion and pecking rather than flaking." ), "what_to_look_for": [ "Pecking marks — small circular pits from repeated impact use", "Ground or polished facets on working surfaces", "Heavier and more rounded form than flaked tools", "Battered or crushed areas at impact points", "May show groove for hafting (grooved axes)", "Surface smoothed by use — not from knapping", ], "common_materials": ["Granite", "Basalt", "Sandstone", "Quartzite"], "typical_size": "5 – 25 cm", "period": "Archaic through Woodland periods (8,000 BP – 1,000 BP)", }, "notartifact": { "icon": "🪨", "label": "Natural Rock / Not an Artifact", "color": "#7f8c8d", "description": ( "This appears to be a naturally occurring rock, unmodified stone, or debitage " "(waste flake from tool production) rather than an intentionally shaped artifact." ), "what_to_look_for": [ "No systematic flaking pattern — fractures appear random", "No bilateral symmetry or intentional shaping", "No stem, notch, or hafting preparation", "No organised working edge", "Surface texture consistent with natural weathering", "Consider: may be debitage (waste from knapping nearby tools)", ], "common_materials": ["Any"], "typical_size": "Any", "period": "N/A", }, } # ═══════════════════════════════════════════════════════════════ # LOAD MODEL # ═══════════════════════════════════════════════════════════════ print("Loading model …") model = keras.models.load_model(MODEL_PATH, compile=False) print("Model loaded ✅") # Try to get class names from model if available try: detected_classes = model.output_shape[-1] print(f"Model output classes: {detected_classes}") except: pass # ═══════════════════════════════════════════════════════════════ # FEATURE EXTRACTION # ═══════════════════════════════════════════════════════════════ def compute_laplacian_depth_channel(img_np): gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY).astype(np.float32) blur = cv2.GaussianBlur(gray, (5, 5), 0) lap = np.abs(cv2.Laplacian(blur, cv2.CV_32F)) lap = (lap - lap.min()) / (lap.max() - lap.min() + 1e-8) return lap[:, :, np.newaxis] def compute_gabor_texture_channel(img_np): gray = sk_rgb2gray(img_np).astype(np.float64) resps = [np.abs(gabor(gray, frequency=0.15, theta=t)[0]) for t in [0, np.pi/4, np.pi/2]] gab = np.mean(resps, axis=0).astype(np.float32) gab = (gab - gab.min()) / (gab.max() - gab.min() + 1e-8) return gab[:, :, np.newaxis] def build_5channel_input(img_np): rgb = img_np.astype(np.float32) / 255.0 depth = compute_laplacian_depth_channel(img_np) gab = compute_gabor_texture_channel(img_np) return np.concatenate([rgb, depth, gab], axis=-1) def texture_complexity_score(img_np): return float(cv2.Laplacian(cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY), cv2.CV_64F).var()) def edge_regularity_score(img_np): edges = cv2.Canny(cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY), 50, 150) ys, xs = np.where(edges > 0) if len(xs) < 20: return 0.0 pts = np.stack([xs, ys], axis=1).astype(np.float32) tree = cKDTree(pts) d, _ = tree.query(pts, k=2) return float(np.clip(1.0 / (1.0 + np.std(d[:, 1])), 0, 1)) def symmetry_score(img_np): gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY).astype(np.float32) h, w = gray.shape l = gray[:, :w//2] r = gray[:, w//2:][:, ::-1] m = min(l.shape[1], r.shape[1]) corr = np.corrcoef(l[:, :m].flatten(), r[:, :m].flatten())[0, 1] return float(np.clip((corr + 1) / 2, 0, 1)) def stem_notch_score(img_np): gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) h, w = bw.shape def row_width(region): widths = [cols[-1]-cols[0] for row in region for cols in [np.where(row>0)[0]] if len(cols)>2] return np.mean(widths) if widths else 0 fw = row_width(bw) bw_ = row_width(bw[int(h*0.75):, :]) return float(np.clip(1.0 - (bw_ / fw), 0, 1)) if fw > 1 else 0.0 def is_broken_artifact(tex, reg, sym): return tex > 80 and reg > 0.55 and sym < 0.70 # ═══════════════════════════════════════════════════════════════ # GRAD-CAM # ═══════════════════════════════════════════════════════════════ def make_gradcam(img_5ch): try: gap_idx = next(i for i, l in enumerate(model.layers) if isinstance(l, keras.layers.GlobalAveragePooling2D)) feat_layer = model.layers[gap_idx - 1] grad_model = keras.models.Model( inputs=model.inputs, outputs=[feat_layer.output, model.output] ) img_t = tf.cast(np.expand_dims(img_5ch, 0), tf.float32) with tf.GradientTape() as tape: conv_out, preds = grad_model(img_t) idx = tf.argmax(preds[0]) score = preds[:, idx] grads = tape.gradient(score, conv_out)[0] weights = tf.reduce_mean(grads, axis=(0, 1)) cam = tf.reduce_sum(tf.multiply(weights, conv_out[0]), axis=-1) cam = np.maximum(cam.numpy(), 0) cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) return cam except: return None def overlay_gradcam(img_np, cam, alpha=0.45): hm = cv2.resize(cam, (img_np.shape[1], img_np.shape[0])) hm = cv2.applyColorMap(np.uint8(255 * hm), cv2.COLORMAP_JET) hm = cv2.cvtColor(hm, cv2.COLOR_BGR2RGB) return cv2.addWeighted(img_np, 1 - alpha, hm, alpha, 0) # ═══════════════════════════════════════════════════════════════ # VISUALISATION PANELS # ═══════════════════════════════════════════════════════════════ def make_feature_panel(img_np, x5, tex, reg, sym, stem): """ Returns a matplotlib figure showing: Original | Depth channel | Gabor texture | Edge map """ fig, axes = plt.subplots(1, 4, figsize=(16, 4)) fig.patch.set_facecolor("#1a1a2e") titles = ["Original Photo", "Depth / Relief\n(Laplacian)", "Texture / Knapping\n(Gabor)", "Edge Regularity\n(Canny)"] imgs = [ img_np, x5[:, :, 3], x5[:, :, 4], cv2.Canny(cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY), 50, 150), ] cmaps = [None, "hot", "viridis", "gray"] for ax, title, im, cmap in zip(axes, titles, imgs, cmaps): ax.imshow(im, cmap=cmap) ax.set_title(title, color="white", fontsize=10, pad=6) ax.axis("off") # Score bar at the bottom fig.text(0.5, 0.01, f"Texture: {tex:.0f} Edge Regularity: {reg:.2f} Symmetry: {sym:.2f} Stem/Notch: {stem:.2f}", ha="center", color="#aaaaaa", fontsize=9) plt.tight_layout(rect=[0, 0.06, 1, 1]) return fig def make_confidence_chart(preds, class_names, top_cls, override): """ Horizontal bar chart of per-class confidence scores. """ info = CLASS_INFO colors = [info.get(c, {}).get("color", "#888888") for c in class_names] labels = [info.get(c, {}).get("label", c) for c in class_names] icons = [info.get(c, {}).get("icon", "") for c in class_names] fig, ax = plt.subplots(figsize=(8, 3)) fig.patch.set_facecolor("#1a1a2e") ax.set_facecolor("#16213e") y_pos = range(len(class_names)) bars = ax.barh(list(y_pos), preds, color=colors, height=0.55, edgecolor="none") # Value labels on bars for bar, val in zip(bars, preds): ax.text(min(val + 0.02, 0.98), bar.get_y() + bar.get_height() / 2, f"{val:.1%}", va="center", color="white", fontsize=10, fontweight="bold") ax.set_yticks(list(y_pos)) ax.set_yticklabels([f"{ic} {lb}" for ic, lb in zip(icons, labels)], color="white", fontsize=9) ax.set_xlim(0, 1.15) ax.set_xlabel("Confidence", color="#aaaaaa", fontsize=9) ax.tick_params(colors="white") for spine in ax.spines.values(): spine.set_visible(False) title = f"Model Confidence Scores" if override: title += " ⚡ Override applied" ax.set_title(title, color="white", fontsize=11, pad=8) plt.tight_layout() return fig def fig_to_pil(fig): buf = io.BytesIO() fig.savefig(buf, format="png", dpi=120, bbox_inches="tight") buf.seek(0) img = Image.open(buf).copy() plt.close(fig) return img # ═══════════════════════════════════════════════════════════════ # MAIN PREDICTION FUNCTION # ═══════════════════════════════════════════════════════════════ def predict_artifact(image): """ Main Gradio prediction function. Returns: - result_html : full formatted HTML result card - gradcam_img : PIL image of Grad-CAM overlay - feature_img : PIL image of 4-panel feature analysis - confidence_img : PIL image of confidence bar chart """ if image is None: return "
Please upload an image.
", None, None, None img_np = np.array(image.convert("RGB").resize(IMG_SIZE)) # ── Feature scores ──────────────────────────────────────── tex_score = texture_complexity_score(img_np) reg_score = edge_regularity_score(img_np) sym_score = symmetry_score(img_np) stem_score = stem_notch_score(img_np) broken = is_broken_artifact(tex_score, reg_score, sym_score) # ── Gate 1: Realism check ───────────────────────────────── if tex_score < TEXTURE_THRESHOLD: html = _format_rejection( "Not a Real Stone Photo", "The image surface appears too uniform. Please upload a real photograph of a stone object.", tex_score, reg_score, sym_score, stem_score ) return html, None, None, None # ── Model prediction ────────────────────────────────────── x5 = build_5channel_input(img_np) preds = model.predict(np.expand_dims(x5, 0), verbose=0)[0] top_idx = int(np.argmax(preds)) top_cls = CLASS_NAMES[top_idx] top_conf = float(preds[top_idx]) ent = float(-np.sum(preds * np.log(preds + 1e-8))) notartifact_idx = next((i for i, c in enumerate(CLASS_NAMES) if "notartifact" in c.lower()), None) artifact_score_sum = sum(float(preds[i]) for i in range(len(CLASS_NAMES)) if i != notartifact_idx) # ── Archaeological override ─────────────────────────────── override = False predicted_notartifact = (notartifact_idx is not None and preds[notartifact_idx] == max(preds)) if predicted_notartifact: if tex_score > 80 and reg_score > 0.55 and artifact_score_sum > ARTIFACT_OVERRIDE_THR: override = True art_scores = {c: float(preds[i]) for i, c in enumerate(CLASS_NAMES) if i != notartifact_idx} top_cls = max(art_scores, key=art_scores.get) top_conf = art_scores[top_cls] # ── Gate 4: Confidence check ────────────────────────────── uncertain = (not override) and (top_conf < CONF_THRESHOLD or ent > ENTROPY_THRESHOLD) # ── Grad-CAM ────────────────────────────────────────────── cam = make_gradcam(x5) gradcam_pil = None if cam is not None: overlay = overlay_gradcam(img_np, cam) fig_gc, ax = plt.subplots(figsize=(5, 5)) fig_gc.patch.set_facecolor("#1a1a2e") ax.imshow(overlay) ax.set_title("Grad-CAM — What the model focused on", color="white", fontsize=10) ax.axis("off") plt.tight_layout() gradcam_pil = fig_to_pil(fig_gc) # ── Feature panel ───────────────────────────────────────── feat_pil = fig_to_pil(make_feature_panel(img_np, x5, tex_score, reg_score, sym_score, stem_score)) # ── Confidence chart ────────────────────────────────────── conf_pil = fig_to_pil(make_confidence_chart(preds, CLASS_NAMES, top_cls, override)) # ── Build result HTML ───────────────────────────────────── result_html = _format_result( top_cls, top_conf, ent, preds, tex_score, reg_score, sym_score, stem_score, broken, override, uncertain, artifact_score_sum ) return result_html, gradcam_pil, feat_pil, conf_pil # ═══════════════════════════════════════════════════════════════ # HTML FORMATTERS # ═══════════════════════════════════════════════════════════════ def _gate_badge(passed, label): color = "#27ae60" if passed else "#e74c3c" symbol = "✅" if passed else "❌" return f'{symbol} {label}' def _format_result(top_cls, top_conf, ent, preds, tex, reg, sym, stem, broken, override, uncertain, art_sum): info = CLASS_INFO.get(top_cls, CLASS_INFO["notartifact"]) color = info["color"] icon = info["icon"] # Status banner if uncertain: status_bg = "#7f8c8d" status_text = "⚠️ UNCERTAIN — Manual review recommended" status_sub = "The model is not confident. This may be a broken, stained, or unusual specimen." elif top_cls == "notartifact": status_bg = "#7f8c8d" status_text = "🪨 NOT AN ARTIFACT" status_sub = "This appears to be a natural rock, debitage, or unmodified stone." else: status_bg = color status_text = f"{icon} {info['label'].upper()}" status_sub = f"Confidence: {top_conf:.1%}" # Build checklist checklist_html = "".join( f'