iris / README.md
brunopaiva15's picture
Iris 9
7e618aa verified
|
Raw History Blame Contribute Delete
14.6 kB
---
license: cc-by-4.0
pipeline_tag: image-classification
tags:
- image-classification
- plant-identification
- plants
- biodiversity
- tflite
- on-device
- mobile
- mobilenetv3
metrics:
- accuracy
model-index:
- name: Iris 9
results:
- task:
type: image-classification
name: Plant species classification
dataset:
name: Iris held-out test split (2026-09)
type: custom
metrics:
- type: accuracy
name: Top-1
value: 0.6876
- type: accuracy
name: Top-3
value: 0.8227
- type: f1
name: Macro F1
value: 0.6702
---
# 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](https://vergasta.ch/auxine/en/),
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](https://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
```bash
pip install huggingface_hub ai-edge-litert pillow numpy
```
```python
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`](https://github.com/brunopaiva15/iris/blob/main/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.
1. honour the EXIF orientation;
2. crop the **largest centred square**;
3. 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;
4. bilinear resize to `load_size` (366);
5. centre crop to `input_size` (320);
6. 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:
```json
"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](https://creativecommons.org/licenses/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
```bibtex
@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 |