Iris 9
An offline plant species classifier for phones. One 9 MB TensorFlow Lite file, 1,569 species, about one second per photo on a recent handset, no network call.
Iris is the on-device model of Auxine, a plant care app. It is published here on its own so that it can be used, measured and criticised outside that app. Training and evaluation code: github.com/brunopaiva15/iris.
The model in one table
Every number below is read from model.json, which is produced by the export
itself. Nothing here is typed by hand.
| Version | 9 |
| Architecture | MobileNetV3-Large, ImageNet-pretrained backbone, softmax head |
| Exposed classes | 1,569 species |
| Input | 320 Γ 320 Γ 3, float32, values in 0β255 |
| Normalisation | inside the graph (included_in_graph_uint8_0_255) |
| Output | 1,569 softmax probabilities |
| Weights | float16 TFLite, 9,005,584 bytes |
| SHA-256 | a919ae6a24f7e7717a36d5213a800a3410f7cee0f694f5014ba8eb07f0a6caa3 |
| Top-1 / Top-3 | 0.6876 / 0.8227 on 30,954 held-out test images |
| Macro F1 | 0.6702 |
| Mean confidence | 0.7299 |
On cultivated plants β the photographs the app actually sees β top-1 is 0.6728 and top-3 0.8101 over 4,649 images, rising to 0.7650 when the indoor context mask is applied (see Context masks).
Files
| File | What it is |
|---|---|
plants.tflite |
the weights |
labels.txt |
one internal id per line, in output order (monstera-deliciosa) |
model.json |
version, input recipe, SHA-256, metrics, threshold curve, species map (id β scientific name), masks |
labels.txt and the species map in model.json are the only two things you
need to turn an output index into a name. model.json is the model's own fact
sheet: read it rather than hardcoding any value from this page.
Quick start
pip install huggingface_hub ai-edge-litert pillow numpy
import json
import numpy as np
from PIL import Image, ImageOps
from huggingface_hub import hf_hub_download
from ai_edge_litert.interpreter import Interpreter
REPO = "brunopaiva15/iris"
weights = hf_hub_download(REPO, "plants.tflite")
meta = json.load(open(hf_hub_download(REPO, "model.json")))
labels = open(hf_hub_download(REPO, "labels.txt")).read().split()
def prepare(path, size, load_size, source_size):
"""Centre square, area-resize to source_size, bilinear to load_size,
centre crop to size. The two-step resize is not optional β see below."""
im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
side = min(im.size)
left, top = (im.width - side) // 2, (im.height - side) // 2
im = im.crop((left, top, left + side, top + side))
if im.width > source_size > load_size:
im = im.resize((source_size, source_size), Image.BOX)
im = im.resize((load_size, load_size), Image.BILINEAR)
off = (load_size - size) // 2
im = im.crop((off, off, off + size, off + size))
return np.asarray(im, dtype=np.float32)[None] # 0β255, NOT 0β1
interp = Interpreter(model_path=weights)
interp.allocate_tensors()
inp, out = interp.get_input_details()[0], interp.get_output_details()[0]
x = prepare("photo.jpg", meta["input_size"], meta["load_size"], meta["source_size"])
interp.set_tensor(inp["index"], x)
interp.invoke()
scores = interp.get_tensor(out["index"])[0]
for i in scores.argsort()[::-1][:5]:
print(f"{meta['species'][labels[i]]:40s} {scores[i]:.4f}")
On a photograph of a Monstera deliciosa this prints Monstera deliciosa 0.9119, the next candidate two orders of magnitude behind.
A fuller script β context masks, the acceptance rule, multiple photos β is in
examples/identify.py.
Preprocessing, exactly
The graph normalises its own input, so the caller only decodes and frames. But the framing is part of the model: the training images went through a two-step reduction, and skipping it costs several points of top-1, measured.
- honour the EXIF orientation;
- crop the largest centred square;
- if the square is wider than
source_size(448), area-average it down to 448 β this is the state the training images were stored in; - bilinear resize to
load_size(366); - centre crop to
input_size(320); - hand over float32 values in 0β255. Do not divide by 255, do not apply ImageNet mean/std. The rescaling layer is in the graph.
Resizing a 4,000 px phone photo straight to 366 px produces aliasing the model has never seen in training. Step 3 is what avoids it.
Output, and the two context masks
The output is a softmax over all 1,569 classes. model.json also carries a
masks object:
"masks": { "indoor": ["monstera-deliciosa", β¦], "outdoor": ["abies-alba", β¦] }
336 classes indoor, 1,424 outdoor. When you know where the plant lives, keep
only that mask's classes and renormalise by their sum. This is not an
approximation of a smaller model β it is exactly equal to it: dropping columns
from the last layer and re-applying softmax gives exp(zα΅’) / Ξ£_kept exp(zβ±Ό),
which is what dividing the kept probabilities by their mass computes.
Measured on cultivated plants:
| top-1 | accepted at 0.70 | precision when accepted | |
|---|---|---|---|
| indoor mask applied | 0.7650 | 65.7 % | 0.9284 |
| no mask | 0.7230 | 67.5 % | 0.9030 |
And on the full test split:
| classes | top-1 | top-3 | |
|---|---|---|---|
| outdoor mask | 1,424 | 0.6898 | 0.8246 |
| no mask | 1,569 | 0.6876 | 0.8227 |
The value of a mask is in what it removes, not in what it keeps: the indoor mask drops 1,233 classes and buys 4.2 points; the outdoor mask drops 145 and buys two tenths.
Do not use a mask as a hard filter. A class outside the context should be demoted, never deleted β a model that cannot say the right answer will say a wrong one with confidence.
When to trust an answer
A classifier always answers something, including in front of a cat. The app accepts the model's answer alone only when
best score β₯ 0.70 and best β second β₯ 0.25
and treats anything below 0.10 as no answer at all. Otherwise it asks a remote
service. model.json carries the full threshold_curve β autonomy and
precision for every (threshold, margin) pair β so you can pick the trade-off
your product needs rather than inheriting this one:
| threshold | accepted | precision when accepted |
|---|---|---|
| 0.50 | 74.3 % | 0.8371 |
| 0.60 | 67.2 % | 0.8736 |
| 0.70 | 60.8 % | 0.9051 |
| 0.80 | 53.9 % | 0.9361 |
A threshold does not travel from one version to the next. Re-read it from the curve of the model you actually ship.
More than one photo
Averaging the probability vectors of two photos of the same plant was worth +13.7 points of top-1 and three photos +22.4, measured on Iris 7 with the app's exact cascade (not re-measured on Iris 9). It costs no extra model and no extra training. If your interface can ask for a second photo when the first answer is uncertain, do it before anything else.
Training data
About 992,000 photographs of roughly 5,300 species, collected from public biodiversity sources, with a hard licence filter applied twice β once in the query, once on each individual image:
| Licence | Kept |
|---|---|
| CC0 1.0, Public Domain Mark | yes |
| CC BY 2.0 β 4.0 | yes |
| CC BY-SA | yes |
| CC BY-NC, ND, NC-SA, NC-ND | no |
| unknown, absent, proprietary, scraped | no |
Sources: GBIF occurrence media, iNaturalist (the only source that photographs plants as people actually grow them, indoors and in pots), Wikimedia Commons. Each kept image carries its source, source id, author, licence and checksum in the collection manifest.
Cleaning: readability, EXIF orientation, shortest side β₯ 320 px, aspect ratio β€ 1:12, reduction to 1024 px then to 448 px, JPEG. Exact duplicates by SHA-256 and near-duplicates by 64-bit DCT perceptual hash (Hamming β€ 6) within a species. Splits are 80/10/10 by observation group, so a photograph and its near-duplicates never land on both sides of the split.
Per-image attribution is not shipped with this model
The collection pipeline writes an ATTRIBUTIONS.md and an attributions.csv
β author, licence and observation link for every kept image. Those files
were lost with the training machine and are not published here. The
provenance that remains is structural rather than per-image: the sources, the
licence filter, and the collection code, which rebuilds an equivalent
manifest from scratch (collection/build_dataset.py in the GitHub repo).
This is stated plainly rather than quietly: if you need per-image credit β for a CC BY compliance review, for instance β it is not available for the exact set this model saw, and must be regenerated by re-running the collection.
Training procedure
A single head is trained wide and exported narrow. The backbone sees every species collected; the shipped head exposes only the 1,569 the product serves, because every exposed class competes for the answer with all the others β and 1,444 β 5,259 outputs cost 10.2 points of top-1 on the same weights.
| Backbone | MobileNetV3-Large, ImageNet weights, top 100 layers unfrozen |
| Head | global average pooling β dropout 0.5 β dense softmax |
| Input | 320 px, batch 64, mixed precision (softmax kept in float32) |
| Fine-tuning | 30 epochs, ~12,400 steps of 64 per epoch, 8 h on one RTX 2070 Super |
| Early stopping | on val_accuracy, patience 4 β it never fired |
| Export | float16 TFLite, then the head re-sliced to the union of the two masks |
Validation accuracy was still climbing at epoch 30 (0.5473, val_loss 2.2515). This model is not a converged one; it is the one that was shipped.
Limitations
- It cannot say "not a plant." All 1,569 outputs are species, so the mass is always spread over species. On 40 out-of-catalogue plants measured on the previous versions, 27.5 % came back above the 0.70 acceptance threshold β confidently wrong, no fallback triggered. A margin rule and a floor are the only guards here; there is no "other" class. Treat high confidence on an unknown species as a known failure mode, not an edge case.
- Wild-plant photographs dominate. The data are citizen-science observations, overwhelmingly from Europe and North America, mostly of plants in the ground and in flower. Potted indoor plants are the weakest-covered domain and the one the app cares most about β hence the indoor mask.
- Errors cross the botanical family in about 73 % of cases (measured on an earlier version). These are not near-misses between look-alike species; they are photographs outside the learned domain.
- Six species appear under two accepted names among the exposed classes (Cupressus macrocarpa / Hesperocyparis macrocarpa, Dracaena / Sansevieria trifasciata, Echinocactus / Kroenleinia grusonii, Coleus / Plectranthus scutellarioides, and two Citrus pairs). Each splits its own images and each halves its own score.
- Cultivars are not distinguished. A variegated Monstera is a Monstera deliciosa.
- It is not a diagnostic tool. Species identification is not edibility, toxicity, medicinal or legal advice, and a 0.99 score is not a determination. Anything that could harm a person or an animal needs a human expert.
Intended use
Suggesting candidate species to a person who then chooses, on-device, offline β in a plant app, a garden notebook, a museum kiosk, a field survey. The output is a ranked list with scores, meant to be shown as such.
Out of scope: anything that acts on an identification without a human, and any foraging, medical, veterinary or regulatory decision.
Licence and attribution
The weights, labels.txt and model.json are released under
CC BY 4.0. Commercial use and
derivatives are allowed, with attribution. The training and evaluation code in
the GitHub repository is Apache-2.0.
Suggested credit: Iris 9 β brunopaiva15, CC BY 4.0.
The training images themselves are not redistributed here, in any form a person can look at. They belong to the thousands of naturalists who published them under CC0, CC BY and CC BY-SA on GBIF, iNaturalist and Wikimedia Commons. This model exists because of them. The position taken when CC BY-SA images were let into the training set is written down: a trained network is not an adaptation of the photographs β it reproduces none of them, and never redistributes them. That position has not been tested in court.
Citation
@software{iris9_2026,
title = {Iris 9: an offline plant species classifier},
author = {brunopaiva15},
year = {2026},
url = {https://huggingface.co/brunopaiva15/iris},
note = {MobileNetV3-Large, 1569 species, TensorFlow Lite}
}
Version history
| Version | What changed |
|---|---|
| 9 | one head, two context masks (indoor + outdoor) in a single file; 1,569 exposed classes; first full uninterrupted fine-tuning pass |
| Indoor | 336 indoor classes cut from an earlier head; the app's previous model |
| 8 | 5,259 classes trained, 1,444 exposed β the version that proved width is paid for at the output, not in training |
| 7 | 320 px input, dropout 0.5, acceptance threshold back to 0.70 |
| 1β6 | MobileNetV3-Small, 78 β 1,445 classes |
- Downloads last month
- 6
Evaluation results
- Top-1 on Iris held-out test split (2026-09)self-reported0.688
- Top-3 on Iris held-out test split (2026-09)self-reported0.823
- Macro F1 on Iris held-out test split (2026-09)self-reported0.670