An end-to-end walkthrough of training a CNN on soil images, fusing predictions with synthetic sensor data, and deploying to the edge — including every gotcha.
Introduction
Our "5G in Agriculture" project required building a full soil fertility assessment pipeline: multi-parameter sensors, GPS-based spatial mapping, deep learning inference, and a cloud dashboard — all running on a Raspberry Pi 5 edge device.
Since we don't have real sensor hardware yet, and haven't visited any farmland, we needed the entire system to be testable right now from my desk — without sacrificing fidelity. So I combined synthetic sensor data with a custom CNN trained on public soil image datasets, fused both streams at inference time, and served results through a live web dashboard.
Here's the complete story — architecture, code, results, and the things that almost didn't work.
Architecture
┌───────────────(one-time)──────────────┐ ┌──────────────(continuous)─────────────┐
│ train_all.py │ │ run_edge.py │
│ │ │ │
│ data/download.py ──────────────┐ │ │ SimulatedSensor │
│ ↓ │ │ │ ↓ NPK / pH / moisture │
│ model/train_image.py ────────────┼──┼──→ edge/infer_loop.py │
│ ↓ │ │ │ ↓ MLP (windowed readings) │
│ models/*.pt ← Model file →│ │ │ ↓ (optional) Camera frame │
└───────────────training──────────────┘ │ │ ↓ Image CNN │
│ │ ↓ Late Fusion │
┌────────────────────────────────────────┘ │ │ ↓ MQTT │
│ web/app.py ← Dashboard │ │ ↓ Publish │
│ ↓ │ │ │
│ Flask server: / /map /history /settings │ └────────────────────────────────────────┘
└──────────────────────────────────────────┘
Two independent pipelines feeding into one decision:
-
Sensor branch: Sliding window of 12 recent readings →
sklearnMLPClassifier → class probability vector - Image branch: Real soil photograph → Pure-PyTorch MobileNetV3-style CNN → class probability vector
-
Fusion: Weighted average (
0.7 × sensor + 0.3 × image) → final prediction
Both produce (High, Low, Moderate) softmax vectors, so fusion is just element-wise weighted averaging — simple, interpretable, reversible.
Why Pure PyTorch (Not TensorFlow or ONNX)
A common mistake is reaching for TensorFlow first. On this stack, it was broken:
| Framework | Status |
|---|---|
| TF 2.21 + Python 3.13 | Broken — ABI mismatch on Windows DLLs |
| ONNX Runtime | Broken — same VC++ redistributable issue |
| torchvision | Unavailable — no wheel for our Python version |
| PyTorch CPU | Working — pure Python ops, TorchScript export |
The fix was straightforward: build a custom CNN entirely from torch.nn primitives (no torchvision). Trained from scratch on public soil image datasets, exported as a TorchScript .pt file — zero Python dependency at inference time.
Training Results
Dataset: Phantom-fs/Soil-Classification-Dataset (7 soil types → mapped to 3 fertility tiers via agricultural science):
| Tier | Soil Types |
|---|---|
| High | Alluvial, Black |
| Moderate | Red, Yellow |
| Low | Laterite, Arid, Mountain |
SoilCNN Architecture
~0.6M parameters. Inspired by MobileNetV3-Small but built from scratch:
class SoilCNN(nn.Module):
def __init__(self, num_classes=3, img_size=224):
# Stem: Conv2d(3→16, k=3, s=2, p=1) → BN → HardSwish()
self.stem = nn.Sequential(...)
# Body: 7 stages of DepthwiseSeparableConv blocks
self.body = nn.Sequential(*layers)
# Head: Conv2d(320→1280, k=1) → BN → HardSwish → GAP → Dropout → FC
self.head = nn.Sequential(...)
Each body stage uses DepthwiseSeparableConv internally, which wraps a SEBlock (Squeeze-and-Excitation) followed by depthwise → pointwise convolution + batch norm + activation. The last block uses HardSwish instead of ReLU to stay closer to MobileNetV3 design principles.
Metrics
After 30 epochs (Adam, cosine annealing LR, class-weighted loss):
| Class | Precision | Recall | F1 | Support |
|---|---|---|---|---|
| High | 0.98 | 0.98 | 0.98 | 445 |
| Low | 0.96 | 0.95 | 0.96 | 280 |
| Moderate | 0.99 | 0.99 | 0.99 | 533 |
| Overall | — | — | 0.98 | 1258 |
Validation accuracy peaked at 98.4%. Not production-grade for soil science (real lab validation would require thousands of annotated field photographs), but strong enough to demonstrate the dual-stream pipeline end-to-end.
Export: models/soil_image_model.pt — a 3 MB TorchScript file ready for the Pi.
What You Can Actually Test Right Now
With --simulate, the entire system runs without hardware:
# Terminal 1 — inference loop with simulated sensors + synthetic camera
python run_edge.py --simulate --map --map-walk --sim-demo --camera-demo \
--period 1
# Terminal 2 — web dashboard
python run_web.py
# Open http://localhost:5000
This produces:
- A live IDW-mapped PNG showing high/moderate/low zones across the farm
- JSON-per-line logs pushed to MQTT
- A Flask dashboard with latest map, table of predictions, colour-feature stats
- CSV with 15 columns: timestamp, lat, lon, score, class, confidence, img_label, img_conf, L*, a*, b*, dark_colour_index, organic_proxy, moisture_proxy, crack_proxy
Replace --camera-demo with --picamera when you have a PiCamera v3 module connected. The Picamera2Camera class wraps libcamera on Raspberry Pi OS Bookworm natively.
Deploying to the Raspberry Pi 5
The hard part wasn't writing code — it was getting it to run. Three blockers took most of the effort:
Blocker 1: No PyTorch wheels for Python 3.13 on ARM
ERROR: Could not find a version that satisfies the requirement torch
PyTorch doesn't ship Python 3.13 ARM wheels yet (as of August 2026). Python 3.13 on the Pi was too bleeding-edge. Fix: install Python 3.11 and use a separate venv.
Blocker 2: scikit-image won't compile on Python 3.13 + GCC 14.2.0
When pip tried to build scikit-image from source, pythran-generated C++ code failed because Pythran uses _v suffix traits (is_integral_v) that Debian's GCC 14 headers expose as plain is_integral:
error: 'is_integral_v' is not a member of 'std'; did you mean 'is_integral'?
Fix: pin scikit-image < 0.24 (the 0.23.x series has a pre-built wheel for 3.13) or downgrade to Python 3.11 where both packages have pre-built wheels.
Blocker 3: NetworkManager profile secret never persisted to disk
The most frustrating bug. After creating a Wi-Fi connection profile with nmcli, checking its contents with nmcli -s show showed the password correctly stored. But the actual config file on disk had psk= empty:
[wifi-security]
key-mgmt=wpa-psk
psk= ← nothing here
NetworkManager reads the file, sees no secret, rejects the connection, and nmcli lies to your face about having saved it. Fix: edit /etc/NetworkManager/system-connections/<profile>.nmconnection directly and restart NM.
These three issues alone cost more time than writing the actual pipeline code. Lesson learned: always verify state with cat before assuming a tool reported truth.
File Structure
soil_ai/
├── config.py # Central config: paths, classes, fusion weights
├── requirements.txt
├── run_edge.py # Edge inference entry point
├── run_web.py # Web dashboard entry point
├── model/
│ ├── train_image.py # Train SoilCNN (pure PyTorch) → TorchScript
├── edge/
│ ├── sensors.py # SimulatedSensor (synthetic NPK/pH/moisture/GPS)
│ ├── mqtt_client.py # MQTT publisher
│ ├── infer_loop.py # Main loop: buffer → predict → fuse → publish
│ └── camera.py # CameraSimulator + Picamera2Camera + preprocessing
├── spatial/
│ ├── live.py # MapRecorder: CSV + periodic PNG render
│ └── interpolate.py # IDW interpolation for continuous maps
├── web/
│ ├── app.py # Flask routes: /, /map, /history, /settings, API
│ └── templates/ # Dashboard, map, history, settings pages
└── outputs/
├── predictions.csv # 15-column log
├── farm_live_*.png # Animated map snapshots
└── latest_image.jpg # Latest captured frame
Next Steps
- Retrain on real labeled data when we get to a farm. The crop → fertility mapping is approximate; lab-tested samples are the path to production accuracy.
-
Add EC sensor driver in
edge/sensors.py. Currently logged but not modeled (public dataset lacks EC). - Online retraining — weekly fine-tuning on newly labeled readings could improve drift performance.
- Anomaly detection — z-score filtering or autoencoder outlier detection before the MLP catches broken-sensor noise.
TL;DR
| What we built | How |
|---|---|
| Sensor model | sklearn MLP on sliding windows of 6 features |
| Image model | Pure PyTorch CNN (~0.6M params), TorchScript export |
| Fusion | Weighted softmax average (0.7 sensor + 0.3 image) |
| Spatial mapping | IDW interpolation → animated PNG heatmaps |
| Deployment | Raspberry Pi 5, Picamera2, MQTT, Flask dashboard |
| Fully testable? | Yes — --simulate --camera-demo needs zero hardware |
The full code is in the project repo. Happy to share links if you want to clone and experiment.
Special thanks to everyone who debugged Python 3.13 compatibility issues and NetworkManager race conditions along the way. The code works; the infrastructure fought back.
Top comments (0)