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 @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.
| Metric | Healthy (class 0) | Diseased (class 1) | Weighted avg |
|---|---|---|---|
| Precision | 0.946 | 0.938 | 0.942 |
| Recall | 0.940 | 0.944 | 0.942 |
| F1-Score | 0.943 | 0.941 | 0.942 |
| Support | 364 images | 336 images | 700 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.
# 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.
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
#!/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.
// 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
Dataset curation — 3,500 labelled images
Agronomist labels, class balance audit, augmentation pipeline
MobileNetV2 fine-tuning — 20 epochs
Transfer learning, Platt calibration post-training
FastAPI inference server
Preprocessing pipeline, <200ms target, health endpoint
React PWA + TensorFlow.js
Service worker, offline inference, camera capture UI
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.