An end-to-end deep learning system that detects diseases in plant leaves from a photo — using transfer learning on EfficientNetB0, deployed as a Flask REST API with Grad-CAM visual explainability.
Live Demo: plant-disease-api-l7mi.onrender.com (Free tier — allow 30–60 seconds for cold start)
⚠️ Grad-CAM is feature-flagged off on the live demo. Building the explainability sub-models and running the extra forward+backward pass they require pushed peak memory past Render's 512MB free-tier ceiling — confirmed via Render's own Events tab ("Ran out of memory"), even after merging the prediction and Grad-CAM computation into a single pass. Grad-CAM is fully implemented and verified working locally (setENABLE_GRADCAM=true); it's disabled in production specifically to keep the live demo stable, not because it doesn't work. See screenshot below.
Plant diseases cause significant crop loss every year. Early detection matters — but farmers often can't identify a disease until it has already spread. The goal here was simple: upload a photo of a leaf, get an instant diagnosis.
The harder design question was: what happens when the model isn't sure? Most student projects ignore this. This one doesn't.
Two decisions separate this from a standard image classifier:
1. Confidence thresholding If the model's top prediction scores below 60% confidence, the API returns a warning instead of a class label — "Low confidence — retake image in better lighting." A wrong prediction with high confidence is worse than no prediction. This matters in real-world use.
2. Grad-CAM explainability The model doesn't just output a label — it highlights which part of the leaf influenced the prediction. This makes the system interpretable, not just accurate. (Runs locally — see note above on why it's flagged off in the hosted demo.)
Local Grad-CAM output:
Pepper Bell: Bacterial Spot, Healthy Potato: Early Blight, Late Blight, Healthy Tomato: Bacterial Spot, Early Blight, Late Blight, Leaf Mold, Septoria Leaf Spot, Spider Mites, Target Spot, Mosaic Virus, Yellow Leaf Curl Virus, Healthy
Source: PlantVillage Dataset (via Kaggle) Total images: 20,619 across 15 classes Split: 80% training (16,496) / 20% validation (4,123) Input size: 224×224 RGB
Notable class imbalance: Potato Healthy had only 152 images vs Tomato Yellow Leaf Curl Virus with 3,209. The model still generalised well across all classes.
Transfer learning with EfficientNetB0 pretrained on ImageNet.
Input (224×224×3)
→ EfficientNetB0 frozen base (4,049,571 params — not trained)
→ GlobalAveragePooling2D
→ Dropout(0.2)
→ Dense(15, softmax)
Total params: 4,068,786 Trainable params: 19,215 (only the classification head)
Why EfficientNetB0 and not a custom CNN? The previous project (CIFAR-10) used a custom CNN on 32×32 images. At that resolution, EfficientNetB0 causes spatial collapse — the feature maps shrink to near-zero size before pooling. PlantVillage images at 224×224 are the right size for transfer learning to actually work. Different problem, different tool.
| Layer | Tool |
|---|---|
| Deep Learning | TensorFlow, Keras |
| Base Model | EfficientNetB0 (ImageNet weights) |
| Explainability | Grad-CAM (local only — see note above) |
| API | Flask |
| Server | Gunicorn |
| Container | Docker |
| Deployment | Render |
Phase 1 (frozen base, classification head only):
| Epoch | Train Accuracy | Val Accuracy |
|---|---|---|
| 1 | 74.2% | 85.3% |
| 5 | 91.9% | 91.6% |
| 7 | 92.5% | 93.2% ← best |
| 10 | 93.2% | 93.1% |
Final validation accuracy: 93.09% Validation loss: 0.2126
Phase 2 (fine-tuning unfrozen layers) was evaluated but skipped — the model had already plateaued at epoch 7-8. Unfreezing would risk overfitting with minimal accuracy gain.
| Class | Accuracy |
|---|---|
| Pepper Bell Healthy | 1.00 |
| Potato Early Blight | 0.99 |
| Tomato Healthy | 0.99 |
| Potato Late Blight | 0.98 |
| Tomato Yellow Leaf Curl Virus | 0.98 |
| Potato Healthy | 0.97 |
| Pepper Bell Bacterial Spot | 0.97 |
| Tomato Septoria Leaf Spot | 0.93 |
| Tomato Mosaic Virus | 0.93 |
| Tomato Target Spot | 0.93 |
| Tomato Late Blight | 0.92 |
| Tomato Spider Mites | 0.88 |
| Tomato Leaf Mold | 0.86 |
| Tomato Bacterial Spot | 0.91 |
| Tomato Early Blight | 0.60 ← hardest class |
Tomato Early Blight at 60% is the weakest class — its symptoms (brown spots with yellow rings) are visually similar to Septoria Leaf Spot and Target Spot. Grad-CAM on uncertain Early Blight predictions shows diffuse activation across the whole leaf instead of focusing on lesions — a genuine visual ambiguity, not a model bug.
Grad-CAM visualises which leaf regions the model focused on when making a prediction. This matters for trust — a model that highlights the right lesion area is more credible than one that accidentally got the right answer.
The last convolutional layer used: top_conv inside EfficientNetB0.
Implementation note: EfficientNetB0 is nested inside the outer classification model as a single sub-model layer. Building a Grad-CAM model that spans from the outer model's input directly to an internal layer of that nested sub-model fails in Keras 3 with a graph-disconnection error, since the sub-model's internal tensors belong to a separate graph. The working approach splits this into two models: one from the base model's own input to its own top_conv output, and a second that replays the remaining classifier layers (pooling, dropout, dense) on a fresh input shaped like that conv output — sidestepping the graph-connection issue entirely.
Deployment note: running both the base model and the Grad-CAM sub-models for every request roughly doubles per-request memory. On Render's 512MB free tier, this reliably OOM-killed the worker (confirmed via the Events tab), even after merging the main prediction and the Grad-CAM computation into a single forward+backward pass to cut the duplicate EfficientNetB0 pass. Grad-CAM is now feature-flagged via an ENABLE_GRADCAM environment variable — off by default (production/Render), on locally for testing and demos.
Endpoint: POST /predict
Input: multipart form-data with key image
Output: JSON
Confident prediction:
{
"predicted_class": "Tomato_Late_blight",
"confidence": 0.9423,
"warning": null,
"grad_cam_image": null
}grad_cam_image is a base64 PNG string when ENABLE_GRADCAM=true (local), otherwise null (production).
Low confidence (below 0.60 threshold):
{
"predicted_class": null,
"confidence": 0.4821,
"warning": "Low confidence — retake image in better lighting",
"grad_cam_image": null
}Health check: GET /health → {"status": "running"}
CNN_Projects/Plant_village/
│
├── plant_village.ipynb # Training, evaluation, Grad-CAM notebook
├── app.py # Flask REST API (prediction + Grad-CAM, feature-flagged)
├── best_model.keras # Trained model weights
├── templates/ # HTML frontend
├── gradcam-demo.png # Local Grad-CAM output, for README/portfolio use
├── Dockerfile # Container config
├── requirements.txt # Dependencies
└── README.md
Option 1 — Python
git clone https://github.com/Kuldip-Lakhtariya/plant-disease-api.git
cd plant-disease-api
pip install -r requirements.txt.env:
ENABLE_GRADCAM=true
python app.pyVisit http://localhost:5000
Option 2 — Docker
docker build -t plant-disease-api .
docker run -p 5000:5000 -e ENABLE_GRADCAM=true plant-disease-apiUpload a leaf image → get disease prediction + confidence score (+ Grad-CAM heatmap, locally)
- Transfer learning needs the right input size. EfficientNetB0 at 32×32 (CIFAR-10) caused spatial collapse. At 224×224 it works exactly as intended — the features it learned on ImageNet are actually useful at this resolution.
- Confidence thresholding is a production decision, not a model decision. The model always outputs a probability. Deciding what to do with low-confidence outputs is a system design choice. In a healthcare or agriculture context, a wrong confident answer is worse than no answer.
- When Grad-CAM shows diffuse activation, the model is genuinely uncertain. Tomato Early Blight at 54% confidence showed activation spread across the whole leaf — the model wasn't focusing on anything specific. That's a signal the disease signatures are visually ambiguous, not that the code is wrong.
- Phase 2 fine-tuning isn't always needed. Monitoring the val accuracy curve before deciding to unfreeze layers saved overfitting risk. The decision to skip Phase 2 was data-driven, not lazy.
- A nested sub-model breaks naive Grad-CAM graph construction. When a pretrained backbone (EfficientNetB0) is wrapped inside an outer classification model, its internal layers aren't directly reachable from the outer model's input in Keras 3's graph validation. Splitting into a base-model sub-graph plus a replayed classifier head sidesteps this cleanly.
- Not every feature that works locally belongs in production as-is. Grad-CAM roughly doubles per-request memory. Rather than force it onto a 512MB free tier and accept instability, feature-flagging it off in production — while keeping it fully implemented and demonstrable locally — was the more honest engineering call.
- No out-of-distribution rejection — the model will confidently classify a non-leaf image. The final layer is
Dense(15, softmax), which mathematically always distributes 100% of its output across those 15 classes, whatever the input is. Upload a photo of a car or a wall, and it still returns one of the 15 diseases, often at 70-80%+ confidence — the existing 60% threshold only catches uncertainty among the known classes, not "this isn't a leaf at all." More training data on the existing 15 classes wouldn't fix this; it's a closed-set classifier by construction, not a data-volume problem. A real fix needs either a lightweight gatekeeper (a general pretrained classifier checking "is this plant-related" before the specialized model runs) or training an explicit 16th "not a leaf" class on diverse negative examples. Documented here, not yet implemented, given time constraints.
- File type validation in API (extension check, byte-size cap, corrupt-image check via PIL — added)
- Grad-CAM implemented (feature-flagged for production memory reasons — see above)
- More interactive frontend — drag-and-drop upload, real-time confidence bar, Grad-CAM overlay displayed in browser (done for local/flagged-on mode; live demo shows prediction only)
- Extend to more crop types beyond tomato, potato, pepper bell
- Investigate lighter-weight Grad-CAM (e.g. a smaller resolution pass, or a paid tier with more RAM) to enable it in production
- Out-of-distribution / non-leaf image rejection (see Known Limitations above)
Kuldip Lakhtariya B.Tech ECE — LD College of Engineering, Ahmedabad GitHub · LinkedIn · kuldip2611lakhtariya@gmail.com
