Spaces:
Sleeping
Sleeping
Download app.py from Unearthed/artifact.classifier: direct link, hf CLI and curl.
- Browser
- Download file 31.1 kB
-
https://huggingface.co/spaces/Unearthed/artifact.classifier/resolve/main/app.py
- Command line
-
hf download hf://spaces/Unearthed/artifact.classifier/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Unearthed/artifact.classifier/resolve/main/app.py
31.1 kB
| """ | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β 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 "<p style='color:red'>Please upload an image.</p>", 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'<span style="background:{color};color:white;padding:2px 8px;border-radius:10px;font-size:12px;margin:2px">{symbol} {label}</span>' | |
| 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'<li style="margin:4px 0;color:#ddd">β¦ {item}</li>' | |
| for item in info["what_to_look_for"] | |
| ) | |
| # Gate summary | |
| gate1 = tex > TEXTURE_THRESHOLD | |
| gate2 = reg > REGULARITY_THRESHOLD | |
| gate3 = sym > SYMMETRY_THRESHOLD | |
| gate4 = top_conf > CONF_THRESHOLD and ent < ENTROPY_THRESHOLD | |
| gates_html = ( | |
| _gate_badge(gate1, f"Texture {tex:.0f}") + | |
| _gate_badge(gate2, f"Edge Regularity {reg:.2f}") + | |
| _gate_badge(gate3, f"Symmetry {sym:.2f}") + | |
| _gate_badge(gate4, f"Confidence {top_conf:.1%}") + | |
| (f'<span style="background:#e67e22;color:white;padding:2px 8px;border-radius:10px;font-size:12px;margin:2px">β‘ Override Applied</span>' if override else "") + | |
| (f'<span style="background:#e67e22;color:white;padding:2px 8px;border-radius:10px;font-size:12px;margin:2px">π Fragment Detected</span>' if broken else "") | |
| ) | |
| # Materials & metadata | |
| materials = ", ".join(info["common_materials"]) | |
| html = f""" | |
| <div style="font-family:'Segoe UI',sans-serif;background:#1a1a2e;border-radius:16px;overflow:hidden;max-width:820px"> | |
| <!-- Header banner --> | |
| <div style="background:{status_bg};padding:20px 24px"> | |
| <div style="font-size:26px;font-weight:700;color:white;letter-spacing:1px">{status_text}</div> | |
| <div style="font-size:14px;color:rgba(255,255,255,0.85);margin-top:4px">{status_sub}</div> | |
| </div> | |
| <!-- Body --> | |
| <div style="padding:20px 24px"> | |
| <!-- Archaeological gates --> | |
| <div style="margin-bottom:18px"> | |
| <div style="color:#aaa;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Archaeological Feature Gates</div> | |
| <div>{gates_html}</div> | |
| </div> | |
| <!-- Description --> | |
| <div style="background:#16213e;border-radius:10px;padding:14px 16px;margin-bottom:16px"> | |
| <div style="color:{color};font-weight:600;font-size:14px;margin-bottom:6px">{icon} What is this?</div> | |
| <div style="color:#ccc;font-size:13px;line-height:1.6">{info['description']}</div> | |
| </div> | |
| <!-- Two columns: checklist + diagnostics --> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px"> | |
| <!-- What to look for --> | |
| <div style="background:#16213e;border-radius:10px;padding:14px 16px"> | |
| <div style="color:{color};font-weight:600;font-size:13px;margin-bottom:8px">π What to look for</div> | |
| <ul style="margin:0;padding-left:4px;list-style:none;font-size:12px"> | |
| {checklist_html} | |
| </ul> | |
| </div> | |
| <!-- Diagnostic scores --> | |
| <div style="background:#16213e;border-radius:10px;padding:14px 16px"> | |
| <div style="color:{color};font-weight:600;font-size:13px;margin-bottom:10px">π Diagnostic Scores</div> | |
| <div style="margin-bottom:8px"> | |
| <div style="display:flex;justify-content:space-between;color:#aaa;font-size:11px"><span>Texture Complexity</span><span style="color:white">{tex:.0f}</span></div> | |
| <div style="background:#0f3460;border-radius:4px;height:6px;margin-top:3px"> | |
| <div style="background:{color};height:6px;border-radius:4px;width:{min(tex/500*100,100):.0f}%"></div> | |
| </div> | |
| <div style="color:#666;font-size:10px;margin-top:1px">>80 = worked stone surface</div> | |
| </div> | |
| <div style="margin-bottom:8px"> | |
| <div style="display:flex;justify-content:space-between;color:#aaa;font-size:11px"><span>Edge Regularity</span><span style="color:white">{reg:.2f}</span></div> | |
| <div style="background:#0f3460;border-radius:4px;height:6px;margin-top:3px"> | |
| <div style="background:{color};height:6px;border-radius:4px;width:{reg*100:.0f}%"></div> | |
| </div> | |
| <div style="color:#666;font-size:10px;margin-top:1px">>0.55 = systematic knapping edges</div> | |
| </div> | |
| <div style="margin-bottom:8px"> | |
| <div style="display:flex;justify-content:space-between;color:#aaa;font-size:11px"><span>Bilateral Symmetry</span><span style="color:white">{sym:.2f}</span></div> | |
| <div style="background:#0f3460;border-radius:4px;height:6px;margin-top:3px"> | |
| <div style="background:{color};height:6px;border-radius:4px;width:{sym*100:.0f}%"></div> | |
| </div> | |
| <div style="color:#666;font-size:10px;margin-top:1px">>0.70 = intact hafted point</div> | |
| </div> | |
| <div style="margin-bottom:4px"> | |
| <div style="display:flex;justify-content:space-between;color:#aaa;font-size:11px"><span>Stem / Notch Evidence</span><span style="color:white">{stem:.2f}</span></div> | |
| <div style="background:#0f3460;border-radius:4px;height:6px;margin-top:3px"> | |
| <div style="background:{color};height:6px;border-radius:4px;width:{stem*100:.0f}%"></div> | |
| </div> | |
| <div style="color:#666;font-size:10px;margin-top:1px">>0.25 = base constriction for hafting</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Material & period metadata --> | |
| <div style="background:#16213e;border-radius:10px;padding:12px 16px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px"> | |
| <div> | |
| <div style="color:#666;font-size:10px;text-transform:uppercase;letter-spacing:1px">Common Materials</div> | |
| <div style="color:#ddd;font-size:12px;margin-top:3px">{materials}</div> | |
| </div> | |
| <div> | |
| <div style="color:#666;font-size:10px;text-transform:uppercase;letter-spacing:1px">Typical Size</div> | |
| <div style="color:#ddd;font-size:12px;margin-top:3px">{info['typical_size']}</div> | |
| </div> | |
| <div> | |
| <div style="color:#666;font-size:10px;text-transform:uppercase;letter-spacing:1px">Time Period</div> | |
| <div style="color:#ddd;font-size:12px;margin-top:3px">{info['period']}</div> | |
| </div> | |
| </div> | |
| <!-- Disclaimer --> | |
| <div style="margin-top:14px;padding:10px 14px;background:#0f3460;border-radius:8px;border-left:3px solid {color}"> | |
| <div style="color:#aaa;font-size:11px;line-height:1.5"> | |
| β οΈ <strong style="color:#ddd">Disclaimer:</strong> This tool provides an AI-assisted preliminary classification only. | |
| Always consult a certified archaeologist or lithic specialist for definitive identification. | |
| Do not disturb, collect, or move artifacts found in the field without proper permits. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| return html | |
| def _format_rejection(title, message, tex, reg, sym, stem): | |
| return f""" | |
| <div style="font-family:'Segoe UI',sans-serif;background:#1a1a2e;border-radius:16px;overflow:hidden;max-width:820px"> | |
| <div style="background:#c0392b;padding:20px 24px"> | |
| <div style="font-size:22px;font-weight:700;color:white">β {title}</div> | |
| </div> | |
| <div style="padding:20px 24px;color:#ccc;font-size:14px"> | |
| <p>{message}</p> | |
| <p style="margin-top:12px;color:#888;font-size:12px"> | |
| Texture score: {tex:.1f} (minimum required: {TEXTURE_THRESHOLD}) | | |
| Edge regularity: {reg:.2f} | Symmetry: {sym:.2f} | Stem: {stem:.2f} | |
| </p> | |
| </div> | |
| </div> | |
| """ | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GRADIO UI | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| TITLE = "πΉ Unearthed β Archaeological Lithic Artifact Classifier" | |
| DESCRIPTION = """ | |
| Upload a photograph of a stone object to identify whether it is a **projectile point (arrowhead)**, | |
| **scraper**, **axe/hammerstone**, or a **natural rock / debitage**. | |
| The classifier uses texture analysis, depth mapping, edge regularity, and bilateral symmetry β | |
| the same visual criteria used by field archaeologists. | |
| **Tips for best results:** | |
| - Photograph the artifact against a plain, contrasting background (white or black) | |
| - Include both faces if possible | |
| - Use natural or diffuse lighting to show surface texture clearly | |
| - Avoid harsh shadows across the face of the artifact | |
| """ | |
| with gr.Blocks( | |
| title=TITLE, | |
| theme=gr.themes.Base( | |
| primary_hue="orange", | |
| neutral_hue="slate", | |
| font=gr.themes.GoogleFont("Inter"), | |
| ), | |
| css=""" | |
| .gradio-container { max-width: 1100px !important; } | |
| #result-panel { min-height: 200px; } | |
| footer { display: none !important; } | |
| """ | |
| ) as demo: | |
| gr.Markdown(f"# {TITLE}") | |
| gr.Markdown(DESCRIPTION) | |
| with gr.Row(): | |
| # Left column: upload | |
| with gr.Column(scale=1): | |
| image_input = gr.Image( | |
| type="pil", | |
| label="Upload artifact photo", | |
| height=320, | |
| sources=["upload", "clipboard"], | |
| ) | |
| submit_btn = gr.Button("π Analyze Artifact", variant="primary", size="lg") | |
| gr.Markdown(""" | |
| --- | |
| **How it works** | |
| | Feature | What it detects | | |
| |---|---| | |
| | π‘οΈ Texture | Knapping scar density | | |
| | π Depth map | Biface ridge / relief | | |
| | γ°οΈ Edge regularity | Systematic vs random edges | | |
| | βοΈ Symmetry | Hafting geometry | | |
| | πͺ Stem/notch | Base constriction for shaft | | |
| """) | |
| # Right column: results | |
| with gr.Column(scale=2): | |
| result_html = gr.HTML( | |
| label="Identification Result", | |
| elem_id="result-panel" | |
| ) | |
| gr.Markdown("---") | |
| gr.Markdown("### π Analysis Visualizations") | |
| with gr.Row(): | |
| conf_chart = gr.Image(label="Confidence Scores", height=240) | |
| gradcam_out = gr.Image(label="Grad-CAM β Model Focus Area", height=240) | |
| feature_out = gr.Image(label="Feature Analysis β Depth Β· Texture Β· Edges", height=260) | |
| # Examples | |
| gr.Markdown("---") | |
| gr.Markdown("### π· Try an example") | |
| gr.Examples( | |
| examples=[], # add local image paths here once you have them e.g. ["examples/arrowhead1.jpg"] | |
| inputs=image_input, | |
| label="Example images (add your own to the /examples folder)" | |
| ) | |
| # Footer | |
| gr.Markdown(""" | |
| --- | |
| <div style='text-align:center;color:#666;font-size:12px'> | |
| Built with EfficientNetB3 Β· Gabor texture analysis Β· Laplacian depth mapping<br> | |
| Grounded in criteria from <a href='https://projectilepoints.net' target='_blank'>projectilepoints.net</a>, | |
| <a href='https://cartarchaeology.wordpress.com' target='_blank'>CART Archaeology</a>, | |
| and <a href='https://rockhoundresource.com' target='_blank'>Rockhound Resource</a><br> | |
| β οΈ For educational use only β consult a certified archaeologist for definitive identification | |
| </div> | |
| """) | |
| submit_btn.click( | |
| fn=predict_artifact, | |
| inputs=[image_input], | |
| outputs=[result_html, gradcam_out, feature_out, conf_chart], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |