import os import sys import logging import traceback import warnings import re warnings.filterwarnings("ignore") import numpy as np import gradio as gr from PIL import Image, ImageDraw, ImageFont logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s — %(message)s", handlers=[logging.StreamHandler(sys.stdout)], ) logger = logging.getLogger("cocoscan-classifier") logger.info("Logger initialised.") logger.info(f"Gradio version: {gr.__version__}") MODEL_PATH = "best.pt" model = None try: from ultralytics import YOLO if os.path.exists(MODEL_PATH): model = YOLO(MODEL_PATH) logger.info(f"Model loaded: {MODEL_PATH}") else: logger.warning(f"best.pt not found at '{MODEL_PATH}'. Running in fallback mode.") except Exception as e: logger.error(f"Failed to load model: {e}\n{traceback.format_exc()}") model = None DEFAULT_CLASSES = [ "infested by CRB", "infestation from other pest", "not infested", "unspecified", ] CONFIDENCE_THRESHOLDS = { "not infested": 0.50, "infested by CRB": 0.45, "infestation from other pest": 0.45, "unspecified": 0.30, } CLASS_COLORS = { "not infested": "#2ecc71", "infested by CRB": "#e74c3c", "infestation from other pest": "#3498db", "unspecified": "#95a5a6", } # Keep this small and safe: # - normalize common spacing/case # - map any legacy/variant labels into the 4 final labels NAME_NORMALIZATION = { "crb infestation": "infested by CRB", "crb-infestation": "infested by CRB", "crb_infestation": "infested by CRB", "infested-by-crb": "infested by CRB", "infested_by_crb": "infested by CRB", "unhealthy": "infestation from other pest", "other-pest-damage": "infestation from other pest", "other_pest_damage": "infestation from other pest", "healthy": "not infested", "not-infested": "not infested", "not_infested": "not infested", "unknown": "unspecified", "uncertain": "unspecified", "unclear": "unspecified", } def normalize_class_name(name: str) -> str: if not isinstance(name, str): return str(name) key = name.strip().lower() key = re.sub(r"\s+", " ", key) return NAME_NORMALIZATION.get(key, name.strip()) def health_cascade(probs: dict) -> tuple: ranked = sorted(probs.items(), key=lambda x: float(x[1]), reverse=True) if not ranked: return "not infested", 0.0 for cls_name, conf in ranked: threshold = CONFIDENCE_THRESHOLDS.get(cls_name, 0.30) if float(conf) >= float(threshold): return cls_name, float(conf) return ranked[0] def multi_run_predict(image: Image.Image, runs: int = 3) -> dict: if model is None: return {} accumulated = {} imgsz_list = [224, 256, 192] for i in range(runs): imgsz = imgsz_list[i % len(imgsz_list)] try: result = model(image, imgsz=imgsz, verbose=False)[0] names = result.names probs = result.probs.data.cpu().numpy() for idx, prob in enumerate(probs): cls_name = names.get(idx, f"class_{idx}") cls_name = normalize_class_name(cls_name) accumulated[cls_name] = accumulated.get(cls_name, 0.0) + float(prob) except Exception as e: logger.warning(f"Run {i+1} failed: {e}") continue if not accumulated: return {} averaged = {k: v / runs for k, v in accumulated.items()} for c in DEFAULT_CLASSES: averaged.setdefault(c, 0.0) return averaged def predict_classification(image: Image.Image) -> dict: if image is None: return { "success": False, "class": "not infested", "confidence": 0.0, "all_probs": {}, "message": "No image provided.", } image = image.convert("RGB") if model is None: return { "success": True, "class": "not infested", "confidence": 0.0, "all_probs": {c: 0.0 for c in DEFAULT_CLASSES}, "message": "Model not available. Please put best.pt beside app.py and restart.", } try: avg_probs = multi_run_predict(image, runs=3) if not avg_probs: raise ValueError("No probabilities returned from model.") predicted_class, confidence = health_cascade(avg_probs) predicted_class = normalize_class_name(predicted_class) logger.info(f"Prediction: {predicted_class} ({confidence:.4f})") return { "success": True, "class": predicted_class, "confidence": round(float(confidence), 4), "all_probs": {k: round(float(v), 4) for k, v in avg_probs.items()}, "message": "Classification successful.", } except Exception as e: logger.error(f"Prediction error: {e}\n{traceback.format_exc()}") return { "success": True, "class": "not infested", "confidence": 0.0, "all_probs": {c: 0.0 for c in DEFAULT_CLASSES}, "message": f"Prediction failed: {str(e)}", } def _escape_html(s: str) -> str: return str(s).replace("&", "&").replace("<", "<").replace(">", ">") def predict_on_image(input_image): if input_image is None: blank = Image.new("RGB", (420, 220), color="#1a1a2e") draw = ImageDraw.Draw(blank) draw.text((80, 100), "Please upload an image.", fill="white") return blank, "
Upload an image to classify it into:
INFESTED BY CRB, INFESTATION FROM OTHER PEST, NOT INFESTED, UNSPECIFIED
Model: YOLOv8-cls · 4 classes · Multi-run averaging