CoolFace
Apppublic

Tresor26/Coffee-beans-classification

sourceHugging Facemitupdated 2mo agoView on Hugging Face
1likes
main.py197 linesDownload Raw Back to api
1"""FastAPI service. All model work happens here; the UI is a pure client."""2import json3import threading4import time5import uuid6from contextlib import asynccontextmanager7 8from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile9from fastapi.responses import JSONResponse10from pydantic import BaseModel11 12from src import config, database, model as model_module, prediction, preprocessing13 14START_TIME = time.time()15 16_JOBS: dict = {}17_retrain_lock = threading.Lock()18 19 20@asynccontextmanager21async def lifespan(app: FastAPI):22    database.init_db()23    try:24        prediction.load_champion()25        prediction.warmup()26    except Exception as exc:  # noqa: BLE001 — service must start to report this27        print(f"WARNING: could not load champion model: {exc}")28    yield29 30 31app = FastAPI(32    title="Coffee Bean Grading API",33    description="Grades green Arabica coffee beans into four classes.",34    version="1.0.0",35    lifespan=lifespan,36)37 38 39class RetrainRequest(BaseModel):40    epochs: int | None = None41    replay_n: int | None = None42    force: bool = False43 44 45@app.get("/api/health")46def health():47    """Liveness only. Deliberately does not touch the model."""48    return {"status": "ok"}49 50 51@app.get("/api/status")52def status():53    counts = preprocessing.pending_counts()54    pending_total = sum(counts.values())55    stats = database.prediction_stats()56    champion = database.get_champion()57    return {58        "status": "ok",59        "uptime_seconds": round(time.time() - START_TIME, 1),60        "model_loaded": prediction.is_ready(),61        "model_version": prediction.get_version(),62        "model_accuracy": champion["accuracy"] if champion else None,63        "classes": config.CLASS_NAMES,64        "pending_counts": counts,65        "pending_total": pending_total,66        "retrain_threshold": config.RETRAIN_THRESHOLD,67        "retrain_ready": pending_total >= config.RETRAIN_THRESHOLD,68        "predictions_served": stats["total"],69        "mean_latency_ms": stats["mean_latency_ms"],70        "p95_latency_ms": stats["p95_latency_ms"],71        "class_counts": stats["class_counts"],72    }73 74 75@app.post("/api/predict")76async def predict(file: UploadFile = File(...)):77    if not prediction.is_ready():78        raise HTTPException(503, "Model is not loaded")79    payload = await file.read()80    try:81        return prediction.predict_image(payload)82    except RuntimeError as exc:83        raise HTTPException(503, str(exc)) from exc84    except Exception as exc:  # noqa: BLE00185        raise HTTPException(415, "Could not decode image") from exc86 87 88@app.post("/api/upload")89async def upload(file: UploadFile = File(...)):90    payload = await file.read()91    if len(payload) > config.MAX_UPLOAD_BYTES:92        raise HTTPException(413, "Upload exceeds the maximum size")93    try:94        result = preprocessing.stage_upload(payload, str(uuid.uuid4())[:8])95    except ValueError as exc:96        raise HTTPException(400, str(exc)) from exc97    result["retrain_threshold"] = config.RETRAIN_THRESHOLD98    result["retrain_ready"] = result["pending_total"] >= config.RETRAIN_THRESHOLD99    return result100 101 102def _run_retrain_job(run_id: int, epochs, replay_n) -> None:103    """Synchronous by design.104 105    Starlette dispatches sync background tasks to a threadpool. An async def106    here would block the event loop and freeze the status polling that draws107    the progress log.108    """109    job = _JOBS[run_id]110    try:111        result = model_module.retrain(112            progress_cb=job["log"].append, epochs=epochs, replay_n=replay_n)113        job.update(status="completed", promoted=result["promoted"],114                   metrics=result["metrics"],115                   candidate_accuracy=result["candidate_accuracy"],116                   champion_accuracy=result["champion_accuracy"])117        database.finish_retrain_run(118            run_id, "completed", result["candidate_accuracy"],119            result["champion_accuracy"], result["promoted"],120            result["model_path"], "\n".join(job["log"]))121    except Exception as exc:  # noqa: BLE001122        job.update(status="failed", error=str(exc))123        job["log"].append(f"FAILED: {exc}")124        database.finish_retrain_run(125            run_id, "failed", None, None, False, None, "\n".join(job["log"]))126    finally:127        if _retrain_lock.locked():128            _retrain_lock.release()129 130 131@app.post("/api/retrain", status_code=202)132def trigger_retrain(request: RetrainRequest, background: BackgroundTasks):133    counts = preprocessing.pending_counts()134    pending_total = sum(counts.values())135    if pending_total == 0:136        raise HTTPException(422, "No pending images to retrain on")137    if pending_total < config.RETRAIN_THRESHOLD and not request.force:138        raise HTTPException(139            422,140            f"Only {pending_total} pending images; threshold is "141            f"{config.RETRAIN_THRESHOLD}. Upload more or use force.")142    if not _retrain_lock.acquire(blocking=False):143        raise HTTPException(409, "A retraining run is already in progress")144 145    epochs = request.epochs or config.RETRAIN_EPOCHS146    replay_n = request.replay_n or config.REPLAY_SAMPLES147    run_id = database.create_retrain_run(pending_total, replay_n, epochs)148    _JOBS[run_id] = {"status": "running", "log": [], "promoted": None,149                     "metrics": None, "error": None}150    background.add_task(_run_retrain_job, run_id, epochs, replay_n)151    return {"job_id": run_id, "status": "running",152            "n_pending": pending_total, "n_replay": replay_n}153 154 155# Declared before /api/retrain/{job_id} so "history" is not parsed as an int.156@app.get("/api/retrain/history")157def retrain_history():158    return {"runs": database.list_retrain_runs()}159 160 161@app.get("/api/retrain/{job_id}")162def retrain_status(job_id: int):163    job = _JOBS.get(job_id)164    if job is None:165        record = database.get_retrain_run(job_id)166        if record is None:167            raise HTTPException(404, f"No retraining job with id {job_id}")168        return {"job_id": job_id, "status": record["status"],169                "log": (record["log"] or "").splitlines(),170                "promoted": bool(record["promoted"]),171                "candidate_accuracy": record["candidate_accuracy"],172                "champion_accuracy": record["champion_accuracy"]}173    return {"job_id": job_id, **job}174 175 176@app.get("/api/insights")177def insights():178    if not config.INSIGHTS_PATH.exists():179        raise HTTPException(180            404, "Insights not generated. Run scripts/build_insights.py")181    return JSONResponse(json.loads(config.INSIGHTS_PATH.read_text()))182 183 184@app.get("/api/metrics")185def metrics():186    """Evaluate the deployed champion against the full test set.187 188    This is the production-evaluation surface: its numbers should line up189    with the notebook's.190    """191    if not prediction.is_ready():192        raise HTTPException(503, "Model is not loaded")193    ds = preprocessing.load_dataset(config.test_dir(), shuffle=False)194    result = model_module.evaluate(prediction.get_model(), ds)195    result["model_version"] = prediction.get_version()196    return result197