A modular deep learning pipeline for automated high-throughput leaf segmentation and trait classification.
High-throughput plant phenotyping platforms take thousands of images, and outlining every leaf by hand does not scale. PhenoSelect finds each single leaf in a canopy image with a YOLOv11 instance segmentation model and cuts it out. A second model then sorts the cutouts by traits you define yourself, for example visibility or health. Every leaf ends up as one row in a CSV file, ready for further analysis such as leaf area or leaf-level NDVI.
The full story, with figures and results, is in the blog post: PhenoSelect: Training a Neural Network Because I Refuse to Click on 100,000 Leaves.
The pipeline has two stages.
- Segmentation. You outline leaves on a set of training images in LabelMe. PhenoSelect converts the labels to YOLO format, splits them into train, validation and test sets, and trains a YOLOv11 segmentation model. The trained model then finds the leaves in new images and saves a cutout and a mask for each one.
- Classification. You label a share of the leaf cutouts with the labeling tool in
toolbox/. A ResNet18 classifier with one output head per label group learns these labels and then labels every cutout.
A label such as "not a plant" is a handy safety net. It lets the classifier catch pot rims, label stakes or soil that the segmenter mistook for leaves, so you can drop them from the results.
- Python 3.10.
- These Python packages (there is no requirements file yet):
- pipeline:
ultralytics,torch,torchvision,opencv-python,tifffile,numpy,pillow,pandas,pyyaml,tqdm - analysis scripts: also
matplotlib,seaborn,scipy,scikit-learn,scikit-posthocs,statsmodels - labeling tool:
tkinter
- pipeline:
- LabelMe to draw the leaf polygons for training.
- A CUDA GPU is used for training when one is available. Without one, the scripts fall back to the CPU.
- Internet access the first time you train, to download the YOLO11 weights.
The pretrained PhenoSelect models are too large for GitHub. To get them, please contact me (see Contact).
Run every script from the repository root. All paths are relative to it, and every script appends its log to master_log.txt.
| Folder | Contents |
|---|---|
train_image_dump/ |
Training images (.tiff) with their LabelMe .json files |
train_data_split/ |
Train, val and test sets made by 12_split_data_set.py |
inference_in/ |
Images you want to segment (.tiff) |
inference_out/ |
Everything the pipeline produces (cutouts, masks, CSV files, plots) |
runs/ |
Training runs and trained models (created on first training) |
models/ |
YOLO11 base weights and the pretrained PhenoSelect models (not in git) |
all_images_all_spec/ |
Raw multispectral images for the analysis scripts |
toolbox/ |
Helper tools, including the labeling tool |
analysis_scripts/ |
Trait extraction, statistics and plots |
99_run_pipeline.py runs the numbered scripts in the right order. It has one function per use case:
| Function | What it runs |
|---|---|
default_pipeline_segmenter() |
11, 12, 14, then 22 with --epochs 100 --batch-size 4, then 23 |
default_pipeline_classifier() |
52, then 53 |
data_preparation_only() |
11, 12 and 14 |
continue_training_segmenter() |
22 with --epochs 100 --batch-size 4 --continue-training |
inference_segmenter_pretrained_model_own_data() |
23 with --use-sample-model --sample-model-size x |
classifier_pretrained_model_own_data() |
53 with --use-sample-model |
inference_classifier_pretrained_model_own_data() |
53 with --use-sample-model and the sample annotations.csv |
default_pipeline_classifier() runs 53 with its default model folder, which is not where 52 saves the model. See step 8 for the fix.
As shipped, the __main__ block calls every function one after the other. Comment out the ones you don't need, then run:
python 99_run_pipeline.py
The numbered pipeline scripts (11 to 53) take --help, which lists all of their arguments with their defaults.
The pipeline reads .tiff files. Inference opens each image as a three-channel picture, so multispectral data has to be packed into three channels first. Two toolbox scripts do that:
toolbox/01_replace_B_by_NIR1.pypairs an RGB image (_Source0_in the file name) with a NIR image (_Source1_) taken within 6 seconds of it. It writes a TIFF with the channels red, green and NIR, named after the RGB file. File names must start with aYYYYMMDD_HHMMSStimestamp. Set the input and output folders at the bottom of the script.toolbox/05_open_hsi_image.pyandtoolbox/06_hsi_to_rgb.pyturn a hyperspectral cube into a pseudo-RGB image (see Toolbox).
Outline the leaves in LabelMe and put the images and their .json files into train_image_dump/.
python 11_convert_lableme_to_yolo.py
python 12_split_data_set.py
python 13_verify_yolo_labes.py
python "14_generate,update_21_model_config.py"
11_convert_lableme_to_yolo.pywrites one YOLO.txtlabel file per image and a class list,classes_(read_only).txt. It deletes old.txtlabel files in the output folder first. Use--show-groupsto preview the classes without changing anything.12_split_data_set.pycopies image and label pairs intotrain_data_split/(80 % train, 10 % val, 10 % test by default). The split is done separately for each combination of classes, so every split gets its share of each class. Options:--train-ratio,--val-ratio,--test-ratio,--image-ext(default.tiff) and--no-clean.13_verify_yolo_labes.pyis optional. It shows each image with its label polygons. Usea/dor the arrow keys to browse andEscto quit.14_generate,update_21_model_config.pywrites21_model_config.yamlfor YOLO. Its default--classes-filepath uses a Windows backslash. On Linux or macOS, pass it yourself:--classes-file "train_image_dump/classes_(read_only).txt".
python 22_train_segmenter.py --epochs 100 --batch-size 4
--model-sizepicks the YOLO11 size:n,s,m,lorx(defaultx). The base weights are downloaded tomodels/architecture_model/on first use.- The default for
--epochsis 10000, so set it yourself. --imgsz(default 1024) and--device(defaultcuda) set the image size and device.--continue-trainingstarts from the newestbest.ptinruns/.--use-pretrained-modelstarts from the pretrained PhenoSelect model inmodels/pretrained_model_data/segmentation/instead of the plain YOLO11 weights.- The augmentation settings have their own flags (
--hsv_h,--degrees,--mosaic,--mixup,--copy-pasteand more).
Training results go to runs/segment/. Careful: without --continue-training or --use-pretrained-model, the script deletes the whole runs/ folder before it starts. Copy any models you want to keep first.
Put the images into inference_in/ and run:
python 23_inference_segmenter.py
It uses the newest best.pt in runs/, or the model given with --model-path. With the pretrained models, use --use-sample-model --sample-model-size x, and add --use-sample-images to run on the example images that come with them.
You can limit the analysis to one plot per image. Put a LabelMe .json file with the same name as the image into inference_in/plot_border/ and draw a polygon or rectangle labelled plot. --leaf-inclusion-mode then decides which leaves count: touching_or_inside (default) or fully_inside. Images without a border file are used in full.
Output in inference_out/ (cleaned first unless you pass --no-clean):
| Path | Contents |
|---|---|
numeric/results.csv |
One row per leaf |
frames/ |
Each input image with all kept leaves drawn on it |
cutouts_masked/ |
Each leaf on a black background |
cutouts_leaf_only/ |
Each leaf's bounding box, background kept |
cutouts_with_outline/ |
Same, with the leaf outline drawn in red |
binary_masks/ |
A black and white mask per leaf |
results.csv has these columns: image_name, image_title (the TIFF description tag), original_width, original_height, instance_number, bbox_center_x, bbox_center_y, bbox_width, bbox_height, confidence, class_id, class and polygon_coordinates (pixel coordinates as x,y;x,y;...).
python toolbox/02_classification_labeling.py
The labeling tool opens in its own window. Click the gear button to set it up:
- Image folder (default
inference_out/cutouts_masked) and output CSV (defaultannotations.csvin that folder). - Labeling mode. Categorical gives one label per group for each image and writes one column per group. This is the format the classifier reads. Multi-Label lets you give one image several labels.
- Label groups, their labels and a key for each label.
Keys while labeling: your label keys, Space to skip an image, Backspace to undo, r to jump to a random unlabeled image, Enter to confirm in Multi-Label mode and Esc to quit. Labels are saved as you go, and the settings are kept in toolbox/config.json.
The classifier treats some labels in a special way. Skipped images are left out. A label that starts with unknown_, or the label not_visible, means "can't tell" and does not count towards training. The default groups use this already: visibility (fully_visible, partly_visible, not_visible) and health (healthy, unhealthy, unknown_health).
python 52_train_classifier.py
It reads inference_out/cutouts_masked/annotations.csv and trains a ResNet18 (ImageNet weights) with one output head per label group. Images are resized to 224 × 224 pixels and split 80/10/10. Defaults: --epochs 300, --batch-size 1024, --lr 0.001. --show-groups prints how labels map to numbers and stops.
The model with the best average validation accuracy is saved as runs/segment/all_species/best_multihead_classifier.pt, with the training history in results.csv next to it. The script also tries to train one extra model per segmentation class, using the class column of inference_out/numeric/results.csv. Those go to runs/segment/<class>/.
python 53_inference_classifier.py --global-model-dir runs/segment/all_species --per-species-model-dir runs/segment
By default this script looks for models in runs/classifier/, while step 7 saves them in runs/segment/, so point it there as shown. It rebuilds the label names from the annotation file, so keep that file in place (or pass --annotation-file). With the pretrained models, use --use-sample-model instead.
The script adds one column per label group and a classifier_model_used column to inference_out/numeric/results.csv. Use --output-csv-path to write somewhere else. It also saves up to 300 random cutouts with their predicted labels in inference_out/visualized_classifier_inference/ (--visualize-count to change the number, --no-visualize to turn it off).
The scripts in analysis_scripts/ turn results.csv into traits, statistics and plots. They were written for the showcase datasets, so their paths are set inside each script and some of them expect that experiment's naming. Most of them add columns to inference_out/numeric/results.csv in place. Run them from the repository root, roughly in this order:
| Script | What it does |
|---|---|
31_canopy_coverage.py |
Adds polygon_area, the leaf area in pixels |
32_create_5ch.py |
Stacks RGB (_Source0_), NIR1 (_Source1_) and NIR2 (_Source2_) images taken within 6 seconds into 5-channel TIFFs. Reads all_images_all_spec/, writes temp/combined_spectral_cutouts_5channel/ |
33_5ch_cutout.py |
Cuts every leaf out of the 5-channel images, background set to 0. Writes temp/final_masked_cutouts_5channel/ |
34_indecis.py |
Adds the mean NDVI, NDWI, EVI and NRGI of each leaf (NDVI_avg and so on), using NIR2 as the NIR band |
35_add_variety_and_treatment.py |
Adds species, variety, treatment, plot_id and date, decoded from the image title and file name. The codes (Hosta, Convallaria, Astilbe, blueberry varieties, irrigation levels) belong to the showcase experiment, so edit the maps at the top for your own data |
36_data_aggregation.py |
Writes per-image results to inference_out/numeric/image_level_results.csv and per-plant canopy spread (a convex hull around the leaf centres) to inference_out/numeric/plant_level_canopy_results.csv, with plots in inference_out/canopy_spread_visuals/. It keeps only species and treatment combinations measured on exactly four dates |
36_track_leaves.py |
A first attempt at following single leaves across dates within each plot. Adds leaf_id and draws the tracks into inference_out/visual_tracking/ |
37_stat.py |
Fits a linear mixed-effects model per trait and species (treatment and time as fixed effects, plant as group) and saves plots to inference_out/statistics/ |
38_visulize_data.py |
Plots each leaf's centre coloured by its NDVI, one plot per image, in inference_out/spatial_plots/ |
36_track_leaves.py and 37_stat.py still contain absolute Windows paths. Change them before you run these two.
| Script | What it does |
|---|---|
01_replace_B_by_NIR1.py |
Builds red, green, NIR TIFFs from RGB and NIR image pairs (see step 1) |
02_classification_labeling.py |
The labeling tool (see step 6) |
03_test_models.py |
Trains several YOLO11 sizes (YOLO_MODEL_SIZES_TO_TEST, default l and x) for 150 epochs and prints a table of their mAP scores. Halves the batch size when the GPU runs out of memory. Runs go to runs/segment/model_comparison_runs/, best weights to models/finetuned_comparison_results/. Add | tee -a results.log to keep the output |
04_write_coulumn_form_results_to_annotation.py |
Copies the class column from results.csv into an annotation CSV. Paths are set at the top |
05_open_hsi_image.py <file.raw or folder> [--save cube.npy] |
Reads an ENVI hyperspectral image (.raw plus .hdr) and saves the cube as .npy |
06_hsi_to_rgb.py <file.raw, file.npy or folder> [--out path] [--show] |
Makes a pseudo-RGB TIFF from a hyperspectral cube. Bands up to 650 nm are weighted with the colour sensitivity curves of a Thorlabs Zelux 1.6 MP colour camera, so the result looks like a normal camera image. Needs the .hdr file next to the cube |
07_reduce_channels.py <image_dir> <target_channels> |
Keeps only the first target_channels channels of every TIFF in a folder. Overwrites the files |
results/model/model_size_comparison/plot_model_size_comparison.py plots the mask mAP50 and validation box loss of each model size from the 03_test_models.py runs into model_performance_comparison.pdf. Set RAW_DATA_PATH at the top first.
Several scripts have flags for the pretrained models and sample data: --use-sample-model, --use-sample-images, --use-sample-data and --use-pretrained-model. They all expect the models/pretrained_model_data/ folder, which is not on GitHub. Contact me to get it.
From the blog post:
- On a multi-species RGB-NIR dataset from a gantry-based phenotyping platform, the YOLOv11x segmentation model reached a mAP50 of 0.949 on the validation set.
- On hyperspectral images from a Specim IQ camera, turned into pseudo-RGB with
06_hsi_to_rgb.py, the model scored a mAP above 0.8 without any extra training. After fine-tuning on 30 more images it reached a mAP50 of 0.903. - On a seven-year-old desktop PC the whole chain took about one second per image, from loading the image to saving the classified leaves.
Known limits: in the hyperspectral tests, the model sometimes mixed up blueberry leaves and other species in mixed canopies. Tracking single leaves over time (36_track_leaves.py) is only a first attempt.
Questions, problems, or want the pretrained models? Get in touch through moritznelle.com/contact-me.