cropintel / scripts /train_crop_id.py
Jaithra Polavarapu
feat(gate): tune wrong-crop thresholds (MARGIN 0.12 / OTHER_MIN 0.80)
bd644f8
Raw
History Blame Contribute Delete
4.21 kB
"""
Train the crop-ID classifier: "which of the 5 crops is this leaf?"
This is the durable replacement for the heuristic wrong-crop gate (see
docs/CROP_ID_GATE.md). Crops are far more visually separable than diseases
within a crop, so a small transfer-learned model should reach very high
accuracy and remove the cross-crop false-accepts/rejects the heuristic can't.
Data: reuses the existing per-crop image folders — every image under
ml/data/<crop>/** is implicitly labeled by <crop>. No new data needed.
NOTE: requires the ML training environment (TensorFlow). It will NOT run in the
repo's Py3.14 inference venv. Run it where the per-crop trainer runs.
Usage:
python scripts/train_crop_id.py --epochs 8 --out ml/models/crop_id
"""
import argparse
import json
from datetime import datetime
from pathlib import Path
import tensorflow as tf
from ml.config import CROPS, DATA_DIR, MODEL_CONFIG
from ml.utils.model_builder import build_model, unfreeze_model
from ml.utils.tflite_converter import convert_to_tflite
CROP_NAMES = list(CROPS.keys()) # stable label order: corn, soybean, wheat, rice, tomato
IMG_SIZE = MODEL_CONFIG["input_shape"][:2]
def make_datasets(val_split: float, batch: int, seed: int):
"""Label = crop folder name. image_dataset_from_directory recurses into each
crop's nested disease subfolders, so every leaf image is labeled by its crop.
IMPORTANT: deliver [0, 1] floats and DO NOT use brightness augmentation —
ImageDataGenerator(brightness_range) zeroes [0,1] float images (see
feedback_imagedatagen_brightness_bug). The model carries its own rescaling.
"""
common = dict(directory=str(DATA_DIR), labels="inferred", label_mode="int",
class_names=CROP_NAMES, image_size=IMG_SIZE, batch_size=batch, seed=seed)
train = tf.keras.utils.image_dataset_from_directory(
validation_split=val_split, subset="training", **common)
val = tf.keras.utils.image_dataset_from_directory(
validation_split=val_split, subset="validation", **common)
norm = tf.keras.layers.Rescaling(1.0 / 255)
aug = tf.keras.Sequential([
tf.keras.layers.RandomFlip("horizontal"),
tf.keras.layers.RandomRotation(0.1),
tf.keras.layers.RandomZoom(0.1),
])
AUTOTUNE = tf.data.AUTOTUNE
train = train.map(lambda x, y: (aug(norm(x), training=True), y), AUTOTUNE).prefetch(AUTOTUNE)
val = val.map(lambda x, y: (norm(x), y), AUTOTUNE).prefetch(AUTOTUNE)
return train, val
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--epochs", type=int, default=8)
ap.add_argument("--fine-tune-epochs", type=int, default=4)
ap.add_argument("--batch", type=int, default=32)
ap.add_argument("--val-split", type=float, default=0.2)
ap.add_argument("--seed", type=int, default=1337)
ap.add_argument("--out", default="ml/models/crop_id")
a = ap.parse_args()
train, val = make_datasets(a.val_split, a.batch, a.seed)
model = build_model(num_classes=len(CROP_NAMES), crop="crop_id")
model.compile(optimizer="adam",
loss=tf.keras.losses.SparseCategoricalCrossentropy(),
metrics=["accuracy"])
model.fit(train, validation_data=val, epochs=a.epochs)
# Fine-tune the backbone for a few more epochs.
unfreeze_model(model)
model.fit(train, validation_data=val, epochs=a.fine_tune_epochs)
version = "v1_" + datetime.now().strftime("%Y%m%d_%H%M%S")
out = Path(a.out) / version
out.mkdir(parents=True, exist_ok=True)
model.save(out / "checkpoint.keras")
convert_to_tflite(model, out / "model.tflite")
json.dump({"class_names": CROP_NAMES, "input_shape": list(MODEL_CONFIG["input_shape"])},
open(out / "metadata.json", "w"), indent=2)
# production pointer
json.dump({"version": version}, open(Path(a.out) / "production.json", "w"), indent=2)
val_acc = model.evaluate(val, return_dict=True).get("accuracy")
print(f"crop-ID model saved to {out} val_accuracy={val_acc}")
print("Next: load it in inference_app and gate /predict on argmax != selected_crop; "
"then re-tune with scripts/cross_crop_sweep.py.")
if __name__ == "__main__":
main()