Machine Learning · 2025

Maize Leaf Disease Classifier
Binary CNN for Agricultural AI

Computer vision system for detecting Cercospora gray leaf spot in maize crops. Addresses sub-Saharan agricultural losses from manual inspection limitations. MobileNetV2 backbone fine-tuned on 3,500 labelled field images, deployed as FastAPI inference server + React PWA.

Python TensorFlow MobileNetV2 FastAPI React PWA Docker Blue-Green Deploy
3,500 Training images
94.2% Accuracy
<200ms Inference
100% Uptime
2 Deploy slots blue-green
0 Downtime on update

The Problem

Cercospora gray leaf spot reduces maize yields by 20–50% in affected fields across sub-Saharan Africa. Manual inspection by agronomists is slow, expensive, and inconsistent — a single agronomist can inspect ~50 plants per hour. Early detection (before visible spread) requires lab analysis inaccessible to small-hold farmers.

This system gives any farmer with a smartphone the ability to photograph a leaf and get a disease probability in under 200ms, without internet connectivity after the initial PWA install.

Dataset

3,500 field images labelled by agronomists. Class balance 52% healthy / 48% diseased. Augmentation: random flip, brightness ±20%, rotation ±15°, zoom ±10%.

Model Architecture

MobileNetV2 pretrained on ImageNet. Top layers replaced: GlobalAveragePooling → Dropout(0.3) → Dense(128, ReLU) → Dense(1, sigmoid). Fine-tuned for 20 epochs, learning rate 1e-4.

Confidence Calibration

Platt scaling applied post-training. Raw sigmoid outputs biased toward 0.85+ even for uncertain predictions. Calibration maps to true probability — 0.7 output = 70% historical accuracy.

Offline PWA

Service worker caches model weights + app shell. Inference runs in browser via TensorFlow.js after first load. FastAPI backend used for batch/high-accuracy mode.

FastAPI Inference Server

REST API with a /predict endpoint. Accepts image upload (JPEG/PNG, max 5MB). Preprocessing: resize to 224×224, normalize to [-1, 1] (MobileNetV2 range). Returns JSON with disease prediction, calibrated confidence, and measured latency.

api/predict.py FastAPI · TensorFlow
# api/predict.py
@app.post("/predict")
async def predict(file: UploadFile = File(...)):
    img_bytes = await file.read()
    img = Image.open(io.BytesIO(img_bytes)).convert("RGB").resize((224, 224))
    arr = tf.keras.applications.mobilenet_v2.preprocess_input(
        np.array(img, dtype=np.float32)[np.newaxis]
    )
    t0 = time.perf_counter()
    prob = float(model(arr, training=False)[0, 0])
    latency_ms = int((time.perf_counter() - t0) * 1000)
    # Apply Platt calibration
    prob_calibrated = platt_sigmoid(prob, A=PLATT_A, B=PLATT_B)
    return {
        "diseased": prob_calibrated >= 0.5,
        "confidence": round(prob_calibrated, 3),
        "latency_ms": latency_ms,
    }

The platt_sigmoid function applies learned parameters A and B to map the raw network output to a well-calibrated probability. These parameters are fitted on a held-out calibration set after training using maximum likelihood estimation.

Evaluation Metrics

Evaluated on a held-out test set of 700 images (20% stratified split). Class balance maintained: 364 healthy / 336 diseased.

MetricHealthy (class 0)Diseased (class 1)Weighted avg
Precision0.9460.9380.942
Recall0.9400.9440.942
F1-Score0.9430.9410.942
Support364 images336 images700 images

Pre-calibration accuracy: 94.2%. After Platt scaling, the Expected Calibration Error (ECE) dropped from 0.118 to 0.031 — the model's confidence scores now match empirical accuracy within 3.1 percentage points.

Training curve

20 fine-tuning epochs on the MobileNetV2 backbone (all layers unfrozen after epoch 10). Best validation accuracy: 94.2% at epoch 17. Early stopping patience: 5 epochs. No significant overfitting — train/val gap stayed under 1.8% throughout.

training configTensorFlow · Keras
# Phase 1: train only new top layers (epochs 1–10)
base_model.trainable = False
model.compile(optimizer=Adam(lr=1e-3), loss='binary_crossentropy', metrics=['accuracy'])

# Phase 2: unfreeze all layers, fine-tune at lower LR (epochs 11–20)
base_model.trainable = True
model.compile(optimizer=Adam(lr=1e-4), loss='binary_crossentropy', metrics=['accuracy'])

callbacks = [
    EarlyStopping(monitor='val_accuracy', patience=5, restore_best_weights=True),
    ModelCheckpoint('best_model.h5', save_best_only=True),
]

Blue-Green Zero-Downtime Deploy

Two identical deployment slots (blue/green). Traffic routed via Nginx upstream. The active slot serves 100% of production traffic while the inactive slot receives the new build, passes health checks, and then takes over — with the old slot held warm for instant rollback.

Deploy sequence

1. Build new image → deploy to inactive slot.
2. Health check: GET /healthz must return 200 three times consecutively.
3. Switch Nginx upstream to new slot (reload, no connection drop).
4. Old slot kept warm for 5 minutes for instant rollback.

nginx.conf — upstream block Nginx
upstream maize_api {
    server 127.0.0.1:8001;  # blue slot (active)
    # server 127.0.0.1:8002;  # green slot (standby)
}
# Health check script flips the comment and reloads nginx
deploy.sh Bash · Docker
#!/usr/bin/env bash
# deploy.sh — switches active slot without downtime
ACTIVE=$(cat /var/run/maize-active-slot)  # "blue" or "green"
NEW=$([ "$ACTIVE" = "blue" ] && echo "green" || echo "blue")
docker compose up -d maize-$NEW
# Health check loop
for i in $(seq 1 3); do
  curl -sf http://localhost:800$([ "$NEW" = "green" ] && echo 2 || echo 1)/healthz || exit 1
  sleep 2
done
nginx-slot-switch $NEW && echo "$NEW" > /var/run/maize-active-slot

Offline PWA — service worker strategy

The React PWA uses a cache-first strategy for model weights (large, change infrequently) and a network-first strategy for API calls (want fresh results). The service worker is generated by Workbox via vite-plugin-pwa.

service-worker.jsWorkbox
// Cache-first for TF.js model weights (~8MB, rarely changes)
registerRoute(
  ({url}) => url.pathname.includes('/model/'),
  new CacheFirst({ cacheName: 'model-cache',
    plugins: [new ExpirationPlugin({ maxAgeSeconds: 7 * 24 * 60 * 60 })] })
);
// Network-first for API predict calls (want fresh inference)
registerRoute(
  ({url}) => url.pathname.startsWith('/predict'),
  new NetworkFirst({ cacheName: 'api-cache',
    networkTimeoutSeconds: 3 })  // fall back to cached result after 3s
);

Build Sequence

1

Dataset curation — 3,500 labelled images

Agronomist labels, class balance audit, augmentation pipeline

2

MobileNetV2 fine-tuning — 20 epochs

Transfer learning, Platt calibration post-training

3

FastAPI inference server

Preprocessing pipeline, <200ms target, health endpoint

4

React PWA + TensorFlow.js

Service worker, offline inference, camera capture UI

5

Blue-green deployment — zero downtime

Nginx upstream switch, health gate, rollback slot

Need a production ML system?

I build ML pipelines that go beyond Jupyter notebooks — calibrated models, REST APIs, PWA delivery, and zero-downtime deploy. Available for agricultural, medical, and industrial vision applications.