File size: 11,945 Bytes
6255687
 
 
9233144
e155d31
 
b38aa7a
 
ca3d549
 
b38aa7a
 
 
 
 
 
4896db0
b38aa7a
 
7826bec
 
 
 
 
b38aa7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9233144
ca3d549
9233144
b06e788
ca3d549
 
b38aa7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2babd47
b38aa7a
 
 
 
 
 
 
7826bec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b38aa7a
59c9a48
ca3d549
b6cf9b2
ca3d549
b6cf9b2
2ec496c
b6cf9b2
 
b38aa7a
b6cf9b2
 
 
 
7826bec
 
 
2babd47
2ec496c
 
 
 
 
 
 
 
 
 
 
b38aa7a
2ec496c
 
 
 
ca3d549
b38aa7a
 
ca3d549
b38aa7a
 
 
 
ca3d549
b38aa7a
 
7826bec
 
2ec496c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7826bec
b38aa7a
 
 
 
 
7826bec
2ec496c
 
 
b38aa7a
2ec496c
 
 
 
 
 
ca3d549
2ec496c
 
 
 
 
 
 
 
 
7826bec
 
2ec496c
 
 
 
7826bec
2ec496c
 
7826bec
2ec496c
 
 
 
 
 
7826bec
2ec496c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9233144
2ec496c
 
b38aa7a
2ec496c
 
b38aa7a
 
 
 
 
 
 
 
ca3d549
 
 
 
 
2ec496c
 
 
 
 
ca3d549
2ec496c
ca3d549
 
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
import os
os.environ["CUDA_VISIBLE_DEVICES"] = ""
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"

import gradio as gr
import numpy as np
import cv2
import tensorflow as tf
from PIL import Image
from huggingface_hub import hf_hub_download
from skimage.filters import gabor
from skimage.color import rgb2gray as sk_rgb2gray
from scipy.spatial import cKDTree

# ── Config ───────────────────────────────────────────────────
MODEL_REPO = "Unearthed/artifact-classifier"
MODEL_FILE = "lithic_classifier_hf_fixed.keras"
IMG_SIZE   = (224, 224)

CONF_THRESHOLD        = 0.55
ENTROPY_THRESHOLD     = 1.35
TEXTURE_THRESHOLD     = 15.0
REGULARITY_THRESHOLD  = 0.30
SYMMETRY_THRESHOLD    = 0.38
ARTIFACT_OVERRIDE_THR = 0.45

class_names = ["arrowheads", "axehammerstones", "notartifact", "scrapers"]

DISPLAY_CATEGORY = {
    "arrowheads":      "Projectile Points",
    "axehammerstones": "Other Lithics",
    "scrapers":        "Other Lithics",
    "notartifact":     "Non-Artifacts",
}

def display_category(cls_name):
    for key, label in DISPLAY_CATEGORY.items():
        if key in cls_name.lower():
            return label
    return cls_name

# ── Load model at startup ─────────────────────────────────────
print("Loading model...")
model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE)
model = tf.keras.models.load_model(model_path, compile=False)
print("Model ready!")

