File size: 7,040 Bytes
4fa696a
0bbf613
 
 
 
4fa696a
 
 
 
 
 
0bbf613
 
 
 
4fa696a
 
 
 
 
 
 
 
 
0bbf613
4fa696a
 
 
0bbf613
4fa696a
 
 
 
 
 
 
0bbf613
 
 
4fa696a
0bbf613
 
 
 
 
 
4fa696a
0bbf613
 
4fa696a
 
0bbf613
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4fa696a
 
0bbf613
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4fa696a
0bbf613
 
 
4fa696a
0bbf613
4fa696a
 
 
 
 
0bbf613
4fa696a
 
 
0bbf613
4fa696a
 
0bbf613
 
 
 
 
 
 
 
 
 
 
4fa696a
 
 
 
 
 
 
 
0bbf613
 
 
 
 
 
 
 
 
4fa696a
0bbf613
4fa696a
0bbf613
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
import os
import io
import base64
import time

import gradio as gr
import numpy as np
from PIL import Image
from ultralytics import YOLO
from huggingface_hub import hf_hub_download

from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.responses import JSONResponse
import uvicorn

# ── Model ──────────────────────────────────────────────────────────────────────
MODEL_REPO = "DannyLuna/recaptcha-classification-57k"
MODEL_FILE = "recaptcha_classification_57k.onnx"
MODEL_PATH = os.path.join("models", MODEL_FILE)

os.makedirs("models", exist_ok=True)

if not os.path.exists(MODEL_PATH):
    print(f"[INFO] Downloading {MODEL_FILE} from {MODEL_REPO}...")
    hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE, local_dir="models")
    print("[INFO] Download complete.")

model = YOLO(MODEL_PATH, task="classify")
print(f"[INFO] Model loaded β€” {len(model.names)} classes")

TARGET_CLASSES = {
    "bicycle", "bridge", "bus", "car", "chimney", "crosswalk",
    "fire hydrant", "motorcycle", "mountain", "palm tree",
    "stairs", "tractor", "traffic light",
}

# ── Shared inference logic ─────────────────────────────────────────────────────
def run_inference(image: Image.Image) -> dict:
    t0 = time.perf_counter()
    results = model(image, verbose=False)
    elapsed = time.perf_counter() - t0

    probs     = results[0].probs
    top1_idx  = int(probs.top1)
    top1_name = model.names[top1_idx]
    top1_conf = float(probs.top1conf)

    all_scores = {
        model.names[i]: round(float(probs.data[i]), 6)
        for i in range(len(model.names))
    }
    top5 = sorted(all_scores.items(), key=lambda x: x[1], reverse=True)[:5]

    return {
        "predicted_class":  top1_name,
        "confidence":       round(top1_conf, 6),
        "is_target":        top1_name in TARGET_CLASSES,
        "top5":             [{"class": c, "confidence": s} for c, s in top5],
        "all_scores":       all_scores,
        "inference_ms":     round(elapsed * 1000, 2),
    }

# ── FastAPI ────────────────────────────────────────────────────────────────────
app = FastAPI(
    title="reCAPTCHA Classifier API",
    description=(
        "YOLO-based image classification fine-tuned on 57k reCAPTCHA tiles.\n\n"
        "**14 classes:** bicycle, bridge, bus, car, chimney, crosswalk, "
        "fire hydrant, motorcycle, mountain, palm tree, stairs, tractor, "
        "traffic light, other."
    ),
    version="1.0.0",
)


@app.get("/api/health")
def health():
    """Quick liveness check."""
    return {"status": "ok", "model": MODEL_FILE, "classes": len(model.names)}


@app.get("/api/classes")
def classes():
    """Return all class names the model knows about."""
    return {
        "all":    list(model.names.values()),
        "target": sorted(TARGET_CLASSES),
        "other":  ["other"],
    }


@app.post("/api/predict")
async def predict_file(file: UploadFile = File(...)):
    """
    Classify an uploaded image file.

    - **file**: JPEG / PNG image to classify.

    Returns the predicted class, confidence, top-5 scores, and inference time.
    """
    if not file.content_type.startswith("image/"):
        raise HTTPException(status_code=415, detail="File must be an image (JPEG/PNG).")

    raw = await file.read()
    try:
        image = Image.open(io.BytesIO(raw)).convert("RGB")
    except Exception:
        raise HTTPException(status_code=400, detail="Could not decode image.")

    result = run_inference(image)
    return JSONResponse(content=result)


@app.post("/api/predict/base64")
async def predict_base64(payload: dict):
    """
    Classify an image sent as a base64-encoded string.

    Body JSON:
    ```json
    { "image": "<base64 string>" }
    ```
    Optionally wrap with data-URI prefix (`data:image/jpeg;base64,...`).
    """
    b64 = payload.get("image", "")
    if not b64:
        raise HTTPException(status_code=422, detail="Missing 'image' field.")

    # Strip optional data-URI prefix
    if "," in b64:
        b64 = b64.split(",", 1)[1]

    try:
        raw   = base64.b64decode(b64)
        image = Image.open(io.BytesIO(raw)).convert("RGB")
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid base64 image data.")

    result = run_inference(image)
    return JSONResponse(content=result)


# ── Gradio UI ─────────────────────────────────────────────────────────────────
def gradio_predict(image: Image.Image):
    if image is None:
        return {}, "⚠️ No image provided."

    r = run_inference(image)
    emoji  = "βœ…" if r["is_target"] else "🚫"
    status = (
        f"{emoji} **{r['predicted_class'].upper()}** β€” {r['confidence']:.1%} confidence"
        f"\n\n⏱ Inference: {r['inference_ms']} ms"
        + ("" if r["is_target"] else "\n\n*(classified as `other` / background)*")
    )
    conf_dict = {item["class"]: item["confidence"] for item in r["top5"]}
    return conf_dict, status


CLASSES_MD = ", ".join(f"`{c}`" for c in sorted(TARGET_CLASSES))

with gr.Blocks(title="reCAPTCHA Classifier", theme=gr.themes.Soft()) as ui:
    gr.Markdown(
        f"""
# πŸ” reCAPTCHA Image Classifier
YOLO fine-tuned on **57k reCAPTCHA tiles** β€” also available as a REST API.

**Target classes:** {CLASSES_MD} + `other`

---
### API endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET  | `/api/health` | Liveness check |
| GET  | `/api/classes` | List all classes |
| POST | `/api/predict` | Upload image file |
| POST | `/api/predict/base64` | Send base64 image |

Full docs β†’ [**Swagger UI**](/docs) Β· [**ReDoc**](/redoc)
"""
    )

    with gr.Row():
        with gr.Column(scale=1):
            img_input = gr.Image(type="pil", label="Upload tile image")
            run_btn   = gr.Button("πŸš€ Classify", variant="primary")
        with gr.Column(scale=1):
            status_out = gr.Markdown(label="Result")
            label_out  = gr.Label(num_top_classes=5, label="Top-5 probabilities")

    run_btn.click(fn=gradio_predict, inputs=img_input, outputs=[label_out, status_out])
    img_input.change(fn=gradio_predict, inputs=img_input, outputs=[label_out, status_out])


# Mount Gradio on FastAPI at root
app = gr.mount_gradio_app(app, ui, path="/")

# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=7860)