""" app.py — CropGuard FastAPI inference server ============================================ Loads the trained MobileNetV2 model and exposes a REST API used by both the React frontend and the single-file HTML app. Endpoints --------- GET /health -> {"status": "ok", "model_loaded": bool} POST /predict -> multipart image -> diagnosis JSON GET /diseases -> the full treatment knowledge base Diagnosis JSON shape (consumed by the frontends): { "class_id": "tomato_late", "confidence": 0.94, "severity": "moderate", # null when healthy "diseased_ratio": 0.42, "disease": { ...full record from recommendations.json... } } Run: uvicorn app:app --host 0.0.0.0 --port 8000 --reload """ import io, json, os import numpy as np from fastapi import FastAPI, File, UploadFile, HTTPException from fastapi.middleware.cors import CORSMiddleware from PIL import Image MODEL_DIR = os.getenv("MODEL_DIR", "model") IMG_SIZE = 224 MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) app = FastAPI(title="CropGuard GH API", version="1.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # --- lazy globals --- _model = None _classes = None with open(os.path.join(os.path.dirname(__file__), "recommendations.json")) as f: RECS = json.load(f) def get_model(): """Load the Keras model once and keep it resident in memory (§3.10.2).""" global _model, _classes if _model is None: import tensorflow as tf _model = tf.keras.models.load_model(os.path.join(MODEL_DIR, "crop_model.keras")) with open(os.path.join(MODEL_DIR, "classes.json")) as f: _classes = json.load(f) return _model, _classes def preprocess(img: Image.Image): img = img.convert("RGB").resize((IMG_SIZE, IMG_SIZE)) arr = np.asarray(img, dtype=np.float32) / 255.0 arr = (arr - MEAN) / STD return np.expand_dims(arr, 0) def estimate_severity(img: Image.Image): """Diseased-area ratio via colour thresholding (§3.8). Returns (severity_label, diseased_ratio).""" small = np.asarray(img.convert("RGB").resize((128, 128)), dtype=np.float32) r, g, b = small[..., 0], small[..., 1], small[..., 2] lum = small.mean(axis=2) mx = small.max(axis=2); mn = small.min(axis=2); sat = mx - mn leaf = ~((lum > 235) & (sat < 25)) # drop white background yellow = (r > 120) & (g > 100) & (b < 100) & (r - b > 40) dark = lum < 70 brown = (r > 70) & (r > g) & (g > b) & (r - b > 18) & (lum < 170) leaf_px = max(int(leaf.sum()), 1) ratio = float(((yellow & leaf).sum() * 0.85 + (brown & leaf).sum() * 1.05 + (dark & leaf).sum() * 1.10) / leaf_px) ratio = min(ratio, 1.0) label = "early" if ratio < 0.20 else "moderate" if ratio < 0.55 else "severe" return label, round(ratio, 3) @app.get("/health") def health(): return {"status": "ok", "model_loaded": _model is not None} @app.get("/diseases") def diseases(): return RECS @app.post("/predict") async def predict(file: UploadFile = File(...)): try: raw = await file.read() img = Image.open(io.BytesIO(raw)) except Exception: raise HTTPException(status_code=400, detail="Invalid image file") model, classes = get_model() probs = model.predict(preprocess(img), verbose=0)[0] idx = int(np.argmax(probs)) class_id = classes[idx] confidence = float(probs[idx]) record = RECS.get(class_id, {}) if record.get("healthy"): severity, ratio = None, 0.0 else: severity, ratio = estimate_severity(img) # The uploaded image is never stored — it is discarded here (§3.10.2 privacy). return { "class_id": class_id, "confidence": round(confidence, 4), "severity": severity, "diseased_ratio": ratio, "disease": record, }