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 files

From 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 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.15"))
93
- CROP_OTHER_MIN_CONF = float(os.environ.get("CROPINTEL_CROP_OTHER_MIN_CONF", "0.75"))
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()