| 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_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", |
| } |
|
|
| |
| 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), |
| } |
|
|
| |
| 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.") |
|
|
| |
| 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) |
|
|
|
|
| |
| 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]) |
|
|
|
|
| |
| app = gr.mount_gradio_app(app, ui, path="/") |
|
|
| |
| if __name__ == "__main__": |
| uvicorn.run(app, host="0.0.0.0", port=7860) |