DEV Community

Cover image for I Built a Soil Fertility AI Pipeline That Runs on a Raspberry Pi 5 — Here's How
Ikegbo Ogochukwu
Ikegbo Ogochukwu

Posted on

I Built a Soil Fertility AI Pipeline That Runs on a Raspberry Pi 5 — Here's How

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 │   └────────────────────────────────────────┘
└──────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Two independent pipelines feeding into one decision:

  • Sensor branch: Sliding window of 12 recent readings → sklearn MLPClassifier → 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(...)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)