# ── Feature extraction ───────────────────────────────────────
def build_5channel_input(img_np):
    rgb  = img_np.astype(np.float32) / 255.0
    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)
    gray_sk = sk_rgb2gray(img_np).astype(np.float64)
    resps = [np.abs(gabor(gray_sk, 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 np.concatenate([rgb, lap[:,:,np.newaxis], gab[:,:,np.newaxis]], axis=-1)

def texture_complexity_score(img_np):
    gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
    return float(cv2.Laplacian(gray, cv2.CV_64F).var())

def edge_regularity_score(img_np):
    gray  = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
    edges = cv2.Canny(gray, 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)
    dists, _ = tree.query(pts, k=2)
    return float(np.clip(1.0 / (1.0 + np.std(dists[:, 1])), 0, 1))

def symmetry_score(img_np):
    gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY).astype(np.float32)
    h, w = gray.shape
    left  = gray[:, :w//2]
    right = gray[:, w//2:][:, ::-1]
    min_w = min(left.shape[1], right.shape[1])
    left, right = left[:, :min_w], right[:, :min_w]
    corr = np.corrcoef(left.flatten(), right.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 = []
        for row in region:
            cols = np.where(row > 0)[0]
            if len(cols) > 2:
                widths.append(cols[-1] - cols[0])
        return np.mean(widths) if widths else 0
    full_width = row_width(bw)
    base_width = row_width(bw[int(h * 0.75):, :])
    if full_width < 1:
        return 0.0
    return float(np.clip(1.0 - (base_width / full_width), 0, 1))

def is_likely_broken_artifact(tex, reg, sym):
    return tex > 80 and reg > 0.55 and sym < 0.70

# ── Main prediction ──────────────────────────────────────────

def predict(image):
    print("=== PREDICT CALLED ===")
    if image is None:
        print("=== IMAGE IS NONE ===")
        return "Please upload an image."
    
    print("=== CONVERTING IMAGE ===")
    img_np = np.array(image.convert("RGB").resize(IMG_SIZE))
    print(f"=== IMAGE SHAPE: {img_np.shape} ===")
    
    tex_score = texture_complexity_score(img_np)
    print(f"=== TEX SCORE: {tex_score} ===")
    reg_score  = edge_regularity_score(img_np)
    sym_score  = symmetry_score(img_np)
    stem_score = stem_notch_score(img_np)

    lines = []
    lines.append("=" * 55)
    lines.append("  FEATURE ANALYSIS (archaeological gates)")
    lines.append("=" * 55)
    lines.append(f"  Texture complexity  : {tex_score:.1f}  (>15 = real object, >80 = worked stone)")
    lines.append(f"  Edge regularity     : {reg_score:.3f} (>0.30 = systematic flaking)")
    lines.append(f"  Bilateral symmetry  : {sym_score:.3f} (>0.38 = hafted point geometry)")
    lines.append(f"  Stem/notch evidence : {stem_score:.3f} (>0.25 = base constriction for hafting)")
    lines.append("")

    lines.append(f"[Gate 1] Texture complexity: {tex_score:.1f}")
    if tex_score < TEXTURE_THRESHOLD:
        lines.append("  ❌ REJECTED β€” surface too uniform (not a real stone photo)")
        return "\n".join(lines)

    lines.append(f"[Gate 2] Edge regularity: {reg_score:.3f}")

    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, c in enumerate(class_names) if "notartifact" not in c.lower())
    broken = is_likely_broken_artifact(tex_score, reg_score, sym_score)

    lines.append("")
    lines.append("  Model raw scores:")
    merged_scores = {}
    for i, cls in enumerate(class_names):
        lbl = display_category(cls)
        merged_scores[lbl] = merged_scores.get(lbl, 0.0) + float(preds[i])
    top_display = display_category(top_cls)
    for lbl, score in merged_scores.items():
        bar = "β–ˆ" * int(score * 30)
        tag = " ← predicted" if lbl == top_display else ""
        lines.append(f"    {lbl:22s} {score:.3f}  {bar}{tag}")

    lines.append("")
    lines.append(f"  Top class   : {display_category(top_cls)}")
    lines.append(f"  Confidence  : {top_conf:.3f}")
    lines.append(f"  Entropy     : {ent:.3f}")
    lines.append(f"  Artifact sum: {artifact_score_sum:.3f} (combined non-notartifact score)")

    if broken:
        lines.append("")
        lines.append(f"  πŸ” FRAGMENT INDICATOR: High texture ({tex_score:.0f}) + "
                     f"High regularity ({reg_score:.2f}) + Reduced symmetry ({sym_score:.2f})")
        lines.append("     β†’ Characteristic signature of a BROKEN/FRAGMENTARY worked stone tool")

    override_triggered = False
    if top_cls == "notartifact":
        if tex_score > 80 and reg_score > 0.55 and artifact_score_sum > ARTIFACT_OVERRIDE_THR:
            artifact_scores = {c: float(preds[i]) for i, c in enumerate(class_names) if i != notartifact_idx}
            top_cls  = max(artifact_scores, key=artifact_scores.get)
            top_conf = artifact_scores[top_cls]
            override_triggered = True
            lines.append("")
            lines.append("  ⚑ ARCHAEOLOGICAL OVERRIDE TRIGGERED")
            lines.append(f"     Overriding notartifact β†’ {display_category(top_cls)}")
        else:
            lines.append("")
            lines.append("  βœ… PREDICTION: NON-ARTIFACTS")
            lines.append(f"     Feature gates did not trigger override:")
            lines.append(f"       Texture {tex_score:.0f}, Regularity {reg_score:.2f}, "
                         f"Artifact sum {artifact_score_sum:.2f}")
            return "\n".join(lines)

    lines.append(f"[Gate 3] Bilateral symmetry: {sym_score:.3f}")
    if "arrowhead" in top_cls.lower():
        if sym_score < SYMMETRY_THRESHOLD:
            if broken or override_triggered:
                lines.append(f"  ⚠️  Low symmetry ({sym_score:.2f}) but broken fragment detected β€” gate bypassed")
            else:
                lines.append(f"  ⚠️  Low symmetry for arrowhead prediction β€” may be debitage")

    lines.append(f"[Gate 4] Confidence: {top_conf:.3f} | Entropy: {ent:.3f}")
    if not override_triggered:
        if top_conf < CONF_THRESHOLD or ent > ENTROPY_THRESHOLD:
            lines.append("")
            lines.append("  ⚠️  NOT CONFIDENT β€” ambiguous result")
            lines.append("     (Broken or unusual specimen β€” consider manual review)")
            return "\n".join(lines)

    lines.append("")
    disp = display_category(top_cls)
    if override_triggered:
        lines.append(f"  βœ… FINAL PREDICTION: {disp.upper()} (via archaeological override)")
        lines.append(f"     (Internal model class: {top_cls})")
    else:
        lines.append(f"  βœ… PREDICTION: {disp.upper()}")
        lines.append(f"     (Internal model class: {top_cls})")

    if broken:
        lines.append("  πŸ“Œ NOTE: Fragmentary/broken specimen. Morphology may be incomplete.")
    if stem_score > 0.25 and ("arrowhead" in top_cls.lower() or override_triggered):
        lines.append(f"  πŸ“Œ Stem/notch evidence detected (score {stem_score:.2f}) β€” consistent with hafted projectile point")

    interp = {
        "arrowhead": (
            "Projectile point. Key features to verify:\n"
            "     β€’ Bifacial flaking on both faces\n"
            "     β€’ Thinned cross-section (blade thinner than base)\n"
            "     β€’ Stem or notching for hafting\n"
            "     β€’ Regular, intentional edge serrations\n"
            "     β€’ Conchoidal fracture surface texture (not granular)"
        ),
        "scraper": (
            "Scraper. Key features to verify:\n"
            "     β€’ Unifacial working (one face only)\n"
            "     β€’ Steep retouch angle on working edge (~70Β°)\n"
            "     β€’ Cortex usually present on dorsal face\n"
            "     β€’ No stem or hafting preparation"
        ),
        "axehammerstone": (
            "Axe/Hammerstone. Key features to verify:\n"
            "     β€’ Pecking marks from repeated impact use\n"
            "     β€’ Ground or polished facets\n"
            "     β€’ Heavier, more rounded form\n"
            "     β€’ May show battered/crushed areas"
        ),
    }
    for key, msg in interp.items():
        if key in top_cls.lower():
            lines.append("")
            lines.append("  πŸ“‹ Archaeological checklist:")
            lines.append(f"     {msg}")
            break

    lines.append("=" * 55)
    return "\n".join(lines)

# ── Gradio UI ────────────────────────────────────────────────
with gr.Blocks(title="Unearthed β€” Artifact Classifier") as demo:
    gr.Markdown("""
    # πŸͺ¨ Unearthed β€” Artifact Classifier
    Upload a photo of a stone object to classify it.
    **Categories: Projectile Points Β· Other Lithics Β· Non-Artifacts**
    """)
    with gr.Row():
        with gr.Column():
            image_input = gr.Image(type="pil", label="Upload image")
            submit_btn  = gr.Button("Classify", variant="primary")
        with gr.Column():
            output_text = gr.Textbox(
                label="Analysis Result",
                lines=30,
                max_lines=40,
            )

    submit_btn.click(fn=predict, inputs=image_input, outputs=output_text)

demo.launch()