"Leaffliction" is a computer vision project written in Python: a complete image-classification pipeline that recognises plant diseases from photographs of leaves. Given 7 221 photos of apple and grape leaves sorted into eight classes β healthy, black rot, rust, scab, esca, spot β the program learns to tell them apart and names the disease of a leaf it has never seen.
The pipeline is split into four programs, one per stage. Distribution counts the images of every class and charts them, which shows how imbalanced the dataset is. Augmentation creates plausible new photos from existing ones β flips, rotations, skews, shears, crops, distortions β and balances the classes with them. Transformation isolates the leaf with PlantCV and extracts what a botanist would look at: its mask, its outline and shape, its landmarks and its colour histograms. train and predict fit a convolutional neural network written with PyTorch on background-free leaves and classify new images.
The interesting part is everything around the network. The leaf is segmented
on a colour channel built for the job (b β a in LAB) so that the model never
learns the photo setup instead of the disease; the dataset is split per leaf
before any augmentation, so no copy of a validation leaf is ever trained on;
the validation set is never used to tune anything; and the trained model, the
images it learned from and its metadata are packed into learnings.zip, whose
SHA-1 in signature.txt proves later that the model being evaluated is the
one that was trained.
The model packaged here scores 100 % on the 1 445 validation images.
- Reading an image dataset whose classes are its folders, flat or nested by plant, and measuring how imbalanced it is
- Implementing six geometric augmentations and balancing every class with them without ever augmenting an augmented image
- Segmenting a leaf from its background reliably enough to train on the leaf alone
- Extracting features with PlantCV: masks, regions of interest, shape analysis, pseudolandmarks and colour histograms in RGB, HSV and LAB
- Splitting the data per leaf and per class so that the validation score is an honest estimate, free of data leakage
- Designing, training and evaluating a convolutional network with PyTorch
- Reaching at least 90 % accuracy on a validation set of at least 100 images
- Packaging the learnings in a zip and signing it so the result can be checked
- Reporting every bad input with a message and an exit code, never a traceback
Leaffliction β modules breakdown
| Module | Feature | Description |
|---|---|---|
| Distribution | Class counts | Prints a count table and draws, per plant type, a pie chart and a bar chart with the same colour per class |
| Augmentation | One image | Saves <name>_Flip.JPG, _Rotate, _Skew, _Shear, _Crop, _Distortion next to the original and displays them |
| Augmentation | A dataset | Copies it into augmented_directory/ and adds augmented images until every class reaches the target |
| Transformation | Views | Gaussian blur, mask, ROI objects, analyze object, pseudolandmarks, colour histogram; displayed for one image, saved for a directory |
| train | Pipeline | Split per leaf, balance the training part, remove backgrounds, train LeafCNN, zip the learnings, write the signature |
| predict | Classification | One image: the class, its confidence and a figure; a directory: accuracy, per-class accuracy, confusion matrix, misclassified files |
| dataset | Discovery | Classes are the folders that hold images; plant type from the parent folder or the name prefix; hidden files ignored |
| dataset | Leaf groups | An image, its augmented copies and its byte-identical duplicates form one group that lands on one side of the split |
| augment | Six techniques | Each one (image, rng) -> image, seeded from the seed, the file and the technique; uncovered areas take the background colour |
| balance | Plan | Which source gets which technique, in rounds, so techniques stay evenly spread and no name is overwritten |
| mask | Segmentation | Otsu threshold on b β a, hysteresis, blur, opening and closing, largest component, hole filling |
| transform | PlantCV views | The six views built on one segmentation |
| histogram | Colour | Share of leaf pixels at each intensity, for the nine channels of RGB, HSV and LAB |
| data | Network input | Segment β remove background β 128Γ128 β [-1, 1], the same function for training and prediction |
| model | LeafCNN |
Four convolution blocks, global average pooling, dropout, one linear layer; 1.2 M parameters |
| training | Fit and report | AdamW, one-cycle schedule, label smoothing, random flips and quarter turns, mixed precision on GPU |
| learnings | Bundle | Work directory, zip, SHA-1 signature, loading the model straight from the zip |
| parallel | Process pool | Order-preserving ProcessPoolExecutor with a progress counter, clean on Ctrl+C |
| cli | Plumbing | One exit path: 0 success, 1 error, 2 bad arguments, 130 interrupted; figures saved as PNG without a display |
Usage Example & Testing
make install # .venv with Python 3.13 and the CPU build of PyTorch (TORCH=cuda for the CUDA one)
make predict # downloads learnings.zip, checks signature.txt, scores the validation images
make distribution # with the dataset in images/: also augmentation, transformation, train
make test # pytest; make lint for flake8
make fclean # the .venv and everything generated (learnings.zip is kept)$ ./src/Distribution.py learnings/validation
Apple: 4 classes, 634 images
Apple_Black_rot 124 19.6%
Apple_healthy 329 51.9%
Apple_rust 55 8.7%
Apple_scab 126 19.9%
Grape: 4 classes, 811 images
Grape_Black_rot 236 29.1%
Grape_Esca 276 34.0%
Grape_healthy 84 10.4%
Grape_spot 215 26.5%Drawn from the validation images packaged in learnings.zip. The split is stratified per class, so the shares are those of the whole dataset: Apple_healthy alone is half of the apple leaves, Apple_rust under a tenth.
$ ./src/Augmentation.py leaf.JPG
Saved ./leaf_Flip.JPG
β¦
Saved ./leaf_Distortion.JPG./src/Transformation.py "images/Grape_Esca/image (1000).JPG" # displays the views
./src/Transformation.py -src images/Apple_rust -dst transformed -mask -pseudo./src/Transformation.py on a Grape_Esca leaf: the original, the smoothed mask, the leaf on white, the region of interest, the shape analysis and the pseudolandmarks.
The colour histogram of the same leaf: brown lesions raise the red channel and push a from green toward magenta.
$ ./src/predict.py "learnings/validation/Grape_spot/image (1000).JPG"
Class predicted: Grape_spot (94.9%)
$ ./src/predict.py learnings/validation
Accuracy: 100.00% (1445/1445 images)
Per class:
0 Apple_Black_rot 100.00% (124/124)
1 Apple_healthy 100.00% (329/329)
β¦
7 Grape_spot 100.00% (215/215)$ ./src/Distribution.py nope/
Error: nope/: no such file or directory
$ ./src/Distribution.py empty/
Error: empty/: no images found
$ ./src/Augmentation.py README.md
Error: README.md: not a readable image
$ ./src/Transformation.py -src images/Apple_rust
Transformation.py: error: -src requires -dst
$ ./src/train.py --epochs 0 images/
train.py: error: --epochs and --batch-size must be positiveErrors print one line on standard error and exit with 1, invalid arguments
exit with 2, and Ctrl+C exits with 130; no program ever prints a
traceback.
python -m pytest -q # 107 tests, one to three minutes; no dataset needed
flake8 src tests # PEP 8The suite builds synthetic leaves, so it does not depend on the dataset; the
one check on real images is skipped when images/ is absent.
| File | Covers |
|---|---|
test_dataset.py |
Extensions and hidden files, flat and nested layouts, augmented names, per-leaf split without leakage, duplicate photos, invalid roots |
test_augment.py |
Every technique on square, rectangular and tiny images; reproducibility; the balancing plan |
test_imageio.py |
Grayscale and RGBA input, spaces and non-ASCII paths, empty, corrupt and truncated files |
test_mask_transform.py |
Mask accuracy on synthetic leaves (IoU), uniform images, view sizes, histogram percentages, saved names |
test_learnings.py |
Model output shape, zip layout and round trip, SHA-1 and sha1sum -c, corrupt archives, work-directory safety |
test_cli.py |
The five programs end to end, headless, with a full train β predict run on CPU; any traceback fails |
π₯ Setup & Usage
| Item | Needed for | Notes |
|---|---|---|
Python 3.13, or uv |
Everything | 3.14 is not supported: PlantCV pins scipy<1.16, which has no 3.14 wheels; uv installs 3.13 without root |
make, curl, unzip |
Install and run | make install creates .venv/ with requirements.txt |
| PyTorch, PlantCV, OpenCV, NumPy, Matplotlib | The programs | Pinned in requirements.txt, installed by make install |
images/ |
Distribution, Augmentation, train | The leaf dataset, extracted at the repository root; not in git |
learnings.zip |
predict | The signed bundle, downloaded from the project's release by make learnings; not in git |
| A CUDA GPU | Optional | Everything runs on the CPU; only full training is slow there |
make install # .venv, CPU build of PyTorch (about 1.5 GB)
make install TORCH=cuda # or the CUDA build (about 6 GB)
make learnings # downloads learnings.zip and checks it against signature.txtBy hand, the same steps are:
python3.13 -m venv .venv # with an installed Python 3.13
uv venv --python 3.13 --seed .venv # or with uv (downloads 3.13 if needed)
source .venv/bin/activate
pip install -r requirements.txt # NVIDIA GPU: CUDA build, about 6 GB
pip install -r requirements.txt \
--extra-index-url https://download.pytorch.org/whl/cpu # CPU build, about 1.5 GBThe installation is checked with:
python -c "import torch, plantcv, cv2; print(torch.__version__, 'CUDA:', torch.cuda.is_available())"sha1sum -c signature.txt # Linux: learnings.zip: OK
shasum -c signature.txt # macOSFAILED means learnings.zip is not the archive that was signed: another
training run, or a re-compressed copy. predict.py reads the model straight
from the zip, so the prediction always comes from the signed file; extracting
it (unzip -q learnings.zip) is only needed to browse its images.
Every program prints its options with -h, and saves its figures as PNG in
the current directory when there is no display (SSH, CI, MPLBACKEND=Agg).
| Program | Main options |
|---|---|
Distribution.py <dir> |
A dataset or a single class |
Augmentation.py <path> |
--dst DIR, --target N images per class, --seed N (7) |
Transformation.py <image> |
Or -src PATH -dst DIR, with -blur -mask -roi -analyze -pseudo -hist to pick views |
train.py <dir> |
--epochs (20), --batch-size (64), --img-size (128), --lr (0.002), --val-split (0.2), --seed (7), --device |
predict.py <path> |
An image or a directory; --learnings (learnings.zip), --device |
images/ Apple/
βββ Apple_Black_rot/ βββ apple_healthy/
βββ Apple_healthy/ βββ apple_scab/
βββ ... βββ ...
βββ Grape_spot/
Every directory that directly holds images is a class, named after the
directory. The plant type is the parent folder when nested, otherwise the part
of the name before the first underscore. Extensions .jpg .jpeg .png .bmp .tif .tiff are read in any letter case; hidden files and folders are ignored.
| Symptom | Cause and fix |
|---|---|
No matching distribution found for scipy / plantcv |
Wrong Python version: use 3.13 |
No space left on device during pip install |
Use the CPU build, or create the environment on a larger disk and link it |
| Figures saved as PNG on a desktop | This Python has no tkinter: install python3-tk or use uv's Python |
ModuleNotFoundError |
The virtual environment is not active |
learnings: exists and was not created by train.py |
train.py refuses to wipe a folder it did not create: use --workdir, --out and --signature |
π Project Structure
Leaffliction/
β
βββ README.md # Main project documentation
βββ requirements.txt # Pinned dependencies
βββ signature.txt # SHA-1 of learnings.zip
βββ Makefile # install (.venv), learnings, predict, the programs, test, lint
βββ .gitignore # Dataset, learnings, generated folders, headless figures (docs/img is kept)
β
βββ docs/
β βββ README.md # Condensed project documentation
β βββ img/ # Figures drawn by the programs, used in this README
β
βββ src/
β βββ Distribution.py # Class counts and charts
β βββ Augmentation.py # One image augmented, or a dataset balanced
β βββ Transformation.py # PlantCV views, displayed or saved
β βββ train.py # Split, balance, train, package, sign
β βββ predict.py # Classify one image or score a folder
β β
β βββ leaffliction/
β βββ cli.py # Error handling, exit codes, figure display
β βββ imageio.py # Reading and writing images (BGR, any path)
β βββ dataset.py # Classes, plant types, leaf groups, split
β βββ augment.py # The six augmentations
β βββ balance.py # Balancing plan and file jobs
β βββ parallel.py # Process pool with progress
β βββ mask.py # Leaf segmentation
β βββ transform.py # The PlantCV views
β βββ histogram.py # Colour histograms
β βββ figures.py # Every matplotlib figure
β βββ data.py # Network input preparation
β βββ model.py # LeafCNN
β βββ training.py # Training loop, inference, report
β βββ learnings.py # Work directory, zip, signature, loading
β
βββ tests/ # pytest suite on synthetic leaves
The five programs are thin: they parse their arguments and call the
leaffliction package, so training and prediction share the very same
segmentation and preprocessing.
learnings.zip
βββ learnings/
βββ model.pt # network weights (PyTorch state dict)
βββ meta.json # class names, input size, seed, split sizes, validation accuracy
βββ metrics.txt # validation report
βββ train/<class>/ # balanced training set: augmented, background removed
βββ validation/<class>/ # untouched validation images
π§± Pipeline Overview
images/ β> leaf groups β> split per leaf (80 / 20, stratified)
train β> balance with augmentations β> segment β> remove background β> 128Γ128 β> LeafCNN
validation (originals only) βββββββββ> segment β> remove background β> 128Γ128 β> score
model.pt + meta.json + metrics.txt + images β> learnings.zip β> sha1 β> signature.txt
| Technique | What it does | Why it is plausible for a leaf |
|---|---|---|
| Flip | Mirror horizontally, vertically or both | Leaves have no fixed orientation |
| Rotate | 15β45Β° either way | A photo can be taken at any angle |
| Skew | Projective tilt: one edge shrinks toward its centre | Camera not parallel to the leaf |
| Shear | Slant along x or y | Same, with an affine model |
| Crop | Keep 70β85 % of the image, scale back up | Zoom, partial framing |
| Distortion | Smooth sinusoidal warp of the pixel grid | Leaves are not flat |
Areas uncovered by a transform take the image's median border colour, the background, rather than black or a mirror copy, so the segmentation never mistakes them for leaf. Balancing only uses originals as sources and gives each source every technique at most once, so a class can grow to at most 7Γ its originals; a warning says so when the target is out of reach.
channel = b β a (LAB) leaves green to brown: high b, low a; grey or purple backgrounds: the opposite
sure = channel > Otsu
leaf = sure βͺ (pixels above the background level connected to sure) hysteresis: lesions and glare
leaf = blur β threshold β opening β closing β largest component β fill holes
A channel with almost no contrast (a blank image) is reported as "no leaf". Removing the background matters: it stops the network from learning shortcuts from the photo setup β background colour, shadows β instead of the disease.
A file and its augmented copies (x.JPG, x_Flip.JPG, β¦), and byte-identical
photos (the dataset has 7 such pairs), share one group, and a whole group lands
on one side of the split. Validation keeps original images only. Augmenting
first and splitting afterwards would put copies of the same leaf on both sides:
that is data leakage, and it inflates the score.
| Stage | Layers | Output (128 px input) |
|---|---|---|
| Block 1 | 2 Γ (Conv 3Γ3 β BatchNorm β ReLU) β MaxPool 2 | 32 Γ 64 Γ 64 |
| Block 2 | same | 64 Γ 32 Γ 32 |
| Block 3 | same | 128 Γ 16 Γ 16 |
| Block 4 | same | 256 Γ 8 Γ 8 |
| Head | Global average pool β Dropout 0.3 β Linear | One score per class |
Trained with cross-entropy and label smoothing 0.05, AdamW, a one-cycle learning rate peaking at 0.002, random flips and quarter turns on each batch, and mixed precision on GPU. The number of epochs is fixed in advance and the validation set never chooses a checkpoint, so its score is an honest estimate.
./src/train.py images/ with the default settings (seed 7, 20 epochs, 128 px),
on an RTX 4050 laptop GPU, in 3 min 14 s:
| Split | Images | Accuracy |
|---|---|---|
| Training (balanced, augmented) | 10 488 | 99.98 % |
| Validation (originals, leaves never seen in training) | 1 445 | 100.00 % |
The confusion matrix is diagonal, and a second run with another seed also scored 100 %. The photos are taken in controlled conditions, one leaf on a plain background; on field photos β cluttered backgrounds, several leaves, other lighting β accuracy would be lower, since the segmentation assumes one leaf on a uniform background.
- Looking at the data first: class imbalance, duplicates and the way the photos were taken matter as much as the model
- Data augmentation: geometric transforms that keep a leaf plausible, used both to balance classes and to regularise the network
- Classical computer vision: colour spaces, Otsu thresholding, hysteresis, morphology and connected components to segment an object
- Feature extraction with PlantCV: masks, shape analysis, landmarks and colour histograms, and what each says about a disease
- Convolutional networks: convolutions, batch normalisation, pooling, dropout and softmax, and how they combine into a classifier
- Honest evaluation: splitting by leaf, never tuning on validation, and why data leakage makes a score meaningless
- Reproducibility: seeds everywhere, one preprocessing function for training and prediction, and a signed bundle to prove which model was evaluated
- Language: Python 3.13
- Dependencies: PyTorch 2.14, PlantCV 4.11, OpenCV 4.11, NumPy 2.4, Matplotlib 3.11; pytest and flake8 for the checks
- Dataset: 7 221 RGB images of 256Γ256 in 8 classes, from 275
Apple_rustto 1 640Apple_healthy - Split: per leaf group, stratified per class, 80 % training and 20 % validation; only the training part is balanced
- Network input: leaf on white, 128Γ128, scaled to
[-1, 1] - Model:
LeafCNN, 4 convolution blocks, 1.2 M parameters - Training: 20 epochs, batches of 64, AdamW, one-cycle to 0.002, label smoothing 0.05; about 3 minutes on a laptop GPU
- Measured: 100.00 % on 1 445 validation images, 99.98 % on the 10 488 balanced training images
- Bundle:
learnings.zipwith the model, the metadata, the report and the images, published as a release asset and not in git; SHA-1 insignature.txt,sha1sumformat - Exit codes:
0success,1error,2invalid arguments,130interrupted - Tests: 107 pytest tests on synthetic leaves, flake8 clean
- Python 3.13, with a virtual environment
- The packages of
requirements.txt(CUDA or CPU build of PyTorch) - The leaf dataset in
images/to train or to run Distribution and Augmentation on it learnings.zipbyte for byte, checked againstsignature.txt, to predictcurlandunzip, or a browser, to getlearnings.zipfrom the project's release
Note
Leaffliction gets a perfect score not because the network is large but because the data is handled carefully: the leaf is cut out before the model sees it, no copy of a validation leaf is ever trained on, and the result is signed so that anyone can check it is the model that was actually trained.





