| |
| """ |
| Download pretrained HuggingFace plant-disease models, export to ONNX, |
| and create CropIntel-format model directories for corn, wheat, and rice. |
| |
| Sources |
| ------- |
| - LishaV01/agriculture-crop-disease-detection (ViT, 20-class, 95.4 % accuracy) |
| Used for: corn, wheat, rice |
| - sbaner24/vit-base-patch16-224-Soybean_11-46 (ViT, 5-class, 93 % accuracy) |
| SKIPPED: model id2label contains only numeric placeholders (0-4); |
| class-to-disease mapping is unknown. |
| |
| Usage |
| ----- |
| python -m ml.scripts.download_pretrained_models [--test] [--crops corn wheat rice] |
| """ |
| import argparse |
| import json |
| import os |
| import sys |
| import traceback |
| from datetime import datetime |
| from pathlib import Path |
|
|
| os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "2") |
|
|
| ROOT = Path(__file__).resolve().parents[2] |
| sys.path.insert(0, str(ROOT)) |
|
|
| import numpy as np |
| import torch |
| from PIL import Image |
| from transformers import AutoImageProcessor, AutoModelForImageClassification |
|
|
| from ml.config import MODELS_DIR |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| CROPS_CONFIG = [ |
| { |
| "crop": "corn", |
| "repo": "LishaV01/agriculture-crop-disease-detection", |
| |
| "class_map": { |
| 0: "Common Rust", |
| 1: "Gray Leaf Spot", |
| 2: "Healthy", |
| }, |
| "note": ( |
| "3-class model (source model has no Corn Blight class). " |
| "Source: LishaV01/agriculture-crop-disease-detection, reported accuracy 0.954." |
| ), |
| }, |
| { |
| "crop": "wheat", |
| "repo": "LishaV01/agriculture-crop-disease-detection", |
| |
| "class_map": { |
| 10: "Leaf Rust", |
| 11: "Healthy", |
| 12: "Stripe (Yellow) Rust", |
| }, |
| "note": ( |
| "3-class model (source model has no Powdery Mildew class). " |
| "Source: LishaV01/agriculture-crop-disease-detection, reported accuracy 0.954." |
| ), |
| }, |
| { |
| "crop": "rice", |
| "repo": "LishaV01/agriculture-crop-disease-detection", |
| |
| "class_map": { |
| 8: "Healthy", |
| 13: "Bacterial Leaf Blight", |
| 14: "Rice Blast", |
| 15: "Brown Spot", |
| }, |
| "note": ( |
| "4-class model using indices 8,13,14,15 from the source model. " |
| "Source: LishaV01/agriculture-crop-disease-detection, reported accuracy 0.954." |
| ), |
| }, |
| ] |
|
|
| SOYBEAN_SKIP_NOTE = ( |
| "Soybean model sbaner24/vit-base-patch16-224-Soybean_11-46 was SKIPPED.\n" |
| "Reason: id2label contains only numeric placeholders {0:'0',...,4:'4'}.\n" |
| "The training dataset folder ordering is unknown, so the mapping\n" |
| "[Powdery Mildew, Sudden Death Syndrome, Yellow Mosaic, Healthy, ...]\n" |
| "cannot be safely determined without inspecting the original dataset.\n" |
| "To use this model, determine the class order from the original training\n" |
| "dataset and add a SOYBEAN_CLASS_MAP entry to this script." |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def log(msg: str) -> None: |
| ts = datetime.now().strftime("%H:%M:%S") |
| print(f"[{ts}] {msg}", flush=True) |
|
|
|
|
| def export_to_onnx( |
| pt_model: torch.nn.Module, |
| processor, |
| onnx_path: Path, |
| ) -> None: |
| """Export a PyTorch HuggingFace model to ONNX with fixed 224×224 input.""" |
| pt_model.eval() |
|
|
| |
| dummy_img = np.random.randint(0, 256, (224, 224, 3), dtype=np.uint8) |
| pil_img = Image.fromarray(dummy_img) |
| inputs = processor(images=pil_img, return_tensors="pt") |
| pixel_values = inputs["pixel_values"] |
|
|
| onnx_path.parent.mkdir(parents=True, exist_ok=True) |
| with torch.no_grad(): |
| torch.onnx.export( |
| pt_model, |
| (pixel_values,), |
| str(onnx_path), |
| input_names=["pixel_values"], |
| output_names=["logits"], |
| dynamic_axes=None, |
| opset_version=14, |
| do_constant_folding=True, |
| ) |
| log(f" ONNX saved: {onnx_path} ({onnx_path.stat().st_size / (1024**2):.1f} MB)") |
|
|
|
|
| def build_class_subset_onnx( |
| full_onnx_path: Path, |
| source_indices: list, |
| output_labels: list, |
| out_onnx_path: Path, |
| image_mean: list, |
| image_std: list, |
| ) -> None: |
| """ |
| Post-process the full ONNX model to add: |
| 1. Input normalization (subtract mean, divide std over the channel dim) |
| so the model accepts raw [0,1] float32 (H×W×C channels-last) input. |
| 2. A class-subset slice + softmax over only the selected source indices. |
| |
| The resulting ONNX model: |
| - Input: pixel_values float32 [1, 224, 224, 3] (channels-last, [0,1]) |
| - Output: probabilities float32 [1, N_classes] |
| """ |
| import onnx |
| from onnx import helper, TensorProto, numpy_helper |
|
|
| base = onnx.load(str(full_onnx_path)) |
|
|
| |
| |
| |
| |
| |
| n_classes = len(source_indices) |
|
|
| |
| transpose_node = helper.make_node( |
| "Transpose", |
| inputs=["input_nhwc"], |
| outputs=["input_nchw"], |
| perm=[0, 3, 1, 2], |
| name="pre_transpose", |
| ) |
|
|
| |
| mean_data = np.array(image_mean, dtype=np.float32).reshape(1, 3, 1, 1) |
| std_data = np.array(image_std, dtype=np.float32).reshape(1, 3, 1, 1) |
| mean_init = numpy_helper.from_array(mean_data, name="norm_mean") |
| std_init = numpy_helper.from_array(std_data, name="norm_std") |
|
|
| sub_node = helper.make_node("Sub", ["input_nchw", "norm_mean"], ["sub_out"], name="pre_sub") |
| div_node = helper.make_node("Div", ["sub_out", "norm_std"], ["div_out"], name="pre_div") |
|
|
| |
| orig_input_name = base.graph.input[0].name |
| orig_output_name = base.graph.output[0].name |
|
|
| identity_node = helper.make_node("Identity", ["div_out"], [orig_input_name], name="pre_identity") |
|
|
| |
| indices_data = np.array(source_indices, dtype=np.int64) |
| indices_init = numpy_helper.from_array(indices_data, name="class_indices") |
| gather_node = helper.make_node( |
| "Gather", |
| inputs=[orig_output_name, "class_indices"], |
| outputs=["selected_logits"], |
| axis=1, |
| name="post_gather", |
| ) |
|
|
| |
| softmax_node = helper.make_node( |
| "Softmax", |
| inputs=["selected_logits"], |
| outputs=["probabilities"], |
| axis=1, |
| name="post_softmax", |
| ) |
|
|
| |
| new_input = helper.make_tensor_value_info("input_nhwc", TensorProto.FLOAT, [1, 224, 224, 3]) |
| new_output = helper.make_tensor_value_info("probabilities", TensorProto.FLOAT, [1, n_classes]) |
|
|
| new_nodes = ( |
| [transpose_node, sub_node, div_node, identity_node] |
| + list(base.graph.node) |
| + [gather_node, softmax_node] |
| ) |
| new_initializers = list(base.graph.initializer) + [mean_init, std_init, indices_init] |
|
|
| new_graph = helper.make_graph( |
| nodes=new_nodes, |
| name="cropintel_plant_disease", |
| inputs=[new_input], |
| outputs=[new_output], |
| initializer=new_initializers, |
| ) |
|
|
| new_model = helper.make_model(new_graph, opset_imports=base.opset_import) |
| new_model.ir_version = base.ir_version |
|
|
| onnx.checker.check_model(new_model) |
| onnx.save(new_model, str(out_onnx_path)) |
| log(f" Subset ONNX saved: {out_onnx_path} ({out_onnx_path.stat().st_size / (1024**2):.1f} MB)") |
|
|
|
|
| def save_metadata( |
| crop: str, |
| out_dir: Path, |
| source_repo: str, |
| label_map: dict, |
| note: str, |
| image_mean: list, |
| image_std: list, |
| ) -> None: |
| """Write label_map.json, metadata.json, training_info.json.""" |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| (out_dir / "label_map.json").write_text(json.dumps(label_map, indent=2)) |
|
|
| metadata = { |
| "crop": crop, |
| "version": out_dir.name, |
| "source_model": source_repo, |
| "num_classes": len(label_map), |
| "class_names": list(label_map.values()), |
| "image_size": [224, 224], |
| "input_dtype": "float32", |
| "input_range": [0.0, 1.0], |
| "input_layout": "NHWC (channels last)", |
| "normalization": f"mean={image_mean} std={image_std} (embedded in model)", |
| "model_file": "model.onnx", |
| "runtime": "onnxruntime", |
| "quantization": "none (float32 ONNX)", |
| "note": note, |
| "created_at": datetime.now().isoformat(), |
| } |
| (out_dir / "metadata.json").write_text(json.dumps(metadata, indent=2)) |
|
|
| ti = { |
| "crop": crop, |
| "version": out_dir.name, |
| "model_architecture": "ViT (pretrained HuggingFace)", |
| "source_repo": source_repo, |
| "num_classes": len(label_map), |
| "class_names": list(label_map.values()), |
| "fine_tuned": False, |
| "from_scratch": False, |
| "backbone_weights": "pretrained", |
| } |
| (out_dir / "training_info.json").write_text(json.dumps(ti, indent=2)) |
| log(f" Metadata written to {out_dir}") |
|
|
|
|
| def run_test(onnx_path: Path, label_map: dict) -> None: |
| """Run a sanity-check prediction on a random image.""" |
| import onnxruntime as ort |
|
|
| sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"]) |
| inp_name = sess.get_inputs()[0].name |
| dummy = np.random.rand(1, 224, 224, 3).astype(np.float32) |
| probs = sess.run(None, {inp_name: dummy})[0][0] |
| idx = int(np.argmax(probs)) |
| label = label_map.get(str(idx), "?") |
| log(f" Test → {label} ({probs[idx]:.3f})") |
| log(f" All: { {label_map.get(str(i), str(i)): round(float(p), 3) for i, p in enumerate(probs)} }") |
|
|
|
|
| |
| |
| |
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser(description="Download and convert pretrained models to ONNX") |
| parser.add_argument("--test", action="store_true", help="Run test prediction after each crop") |
| parser.add_argument("--crops", nargs="+", default=None, help="Only process these crops") |
| args = parser.parse_args() |
|
|
| selected = set(args.crops) if args.crops else None |
|
|
| |
| _cache: dict = {} |
|
|
| |
| tmp_dir = ROOT / "ml" / "logs" / "_onnx_tmp" |
| tmp_dir.mkdir(parents=True, exist_ok=True) |
|
|
| for cfg in CROPS_CONFIG: |
| crop = cfg["crop"] |
| if selected and crop not in selected: |
| continue |
|
|
| log(f"\n{'='*60}") |
| log(f"CROP: {crop.upper()}") |
| log(f"{'='*60}") |
|
|
| try: |
| repo = cfg["repo"] |
| class_map: dict = cfg["class_map"] |
| source_indices = list(class_map.keys()) |
| output_labels = list(class_map.values()) |
|
|
| |
| |
| |
| if repo not in _cache: |
| log(f" Downloading {repo} ...") |
| processor = AutoImageProcessor.from_pretrained(repo) |
| pt_model = AutoModelForImageClassification.from_pretrained(repo) |
| pt_model.eval() |
| _cache[repo] = (processor, pt_model) |
| log(" Download complete.") |
| else: |
| log(f" Using cached {repo}") |
| processor, pt_model = _cache[repo] |
|
|
| image_mean = list(getattr(processor, "image_mean", [0.485, 0.456, 0.406])) |
| image_std = list(getattr(processor, "image_std", [0.229, 0.224, 0.225])) |
|
|
| |
| |
| |
| full_onnx = tmp_dir / f"{repo.replace('/', '_')}_full.onnx" |
| if not full_onnx.exists(): |
| log(" Exporting to ONNX ...") |
| export_to_onnx(pt_model, processor, full_onnx) |
| else: |
| log(f" Reusing existing full ONNX: {full_onnx.name}") |
|
|
| |
| |
| |
| version = f"pretrained_v1_{datetime.now().strftime('%Y%m%d')}" |
| out_dir = MODELS_DIR / crop / version |
| out_dir.mkdir(parents=True, exist_ok=True) |
| subset_onnx = out_dir / "model.onnx" |
|
|
| log(f" Building {len(source_indices)}-class subset ONNX → {subset_onnx.name}") |
| build_class_subset_onnx( |
| full_onnx_path=full_onnx, |
| source_indices=source_indices, |
| output_labels=output_labels, |
| out_onnx_path=subset_onnx, |
| image_mean=image_mean, |
| image_std=image_std, |
| ) |
|
|
| |
| |
| |
| label_map = {str(i): lbl for i, lbl in enumerate(output_labels)} |
| save_metadata( |
| crop=crop, |
| out_dir=out_dir, |
| source_repo=repo, |
| label_map=label_map, |
| note=cfg["note"], |
| image_mean=image_mean, |
| image_std=image_std, |
| ) |
|
|
| |
| |
| |
| if args.test: |
| log(" Running test prediction ...") |
| run_test(subset_onnx, label_map) |
|
|
| log(f" ✓ {crop} model ready at {out_dir}") |
|
|
| except Exception as e: |
| log(f" ERROR processing {crop}: {e}") |
| traceback.print_exc() |
|
|
| |
| for f in tmp_dir.glob("*.onnx"): |
| f.unlink() |
| if not any(tmp_dir.iterdir()): |
| tmp_dir.rmdir() |
|
|
| |
| log(f"\n{'='*60}") |
| log("SOYBEAN: SKIPPED") |
| log(SOYBEAN_SKIP_NOTE) |
| log(f"{'='*60}") |
|
|
| log("\nAll done. Models saved to ml/models/<crop>/pretrained_v1_<date>/") |
| log("Runtime: onnxruntime (see ml/inference/onnx_predictor.py)") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|