lepinet · DINOv3 ConvNeXt-L: moth and butterfly identification
Identifies Lepidoptera (moths and butterflies) from a photo at three ranks at once: species (12,041), genus (4,333) and family (102). The three answers are coherent, because genus and family are computed from the species probabilities.
This is the mid-size model of the lepinet project: a DINOv3 ConvNeXt-L (217 M parameters). On light-trap images it is as accurate as the larger BioCLIP-2 model (species macro-F1 0.777 vs 0.783) and 1.5 points behind it on ordinary photos (0.906 vs 0.921 over the full GBIF test fold). Under a 95 %-precision back-off it names the species on more light-trap images than the other released models, and answers "unknown" more often; see Which lepinet model?.
| Architecture | DINOv3 ConvNeXt-L backbone (timm convnext_large.dinov3_lvd1689m), fine-tuned end to end, plus a 1536-d cosine classifier; 217 M parameters |
| Input | one RGB image of one insect, 320×320 |
| Output | species, genus and family probabilities, calibrated species logits, a 1536-d embedding |
| Format | ONNX: fp32 (869 MB), fp16 for GPUs (476 MB), int8 for CPUs (245 MB). Runs with onnxruntime alone |
| Licence | DINOv3 License, plus a non-commercial request for the training data (see Licence) |
| Other sizes | lepinet-effnetv2s (37 M, fast on CPU) · lepinet-dinov3-convnextl (217 M) · lepinet-bioclip2-vitl14 (321 M); all in one collection |
Which lepinet model?
The three released models are about equally accurate on light-trap images (species macro-F1 0.783, 0.777 and 0.765) but answer differently when they are allowed to back off to genus or family. Under the same 95 %-precision back-off policy, with thresholds fitted on half of the held-out light-trap nights and measured on the other half:
| model | size | correct at species | correct at any rank | no answer |
|---|---|---|---|---|
lepinet-bioclip2-vitl14 | 321 M | 67.4 % | 86.4 % | 10.1 % |
lepinet-dinov3-convnextl | 217 M | 74.7 % | 77.2 % | 19.0 % |
lepinet-effnetv2s | 37 M | 72.9 % | 85.8 % | 10.0 % |
- To get the species name as often as possible, the DINOv3 model names it most often at 95 % precision, and answers "unknown" more often.
- If a genus or family answer is useful, the BioCLIP-2 model gives some correct answer most often, because it backs off more.
- The small model is close to the best on both and runs on a laptop CPU.
- On ordinary photos the BioCLIP-2 model is the most accurate (species macro-F1 0.921, against 0.906 and 0.899 over the full GBIF test fold).
Differences of about a point are within the variation between the two halves of the nights. An earlier comparison reported a 17-point advantage for the BioCLIP-2 model; it counted a family answer like a species answer, fitted thresholds in-sample and used the DINOv3 model's uncalibrated probabilities (analysis).
Quick start
pip install onnxruntime pillow numpy huggingface_hub
import json, numpy as np, onnxruntime as ort
from huggingface_hub import snapshot_download
from PIL import Image
repo = snapshot_download("gmougeot/lepinet-dinov3-convnextl", allow_patterns=["model.onnx", "*.json"])
session = ort.InferenceSession(f"{repo}/model.onnx")
names = json.load(open(f"{repo}/names.json"))["names"]
def load(path, size=320): # shorter side -> size, centre crop, RGB in [0, 1]
img = Image.open(path).convert("RGB")
s = size / min(img.size)
img = img.resize((max(size, round(img.width * s)), max(size, round(img.height * s))), Image.BICUBIC)
l, t = (img.width - size) // 2, (img.height - size) // 2
return np.asarray(img.crop((l, t, l + size, t + size)), np.float32).transpose(2, 0, 1)[None] / 255
out = dict(zip([o.name for o in session.get_outputs()], session.run(None, {"image": load("moth.jpg")})))
for rank in ("species", "genus", "family"):
p = out[f"prob_{rank}"][0]
print(f"{rank:8s} {names[rank][p.argmax()]} {p.max():.2f}")
Or use the bundled command-line script, which applies the confidence thresholds for you:
hf download gmougeot/lepinet-dinov3-convnextl model.onnx predict.py config.json taxonomy.json names.json thresholds.json --local-dir lepinet
python lepinet/predict.py moth1.jpg moth2.jpg --top 3
The three lepinet releases share identical taxonomy.json and names.json files, so they are
drop-in interchangeable. Batching works: the input's first axis is dynamic.
Which file to use
| file | use it on | size | batch of 32 | one image |
|---|---|---|---|---|
model.onnx (fp32) | anywhere | 869 MB | CPU 8.4 · GPU 378 img/s | CPU 103 ms · GPU 3.8 ms |
model_fp16.onnx | NVIDIA GPU | 476 MB | GPU 715 img/s | GPU 2.9 ms |
model_int8.onnx | CPU | 245 MB | CPU 10.2 img/s | CPU 70 ms |
Measured with onnxruntime 1.30 on an RTX 5090 and a 24-core Intel Core Ultra 9 285K (a shared server under some background load, so read the ratios rather than the absolute CPU numbers). fp16 matches fp32: the same species macro-F1 to 0.0001 on all three evaluation sets. The int8 file is 1.5× faster per image (70 vs 103 ms), 3.5× smaller, and within 0.35 points of fp32 on all three evaluation sets. As in the BioCLIP-2 model, its MLP output layers use 8-bit weight-only quantization, so it needs onnxruntime ≥ 1.22. int8 does not help on a GPU; use fp16 there.
On a GPU
pip install "onnxruntime-gpu[cuda,cudnn]" # instead of onnxruntime; do not install both
ort.preload_dlls() # loads the CUDA/cuDNN libraries that pip installed
session = ort.InferenceSession(f"{repo}/model_fp16.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
Name the providers explicitly. ort.get_available_providers() puts TensorRT first, and without
TensorRT installed onnxruntime then falls back to the CPU without saying so. predict.py picks CUDA
automatically.
Files
| file | what it is |
|---|---|
model.onnx, model_fp16.onnx, model_int8.onnx | the network. Input image: float32 [N, 3, 320, 320], RGB, values in [0, 1]. Normalisation is inside the graph (do not normalise yourself) |
taxonomy.json | vocabs.<rank>[i] is the GBIF taxon key of output index i; parents maps species to genus and genus to family |
names.json | scientific names, aligned index for index with taxonomy.json |
thresholds.json | per-rank confidence thresholds for a 95 %-precision back-off policy, with the precision and coverage they achieve on held-out data |
config.json | preprocessing and output description (it doubles as a lepinet-app bundle manifest) |
predict.py | a standalone CLI and Lepinet class (onnxruntime + Pillow only) |
model.safetensors | full-precision PyTorch weights, for fine-tuning with lepinet |
LICENSE.md | the DINOv3 License, which these weights are distributed under |
Outputs
| name | shape | meaning |
|---|---|---|
prob_species | [N, 12041] | softmax over species (temperature-calibrated) |
prob_genus | [N, 4333] | sum of each genus's species probabilities |
prob_family | [N, 102] | sum of each family's genus probabilities |
logits_species | [N, 12041] | species scores; softmax(logits_species) == prob_species |
embedding | [N, 1536] | L2-normalised image embedding (cosine similarity = dot product) |
Calibration. The raw model is strongly over-confident: 81 % of validation images get a species probability of exactly 1.0 in float32. The graph divides the species logits by a temperature T = 1.91, fitted by likelihood on 10,000 in-distribution validation images (negative log-likelihood 0.567 → 0.387, saturated predictions 81 % → 0 %). Rankings, and so accuracy, are unchanged.
Using the confidence
Every image gets a species prediction, including images of species outside the label set and
images that are not moths at all. thresholds.json implements the back-off policy the project
evaluates:
answer species if prob_species.max() >= t_species
else genus if prob_genus.max() >= t_genus
else family if prob_family.max() >= t_family
else "unknown"
In code, continuing the quick start above (out, names and repo come from there):
thresholds = {k: v["threshold"] for k, v in json.load(open(f"{repo}/thresholds.json"))["levels"].items()}
def answer(out, i=0):
"""Deepest rank the model is confident about for image i, or None ("unknown")."""
for rank in ("species", "genus", "family"):
p = out[f"prob_{rank}"][i]
if p.max() >= thresholds[rank]: # a threshold above 1 means "never answer at this rank"
return rank, names[rank][p.argmax()], float(p.max())
return None
print(answer(out)) # ('species', 'Macaria notata', 0.99), ('family', 'Erebidae', 0.97) or None
p = out["prob_species"][0]
novelty = float(-(p * np.log(p + 1e-12)).sum()) # entropy: higher = less familiar image
predict.py does exactly this.
The thresholds were fitted on half of the light-trap capture nights and verified on the other half:
| rank | threshold (fitted on half the nights) | share of held-out images answered here | precision (held-out nights) |
|---|---|---|---|
| species | 0.955 | 78.3 % | 95.4 % |
| genus | never used | 0.0 % | n/a |
| family | 0.930 | 2.6 % | 94.1 % |
| unknown | 19.0 % |
Overall, on the held-out nights: 81.0 % of images get an answer, 95.3 % of those answers are correct, so 77.2 % of all images get a correct answer. The thresholds shipped in thresholds.json (levels) are refitted on all nights: species 0.945, genus 0.945, family 0.935. The split fit and its held-out result are stored alongside them.
Species the model has never seen
Any closed-set classifier gives a species to a species it does not know. What the back-off buys is that it usually does not commit to one. Measured with the file above on 3,171 GBIF photos of 591 species outside the label set (the test-fold species below the training floor):
| answer | known species (10,000 GBIF test photos) | species outside the label set (3,171 photos) |
|---|---|---|
| species | 91.9 % (98 % correct) | 20.9 % (always wrong: the true species is not in the list) |
| genus | 0.7 % (100 % correct) | 0.9 % (79 % correct) |
| family | 0.7 % (100 % correct) | 2.0 % (100 % correct) |
| unknown | 6.7 % | 76.3 % |
The entropy novelty score separates the two sets with AUROC 0.917. These thresholds were fitted on light-trap images, so on ordinary photos they are a little conservative.
"Unknown" is not proof of a novel species, and a species answer is not proof that the species is in
the label set: the same rule catches hard photos of known species. Use novelty for a graded
score, for instance to send the most unfamiliar images to an expert.
The released models are compared under this protocol in Which lepinet model?.
Evaluation
Species macro-F1 averages F1 over species, so the rare species count as much as the common ones.
- GBIF test fold (in-distribution): held-out images from the same GBIF sources as training: the full fold (629,742 images, all 12,041 species) for the fp32 file, and a random 10,000-image sample to check the fp16 and int8 files against it. Whether DINOv3's LVD-1689M pre-training images overlap this fold has not been checked.
- Probe: 15,200 light-trap images of 368 Danish species, from (trap, night) groups never used in training. This is a real domain shift: night-time camera crops, not curated photos.
- Probe, held-out species: 2,455 images of 58 species for which no trap images were used in any form during training.
| evaluation | training pipeline | model.onnx + quick-start preprocessing | model_fp16.onnx | model_int8.onnx |
|---|---|---|---|---|
| GBIF test fold, full (629,742 images) | 0.9060 | 0.9059 (top-1 94.5 %) | ||
| GBIF test fold, random 10,000 images | not measured | 0.9261 | 0.9262 | 0.9266 |
| Probe (light traps) | 0.7798 | 0.7766 | 0.7765 | 0.7748 |
| Probe, held-out species | 0.7816 | 0.7708 | 0.7707 | 0.7742 |
All numbers are species macro-F1. On ordinary photos the published file reproduces the training pipeline (0.9059 vs 0.9060 over the full fold). On trap crops the two differ by up to 1.1 points on the smallest set: the network is numerically identical, and the difference is the resizing of small, up-sampled crops (median shorter side 157 px).
Training
- Data: 6.3 M GBIF occurrence images of Lepidoptera; species with at least 50 images; at most about 2,000 images per species; a held-out test fold. No class-balanced oversampling.
- Model: the DINOv3 ConvNeXt-L backbone (self-supervised on LVD-1689M, distilled from DINOv3 ViT-7B) with a single species cosine classifier. It is trained with an ArcFace margin (0.3) and with marginal supervision: genus and family losses on the summed species probabilities, so there are no genus/family parameters. 6 epochs, 320 px, Muon optimiser, one-cycle schedule.
- Trap robustness: domain augmentation that mimics light-trap imagery, plus self-training: about 2 % of each epoch is unlabelled light-trap crops, pseudo-labelled by an earlier model. The trap nights used for evaluation are excluded.
Full recipe and every number: the lepinet repository,
starting at its README. The configuration is configs/20260804_B8_best_at_2pct_dinov3cnxl.yaml.
Limitations
- One insect per image. The model classifies; it does not detect. Run a detector first on scenes, trap screens or multi-insect photos (for example flat-bug) and classify the crops.
- Use the shipped probabilities and thresholds. Without a temperature this model's float32 probabilities are exactly 1.0 for most images, which makes a species threshold useless; this file divides the logits by T = 1.91, fitted on validation data. Use the thresholds shipped here, not a fixed 0.5, and re-fit them on your own data if precision matters.
- Closed label set, uneven geography. It knows 12,041 species. Training images are 36 % North American, 33 % European and 15 % Asian, but only 6 % South American or African. A species outside the label set still receives one of these labels.
- Domain shift is large. Compare the in-distribution and probe rows. The thresholds come from Danish light traps and may not transfer to your camera or region.
- Adults only, in practice. Larvae are about 1 % of the training images.
- Not for decisions about conservation status, pest control or toxicity without expert verification.
Licence
The weights are distributed under the DINOv3 License, because they are a derivative of Meta's DINOv3 weights, and that licence requires derivatives to be distributed under its own terms, with a copy included. Among other things it requires you to acknowledge DINOv3 in publications, to comply with trade controls, and not to use the model for military, weapons or other end uses it prohibits.
Request about the training data. About 77 % of the 6.3 M training images are licensed CC-BY-NC by their GBIF contributors. The two other lepinet releases are CC-BY-NC-4.0 for that reason. We ask that you use this model for non-commercial purposes too. This request is ours, about the data; it is not a term of the DINOv3 License.
Citation
No paper yet. Please cite the repository, and DINOv3:
@software{mougeot_lepinet_2026,
author = {Mougeot, Guillaume},
title = {lepinet: hierarchical Lepidoptera identification that knows what it does not know},
year = {2026},
url = {https://github.com/GuillaumeMougeot/lepinet}
}
@article{simeoni2025dinov3,
title = {DINOv3},
author = {Sim{\'e}oni, Oriane and others},
journal = {arXiv preprint arXiv:2508.10104},
year = {2025}
}
Training data: GBIF.org (15 May 2025) GBIF Occurrence Download https://doi.org/10.15468/dl.hg37y9.