| |
| """ |
| Head-to-head rice benchmark: our retrained EfficientNetB0 vs the pretrained |
| SigLIP2 model (prithivMLmods/Rice-Leaf-Disease), on the SAME held-out real test |
| split that our model did not train on. |
| |
| This tells us how much accuracy headroom (if any) the pretrained transformer |
| leaves on the table — i.e. whether our lightweight 8.8MB model is "good enough" |
| or the 370MB SigLIP2 is meaningfully better on real images. |
| |
| Caveat: SigLIP2's training data is unknown; if it was trained on this same |
| public dataset, its score here is optimistic. The truly neutral comparison is |
| on your own field photos (run both via this script's --test-dir mode). |
| |
| Usage: |
| python -m ml.scripts.benchmark_rice # held-out real test split |
| python -m ml.scripts.benchmark_rice --test-dir ml/field_test/rice |
| """ |
| import argparse |
| import os |
| import sys |
| from pathlib import Path |
|
|
| os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3") |
|
|
| ROOT = Path(__file__).resolve().parents[2] |
| sys.path.insert(0, str(ROOT)) |
|
|
| import numpy as np |
|
|
| SIGLIP_DIR = ROOT / "ml" / "models_pretrained" / "rice_siglip2" |
|
|
| |
| SIGLIP_TO_OURS = { |
| "Bacterialblight": "Bacterial Leaf Blight", |
| "Blast": "Rice Blast", |
| "Brownspot": "Brown Spot", |
| "Healthy": "Healthy", |
| "Tungro": "__tungro__", |
| } |
|
|
|
|
| def _confusion(y_true, y_pred, labels): |
| idx = {l: i for i, l in enumerate(labels)} |
| cm = np.zeros((len(labels), len(labels)), dtype=int) |
| extra = {} |
| for t, p in zip(y_true, y_pred): |
| if p in idx: |
| cm[idx[t]][idx[p]] += 1 |
| else: |
| extra[p] = extra.get(p, 0) + 1 |
| return cm, extra |
|
|
|
|
| def _print_cm(cm, labels, extra=None): |
| w = max(14, max(len(l) for l in labels) + 2) |
| print(f"{'true/pred':>{w}}" + "".join(f"{l[:w-1]:>{w}}" for l in labels)) |
| for i, l in enumerate(labels): |
| print(f"{l[:w-1]:>{w}}" + "".join(f"{v:>{w}}" for v in cm[i])) |
| acc = cm.trace() / cm.sum() if cm.sum() else 0.0 |
| print(f" accuracy = {acc:.1%} ({cm.trace()}/{cm.sum()})") |
| if extra: |
| print(f" predictions outside our catalog: {extra}") |
| print(" per-class recall:") |
| for i, l in enumerate(labels): |
| tot = cm[i].sum() |
| print(f" {l:<24} {cm[i,i]:>4}/{tot:<4} = {(cm[i,i]/tot if tot else 0):.1%}") |
| return acc |
|
|
|
|
| def load_test_split(crop="rice"): |
| """Return (images[0,1] float, label_names) for the held-out real test split.""" |
| from ml.utils.data_loader import CropDatasetLoader |
| loader = CropDatasetLoader(crop) |
| imgs, labels, class_names = loader.load_dataset() |
| loader.create_data_generators(imgs, labels) |
| X_test, y_test = loader.get_test_set() |
| y_names = [class_names[int(i)] for i in y_test] |
| return X_test, y_names, class_names |
|
|
|
|
| def load_test_dir(test_dir: Path): |
| """Load a labeled folder (subdirs = true class) as ([0,1] images, names).""" |
| from PIL import Image |
| exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} |
| X, names = [], [] |
| for sub in sorted(d for d in test_dir.iterdir() if d.is_dir()): |
| for f in sub.iterdir(): |
| if f.suffix.lower() in exts: |
| im = Image.open(f).convert("RGB").resize((224, 224)) |
| X.append(np.asarray(im, dtype=np.float32) / 255.0) |
| names.append(sub.name) |
| return np.array(X, dtype=np.float32), names, sorted(set(names)) |
|
|
|
|
| def run_ours(X, class_names): |
| """Predict with our retrained model via KerasPredictor (expects [0,1]).""" |
| from ml.inference.keras_predictor import KerasPredictor |
| p = KerasPredictor("rice") |
| preds = [] |
| for i in range(len(X)): |
| probs = p.model.predict(X[i:i+1], verbose=0)[0] |
| preds.append(p.class_names[int(np.argmax(probs))]) |
| return preds, p.version |
|
|
|
|
| def run_siglip(X): |
| """Predict with SigLIP2 (expects PIL/uint8); map labels to our taxonomy.""" |
| import torch |
| from transformers import AutoImageProcessor, AutoModelForImageClassification |
| proc = AutoImageProcessor.from_pretrained(str(SIGLIP_DIR)) |
| model = AutoModelForImageClassification.from_pretrained(str(SIGLIP_DIR)) |
| model.eval() |
| id2label = model.config.id2label |
| from PIL import Image |
| preds = [] |
| with torch.no_grad(): |
| for i in range(len(X)): |
| pil = Image.fromarray((X[i] * 255).astype(np.uint8), "RGB") |
| inp = proc(images=pil, return_tensors="pt") |
| logits = model(**inp).logits |
| raw = id2label[int(logits.argmax(-1))] |
| preds.append(SIGLIP_TO_OURS.get(raw, raw)) |
| return preds |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--test-dir", default=None, |
| help="labeled folder (subdirs=classes); default uses held-out real test split") |
| args = ap.parse_args() |
|
|
| if args.test_dir: |
| print(f"Loading labeled test dir: {args.test_dir}") |
| X, y_true, labels = load_test_dir(Path(args.test_dir)) |
| else: |
| print("Loading held-out REAL test split (our model never trained on these)...") |
| X, y_true, labels = load_test_split("rice") |
| print(f"Test set: {len(X)} images, classes={labels}\n") |
|
|
| print("=" * 64) |
| print("MODEL A — our retrained EfficientNetB0 (8.8MB TFLite-class)") |
| print("=" * 64) |
| a_pred, ver = run_ours(X, labels) |
| cm_a, extra_a = _confusion(y_true, a_pred, labels) |
| acc_a = _print_cm(cm_a, labels, extra_a) |
| print(f" model version: {ver}") |
|
|
| print("\n" + "=" * 64) |
| print("MODEL B — pretrained SigLIP2 (prithivMLmods, ~370MB)") |
| print("=" * 64) |
| b_pred = run_siglip(X) |
| cm_b, extra_b = _confusion(y_true, b_pred, labels) |
| acc_b = _print_cm(cm_b, labels, extra_b) |
|
|
| print("\n" + "=" * 64) |
| print(f"RESULT: ours={acc_a:.1%} siglip2={acc_b:.1%} " |
| f"gap={ (acc_b-acc_a)*100:+.1f} pts") |
| print("=" * 64) |
| print("Note: if SigLIP2 trained on this public dataset, its score is optimistic.") |
| print("Re-run with --test-dir ml/field_test/rice on your own photos for a neutral test.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|