Jaithra Polavarapu Claude Opus 4.8 (1M context) commited on
Commit ·
bd644f8
1
Parent(s): ca024a1
feat(gate): tune wrong-crop thresholds (MARGIN 0.12 / OTHER_MIN 0.80)
Browse filesFrom a 57-image cross-crop sweep: false-reject 5.6%->2.8%, catch ~78% held.
Adds sweep tool, crop-ID training scaffold, and production plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs/CROP_ID_GATE.md +86 -0
- ml/serve/inference_app.py +8 -2
- scripts/cross_crop_sweep.py +178 -0
- scripts/train_crop_id.py +97 -0
docs/CROP_ID_GATE.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wrong-crop gate & field-change: production hardening
|
| 2 |
+
|
| 3 |
+
This documents the current heuristic, what a 57-image cross-crop sweep showed,
|
| 4 |
+
and the two model-based upgrades that take both features to production grade.
|
| 5 |
+
|
| 6 |
+
## 1. Current state (shipped)
|
| 7 |
+
|
| 8 |
+
**Wrong-crop gate** (`ml/serve/inference_app.py::_cross_crop_check`): when the
|
| 9 |
+
selected crop's result is unsure, the image is scored against the other in-memory
|
| 10 |
+
crop models and the request is blocked (with a suggested crop) if a different
|
| 11 |
+
crop fits clearly better. Confident, in-catalog results skip the pass.
|
| 12 |
+
|
| 13 |
+
Thresholds were tuned from `scripts/cross_crop_sweep.py` (57 images, all 5 crops,
|
| 14 |
+
each scored by all 5 models):
|
| 15 |
+
|
| 16 |
+
| Thresholds (STRONG/MARGIN/OTHER_MIN) | False-reject | Catch | False-accept |
|
| 17 |
+
|---|---|---|---|
|
| 18 |
+
| 0.85 / 0.15 / 0.75 (initial) | 5.6% | 78.5% | 31 |
|
| 19 |
+
| **0.85 / 0.12 / 0.80 (deployed)** | **2.8%** | **78.5%** | 31 |
|
| 20 |
+
|
| 21 |
+
**Field-change comparison** (`lib/healthComparison.ts`): continuous expected
|
| 22 |
+
health over the model's full probability distribution (replaced 4 fixed
|
| 23 |
+
severity buckets). Detects healthy↔diseased shifts and uncertain/borderline
|
| 24 |
+
moves; cannot measure spread of a confidently-identical disease.
|
| 25 |
+
|
| 26 |
+
## 2. What the sweep proved about the ceiling
|
| 27 |
+
|
| 28 |
+
The heuristic compares five **disease** classifiers' confidences. That caps out
|
| 29 |
+
at ~78% catch / ~3% false-reject because:
|
| 30 |
+
|
| 31 |
+
- The lone false-reject is a rice leaf the **rice model itself** scores 50% on
|
| 32 |
+
(already "no clear match") while corn scores 85%.
|
| 33 |
+
- False-accepts concentrate on out-of-crop overconfidence, worst among the
|
| 34 |
+
grasses and the weak rice model: rice→corn 5/7 slip, wheat→soybean 4/8,
|
| 35 |
+
rice→{others} ~3/7.
|
| 36 |
+
|
| 37 |
+
Disease models are simply not calibrated for "is this even my crop?".
|
| 38 |
+
|
| 39 |
+
## 3. Upgrade A — dedicated crop-ID classifier (the real gate)
|
| 40 |
+
|
| 41 |
+
A single 5-class "which crop is this leaf?" model. Crops are far more visually
|
| 42 |
+
separable than diseases within a crop, so expect >>95% accuracy from a small
|
| 43 |
+
model. It replaces "compare five disease models" with one reliable signal, which
|
| 44 |
+
should eliminate the rice false-rejects and most cross-crop false-accepts.
|
| 45 |
+
|
| 46 |
+
**Data:** already on disk — every `ml/data/<crop>/**` image is implicitly
|
| 47 |
+
labeled by its crop. No new collection needed. Hold out by source/folder to
|
| 48 |
+
avoid the framing shortcut documented in the soybean/rice notes.
|
| 49 |
+
|
| 50 |
+
**Train (needs a TF environment — not runnable in the Py3.14 repo venv):**
|
| 51 |
+
```
|
| 52 |
+
python scripts/train_crop_id.py --epochs 8 --out ml/models/crop_id
|
| 53 |
+
```
|
| 54 |
+
Scaffold provided in `scripts/train_crop_id.py` (EfficientNetB0, in-model
|
| 55 |
+
rescaling, TFLite export — mirrors the per-crop pipeline).
|
| 56 |
+
|
| 57 |
+
**Serve:** load `crop_id` alongside the disease models; in `/predict`, run it
|
| 58 |
+
first. If `argmax(crop_id) != selected_crop` with margin ≥ τ, block and suggest
|
| 59 |
+
`argmax`. Keep the current heuristic as a fallback when the classifier is
|
| 60 |
+
absent. Re-run `scripts/cross_crop_sweep.py` to set τ and confirm
|
| 61 |
+
false-reject ≈ 0.
|
| 62 |
+
|
| 63 |
+
## 4. Upgrade B — severity / leaf-coverage model (real spread detection)
|
| 64 |
+
|
| 65 |
+
The field-change comparison can't quantify worsening of a confirmed disease
|
| 66 |
+
because the classifier outputs identity, not severity. A pixel-color
|
| 67 |
+
"% leaf affected" heuristic was prototyped and rejected — too crop-dependent
|
| 68 |
+
(healthy soybean read 53% "damaged").
|
| 69 |
+
|
| 70 |
+
**Right approach:** a severity regressor/segmenter (e.g. lesion-area
|
| 71 |
+
segmentation, or an ordinal severity head) trained on **severity-labeled** data
|
| 72 |
+
(PlantVillage severity subsets, or in-house annotation). Output a 0–100
|
| 73 |
+
affected-area per check; the comparison then trends the delta of a real
|
| 74 |
+
measurement instead of a label. This is a genuine data+training project, not a
|
| 75 |
+
config change.
|
| 76 |
+
|
| 77 |
+
## 5. Reproduce / re-tune
|
| 78 |
+
|
| 79 |
+
```
|
| 80 |
+
# against the live (rate-limited) space:
|
| 81 |
+
python scripts/cross_crop_sweep.py --url https://jaithrap-cropintel.hf.space --pace 3.3 --out sweep.json
|
| 82 |
+
# against a local service:
|
| 83 |
+
python scripts/cross_crop_sweep.py --url http://127.0.0.1:8000 --out sweep.json
|
| 84 |
+
# re-analyze only:
|
| 85 |
+
python scripts/cross_crop_sweep.py --analyze sweep.json
|
| 86 |
+
```
|
ml/serve/inference_app.py
CHANGED
|
@@ -88,9 +88,15 @@ _log_lock = threading.Lock()
|
|
| 88 |
# - Otherwise block only when another crop is itself confident AND beats the
|
| 89 |
# selected crop by a clear margin — so genuinely close calls stay accepted.
|
| 90 |
# All confidences below are fractions in [0, 1].
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
CROP_STRONG_CONF = float(os.environ.get("CROPINTEL_CROP_STRONG_CONF", "0.85"))
|
| 92 |
-
CROP_MISMATCH_MARGIN = float(os.environ.get("CROPINTEL_CROP_MISMATCH_MARGIN", "0.
|
| 93 |
-
CROP_OTHER_MIN_CONF = float(os.environ.get("CROPINTEL_CROP_OTHER_MIN_CONF", "0.
|
| 94 |
|
| 95 |
|
| 96 |
def _cross_crop_check(pil_image, selected_crop: str, selected_conf: float,
|
|
|
|
| 88 |
# - Otherwise block only when another crop is itself confident AND beats the
|
| 89 |
# selected crop by a clear margin — so genuinely close calls stay accepted.
|
| 90 |
# All confidences below are fractions in [0, 1].
|
| 91 |
+
# Defaults tuned from a 57-image cross-crop sweep against the live models
|
| 92 |
+
# (scripts/cross_crop_sweep.py): MARGIN 0.12 + OTHER_MIN 0.80 minimized
|
| 93 |
+
# false-rejects (valid leaves wrongly blocked: 5.6% -> 2.8%) with no loss of
|
| 94 |
+
# catch rate (~78%). The residual false-rejects/accepts trace to the disease
|
| 95 |
+
# models being overconfident on out-of-crop leaves (esp. the rice model); the
|
| 96 |
+
# durable fix is a dedicated crop-ID classifier — see docs/CROP_ID_GATE.md.
|
| 97 |
CROP_STRONG_CONF = float(os.environ.get("CROPINTEL_CROP_STRONG_CONF", "0.85"))
|
| 98 |
+
CROP_MISMATCH_MARGIN = float(os.environ.get("CROPINTEL_CROP_MISMATCH_MARGIN", "0.12"))
|
| 99 |
+
CROP_OTHER_MIN_CONF = float(os.environ.get("CROPINTEL_CROP_OTHER_MIN_CONF", "0.80"))
|
| 100 |
|
| 101 |
|
| 102 |
def _cross_crop_check(pil_image, selected_crop: str, selected_conf: float,
|
scripts/cross_crop_sweep.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cross-crop gate characterization + threshold tuning.
|
| 3 |
+
|
| 4 |
+
Sends sample leaf images through every crop model (via the running inference
|
| 5 |
+
service or a deployed URL) to capture, per image, each crop's top-1 confidence
|
| 6 |
+
and not-in-catalog flag. Then simulates the wrong-crop gate over a grid of
|
| 7 |
+
thresholds to report false-reject (valid leaf wrongly blocked) and catch
|
| 8 |
+
(wrong-crop correctly blocked) rates, and recommends thresholds.
|
| 9 |
+
|
| 10 |
+
This is how the defaults in ml/serve/inference_app.py were tuned. Re-run it
|
| 11 |
+
after retraining any crop model or adding a crop-ID classifier.
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python scripts/cross_crop_sweep.py --url http://127.0.0.1:8000 \
|
| 15 |
+
--per-crop 9 --out /tmp/sweep.json
|
| 16 |
+
python scripts/cross_crop_sweep.py --analyze /tmp/sweep.json
|
| 17 |
+
"""
|
| 18 |
+
import argparse
|
| 19 |
+
import io
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import time
|
| 23 |
+
from itertools import product
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
from PIL import Image
|
| 27 |
+
|
| 28 |
+
CROPS = ["corn", "soybean", "wheat", "rice", "tomato"]
|
| 29 |
+
DISEASE_KW = [
|
| 30 |
+
"blight", "rust", "spot", "mold", "blast", "virus", "mildew", "septoria",
|
| 31 |
+
"smut", "fusarium", "mosaic", "pustule", "death", "bacterial",
|
| 32 |
+
]
|
| 33 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def quality_ok(fp: str) -> bool:
|
| 37 |
+
"""Mirror inference_app's image-quality gate so we only probe usable photos."""
|
| 38 |
+
try:
|
| 39 |
+
im = Image.open(fp).convert("RGB")
|
| 40 |
+
w, h = im.size
|
| 41 |
+
if w < 200 or h < 200:
|
| 42 |
+
return False
|
| 43 |
+
a = np.asarray(im, dtype=np.float32)
|
| 44 |
+
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
|
| 45 |
+
if float(np.mean((g > 40) & (g > r * 1.05) & (g > b * 1.05))) < 0.03:
|
| 46 |
+
return False
|
| 47 |
+
gray = 0.299 * r + 0.587 * g + 0.114 * b
|
| 48 |
+
sv = float(np.var(np.concatenate(
|
| 49 |
+
[np.diff(gray, axis=1).ravel(), np.diff(gray, axis=0).ravel()])))
|
| 50 |
+
return sv >= 25.0
|
| 51 |
+
except Exception:
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def pick_images(crop: str, n_healthy: int, n_diseased: int):
|
| 56 |
+
h, d = [], []
|
| 57 |
+
base = os.path.join(ROOT, "ml", "data", crop)
|
| 58 |
+
for root, _, fs in os.walk(base):
|
| 59 |
+
low = root.lower()
|
| 60 |
+
for f in sorted(fs):
|
| 61 |
+
if not f.lower().endswith((".jpg", ".jpeg", ".png")):
|
| 62 |
+
continue
|
| 63 |
+
fp = os.path.join(root, f)
|
| 64 |
+
if not quality_ok(fp):
|
| 65 |
+
continue
|
| 66 |
+
if "healthy" in low and len(h) < n_healthy:
|
| 67 |
+
h.append(fp)
|
| 68 |
+
elif "healthy" not in low and any(k in low for k in DISEASE_KW) and len(d) < n_diseased:
|
| 69 |
+
d.append(fp)
|
| 70 |
+
if len(h) >= n_healthy and len(d) >= n_diseased:
|
| 71 |
+
break
|
| 72 |
+
return [("healthy", x) for x in h] + [("diseased", x) for x in d]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def predict(url: str, fp: str, crop: str) -> dict:
|
| 76 |
+
import requests # local import so --analyze works without requests
|
| 77 |
+
with open(fp, "rb") as fh:
|
| 78 |
+
r = requests.post(f"{url}/predict",
|
| 79 |
+
files={"image": fh}, data={"crop": crop}, timeout=45)
|
| 80 |
+
try:
|
| 81 |
+
d = r.json()
|
| 82 |
+
except Exception:
|
| 83 |
+
return {"err": "noparse"}
|
| 84 |
+
if "disease" not in d:
|
| 85 |
+
return {"err": str(d.get("error", "?"))[:40]}
|
| 86 |
+
return {"disease": d["disease"], "conf": d["confidence"],
|
| 87 |
+
"nic": bool(d.get("not_in_catalog")), "mismatch": bool(d.get("crop_mismatch"))}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def run_sweep(url: str, per_crop: int, out: str, pace: float):
|
| 91 |
+
n_h = max(1, per_crop // 2)
|
| 92 |
+
results = []
|
| 93 |
+
for tc in CROPS:
|
| 94 |
+
for kind, img in pick_images(tc, n_h, per_crop - n_h):
|
| 95 |
+
vec = {sc: predict(url, img, sc) for sc in CROPS
|
| 96 |
+
for _ in [time.sleep(pace)]}
|
| 97 |
+
results.append({"true_crop": tc, "kind": kind,
|
| 98 |
+
"img": os.path.basename(img), "vec": vec})
|
| 99 |
+
json.dump(results, open(out, "w"), indent=1)
|
| 100 |
+
print(f"{tc:8} {kind:8} self={vec[tc]}")
|
| 101 |
+
print(f"DONE -> {out}")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _frac(v):
|
| 105 |
+
return None if v.get("conf") is None else v["conf"] / 100.0
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _gate(sc, snic, others, strong, margin, other_min):
|
| 109 |
+
if sc is None:
|
| 110 |
+
return None
|
| 111 |
+
if sc >= strong and not snic:
|
| 112 |
+
return False
|
| 113 |
+
best = max([o for o in others if o is not None], default=0.0)
|
| 114 |
+
return best >= other_min and (best - sc) >= margin
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _evaluate(data, strong, margin, other_min):
|
| 118 |
+
fr = fr_tot = catch = cross_tot = fa = 0
|
| 119 |
+
for r in data:
|
| 120 |
+
T, v = r["true_crop"], r["vec"]
|
| 121 |
+
if "err" in v.get(T, {}):
|
| 122 |
+
continue
|
| 123 |
+
others = [_frac(v[c]) for c in CROPS if c != T and "err" not in v.get(c, {})]
|
| 124 |
+
g = _gate(_frac(v[T]), v[T].get("nic", False), others, strong, margin, other_min)
|
| 125 |
+
if g is not None:
|
| 126 |
+
fr_tot += 1
|
| 127 |
+
fr += int(g)
|
| 128 |
+
for sx in CROPS:
|
| 129 |
+
if sx == T or "err" in v.get(sx, {}):
|
| 130 |
+
continue
|
| 131 |
+
oth = [_frac(v[c]) for c in CROPS if c != sx and "err" not in v.get(c, {})]
|
| 132 |
+
gg = _gate(_frac(v[sx]), v[sx].get("nic", False), oth, strong, margin, other_min)
|
| 133 |
+
if gg is None:
|
| 134 |
+
continue
|
| 135 |
+
cross_tot += 1
|
| 136 |
+
catch += int(gg)
|
| 137 |
+
fa += int(not gg)
|
| 138 |
+
return dict(fr=fr, fr_tot=fr_tot, fr_rate=fr / fr_tot if fr_tot else 0,
|
| 139 |
+
catch=catch, cross_tot=cross_tot,
|
| 140 |
+
catch_rate=catch / cross_tot if cross_tot else 0, fa=fa)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def analyze(path: str):
|
| 144 |
+
data = json.load(open(path))
|
| 145 |
+
print(f"records: {len(data)}")
|
| 146 |
+
cur = _evaluate(data, 0.85, 0.12, 0.80)
|
| 147 |
+
print(f"\nDEPLOYED (0.85/0.12/0.80): "
|
| 148 |
+
f"false-reject {cur['fr']}/{cur['fr_tot']} ({cur['fr_rate']*100:.1f}%), "
|
| 149 |
+
f"catch {cur['catch']}/{cur['cross_tot']} ({cur['catch_rate']*100:.1f}%), "
|
| 150 |
+
f"false-accept {cur['fa']}")
|
| 151 |
+
best = None
|
| 152 |
+
for strong, margin, other_min in product(
|
| 153 |
+
[0.80, 0.82, 0.85, 0.88, 0.90], [0.10, 0.12, 0.15, 0.18, 0.22, 0.25],
|
| 154 |
+
[0.70, 0.75, 0.80, 0.85]):
|
| 155 |
+
m = _evaluate(data, strong, margin, other_min)
|
| 156 |
+
key = (m["fr_rate"], -m["catch_rate"])
|
| 157 |
+
if best is None or key < best[0]:
|
| 158 |
+
best = (key, (strong, margin, other_min), m)
|
| 159 |
+
(_, p, m) = best
|
| 160 |
+
print(f"\nGRID BEST (min false-reject, then max catch): "
|
| 161 |
+
f"STRONG={p[0]} MARGIN={p[1]} OTHER_MIN={p[2]} -> "
|
| 162 |
+
f"false-reject {m['fr_rate']*100:.1f}%, catch {m['catch_rate']*100:.1f}%")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
if __name__ == "__main__":
|
| 166 |
+
ap = argparse.ArgumentParser()
|
| 167 |
+
ap.add_argument("--url", default="http://127.0.0.1:8000")
|
| 168 |
+
ap.add_argument("--per-crop", type=int, default=9)
|
| 169 |
+
ap.add_argument("--out", default="/tmp/cross_crop_sweep.json")
|
| 170 |
+
ap.add_argument("--pace", type=float, default=0.2,
|
| 171 |
+
help="seconds between calls (raise to ~3.3 against a rate-limited host)")
|
| 172 |
+
ap.add_argument("--analyze", help="analyze an existing results JSON and exit")
|
| 173 |
+
a = ap.parse_args()
|
| 174 |
+
if a.analyze:
|
| 175 |
+
analyze(a.analyze)
|
| 176 |
+
else:
|
| 177 |
+
run_sweep(a.url, a.per_crop, a.out, a.pace)
|
| 178 |
+
analyze(a.out)
|
scripts/train_crop_id.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train the crop-ID classifier: "which of the 5 crops is this leaf?"
|
| 3 |
+
|
| 4 |
+
This is the durable replacement for the heuristic wrong-crop gate (see
|
| 5 |
+
docs/CROP_ID_GATE.md). Crops are far more visually separable than diseases
|
| 6 |
+
within a crop, so a small transfer-learned model should reach very high
|
| 7 |
+
accuracy and remove the cross-crop false-accepts/rejects the heuristic can't.
|
| 8 |
+
|
| 9 |
+
Data: reuses the existing per-crop image folders — every image under
|
| 10 |
+
ml/data/<crop>/** is implicitly labeled by <crop>. No new data needed.
|
| 11 |
+
|
| 12 |
+
NOTE: requires the ML training environment (TensorFlow). It will NOT run in the
|
| 13 |
+
repo's Py3.14 inference venv. Run it where the per-crop trainer runs.
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
python scripts/train_crop_id.py --epochs 8 --out ml/models/crop_id
|
| 17 |
+
"""
|
| 18 |
+
import argparse
|
| 19 |
+
import json
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import tensorflow as tf
|
| 24 |
+
|
| 25 |
+
from ml.config import CROPS, DATA_DIR, MODEL_CONFIG
|
| 26 |
+
from ml.utils.model_builder import build_model, unfreeze_model
|
| 27 |
+
from ml.utils.tflite_converter import convert_to_tflite
|
| 28 |
+
|
| 29 |
+
CROP_NAMES = list(CROPS.keys()) # stable label order: corn, soybean, wheat, rice, tomato
|
| 30 |
+
IMG_SIZE = MODEL_CONFIG["input_shape"][:2]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def make_datasets(val_split: float, batch: int, seed: int):
|
| 34 |
+
"""Label = crop folder name. image_dataset_from_directory recurses into each
|
| 35 |
+
crop's nested disease subfolders, so every leaf image is labeled by its crop.
|
| 36 |
+
|
| 37 |
+
IMPORTANT: deliver [0, 1] floats and DO NOT use brightness augmentation —
|
| 38 |
+
ImageDataGenerator(brightness_range) zeroes [0,1] float images (see
|
| 39 |
+
feedback_imagedatagen_brightness_bug). The model carries its own rescaling.
|
| 40 |
+
"""
|
| 41 |
+
common = dict(directory=str(DATA_DIR), labels="inferred", label_mode="int",
|
| 42 |
+
class_names=CROP_NAMES, image_size=IMG_SIZE, batch_size=batch, seed=seed)
|
| 43 |
+
train = tf.keras.utils.image_dataset_from_directory(
|
| 44 |
+
validation_split=val_split, subset="training", **common)
|
| 45 |
+
val = tf.keras.utils.image_dataset_from_directory(
|
| 46 |
+
validation_split=val_split, subset="validation", **common)
|
| 47 |
+
norm = tf.keras.layers.Rescaling(1.0 / 255)
|
| 48 |
+
aug = tf.keras.Sequential([
|
| 49 |
+
tf.keras.layers.RandomFlip("horizontal"),
|
| 50 |
+
tf.keras.layers.RandomRotation(0.1),
|
| 51 |
+
tf.keras.layers.RandomZoom(0.1),
|
| 52 |
+
])
|
| 53 |
+
AUTOTUNE = tf.data.AUTOTUNE
|
| 54 |
+
train = train.map(lambda x, y: (aug(norm(x), training=True), y), AUTOTUNE).prefetch(AUTOTUNE)
|
| 55 |
+
val = val.map(lambda x, y: (norm(x), y), AUTOTUNE).prefetch(AUTOTUNE)
|
| 56 |
+
return train, val
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def main():
|
| 60 |
+
ap = argparse.ArgumentParser()
|
| 61 |
+
ap.add_argument("--epochs", type=int, default=8)
|
| 62 |
+
ap.add_argument("--fine-tune-epochs", type=int, default=4)
|
| 63 |
+
ap.add_argument("--batch", type=int, default=32)
|
| 64 |
+
ap.add_argument("--val-split", type=float, default=0.2)
|
| 65 |
+
ap.add_argument("--seed", type=int, default=1337)
|
| 66 |
+
ap.add_argument("--out", default="ml/models/crop_id")
|
| 67 |
+
a = ap.parse_args()
|
| 68 |
+
|
| 69 |
+
train, val = make_datasets(a.val_split, a.batch, a.seed)
|
| 70 |
+
|
| 71 |
+
model = build_model(num_classes=len(CROP_NAMES), crop="crop_id")
|
| 72 |
+
model.compile(optimizer="adam",
|
| 73 |
+
loss=tf.keras.losses.SparseCategoricalCrossentropy(),
|
| 74 |
+
metrics=["accuracy"])
|
| 75 |
+
model.fit(train, validation_data=val, epochs=a.epochs)
|
| 76 |
+
|
| 77 |
+
# Fine-tune the backbone for a few more epochs.
|
| 78 |
+
unfreeze_model(model)
|
| 79 |
+
model.fit(train, validation_data=val, epochs=a.fine_tune_epochs)
|
| 80 |
+
|
| 81 |
+
version = "v1_" + datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 82 |
+
out = Path(a.out) / version
|
| 83 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 84 |
+
model.save(out / "checkpoint.keras")
|
| 85 |
+
convert_to_tflite(model, out / "model.tflite")
|
| 86 |
+
json.dump({"class_names": CROP_NAMES, "input_shape": list(MODEL_CONFIG["input_shape"])},
|
| 87 |
+
open(out / "metadata.json", "w"), indent=2)
|
| 88 |
+
# production pointer
|
| 89 |
+
json.dump({"version": version}, open(Path(a.out) / "production.json", "w"), indent=2)
|
| 90 |
+
val_acc = model.evaluate(val, return_dict=True).get("accuracy")
|
| 91 |
+
print(f"crop-ID model saved to {out} val_accuracy={val_acc}")
|
| 92 |
+
print("Next: load it in inference_app and gate /predict on argmax != selected_crop; "
|
| 93 |
+
"then re-tune with scripts/cross_crop_sweep.py.")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
main()
|