Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Usage questions
url: https://github.com/frgfm/Holocron/discussions
Expand Down
200 changes: 200 additions & 0 deletions .github/generate_model_zoo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Copyright (C) 2026, François-Guillaume Fernandez.

# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> for full license details.

# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///

"""Single source of truth for the pretrained model-zoo table.

The table is derived statically (no import of the package, no torch) from the ``Checkpoint`` metadata
declared in ``holocron/models/classification`` — both the ``*_Checkpoint`` enums (new API) and the
legacy ``default_cfgs`` dicts. It is written between the AUTOGEN markers in every target file.

Usage::

python .github/generate_model_zoo.py # rewrite the tables in place
python .github/generate_model_zoo.py --check # fail (exit 1) if a target is out of date
"""

import argparse
import ast
import sys
from collections.abc import Iterator
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
MODELS_DIR = ROOT / "holocron" / "models"
START = "<!-- AUTOGEN:MODEL_ZOO START - edit via .github/generate_model_zoo.py -->"
END = "<!-- AUTOGEN:MODEL_ZOO END -->"
TARGETS = [ROOT / "README.md", ROOT / "docs" / "docs" / "index.md"]

# Dataset enum member -> human label
DATASETS = {"IMAGENETTE": "Imagenette (10)", "IMAGENET1K": "ImageNet-1k (1000)", "CIFAR10": "CIFAR-10 (10)"}
# `_checkpoint()` hardcodes this preprocessing input shape for every checkpoint it builds.
CHECKPOINT_INPUT = "224×224" # noqa: RUF001 - the multiplication sign is the intended display character


def _lit(node: ast.AST | None):
if node is None:
return None
try:
return ast.literal_eval(node)
except (ValueError, SyntaxError):
return None


def _from_checkpoint_call(call: ast.Call) -> dict | None:
kwargs = {kw.arg: kw.value for kw in call.keywords}
if not _lit(kwargs.get("arch")):
return None
dataset = "IMAGENETTE"
if isinstance(kwargs.get("dataset"), ast.Attribute):
dataset = kwargs["dataset"].attr # e.g. Dataset.IMAGENET1K -> "IMAGENET1K"
acc1 = _lit(kwargs.get("acc1"))
num_params = _lit(kwargs.get("num_params"))
return {
"input": CHECKPOINT_INPUT,
"dataset": DATASETS.get(dataset, dataset),
"acc1": f"{acc1 * 100:.1f}" if acc1 is not None else "—",
"params": f"{num_params / 1e6:.1f}" if num_params is not None else "—",
"legacy": False,
}


def _from_legacy_cfg(cfg_dict: ast.Dict) -> dict | None:
url = None
shape = None
preset = None # detected from `**IMAGENETTE.__dict__` / `**IMAGENET.__dict__` unpacking
for key, value in zip(cfg_dict.keys, cfg_dict.values, strict=True):
if key is None: # dict unpacking, e.g. **IMAGENETTE.__dict__
if isinstance(value, ast.Attribute) and isinstance(value.value, ast.Name):
preset = value.value.id
elif _lit(key) == "url":
url = _lit(value)
elif _lit(key) == "input_shape":
shape = _lit(value)
elif _lit(key) == "classes" and preset is None:
classes = _lit(value)
preset = "IMAGENETTE" if classes and len(classes) == 10 else None
if not url:
return None
return {
"input": f"{shape[-2]}×{shape[-1]}" if shape else "—", # noqa: RUF001 - intended display character
"dataset": {"IMAGENETTE": "Imagenette (10)", "IMAGENET": "ImageNet-1k (1000)"}.get(preset, "—"),
"acc1": "—",
"params": "—",
"legacy": True,
}


def _checkpoint_entry(classdef: ast.ClassDef) -> tuple[str, dict] | None:
"""Return ``(arch, row)`` for the enum's ``DEFAULT`` checkpoint, or ``None``.

Resolves the ``DEFAULT = <MEMBER>`` alias so the row reflects what ``pretrained=True`` actually
loads, rather than assuming the default is the first-declared member.
"""
members: dict[str, ast.Call] = {}
default_name: str | None = None
for stmt in classdef.body:
if not (isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name)):
continue
name, value = stmt.targets[0].id, stmt.value
if isinstance(value, ast.Call) and isinstance(value.func, ast.Name) and value.func.id == "_checkpoint":
members[name] = value
elif name == "DEFAULT" and isinstance(value, ast.Name):
default_name = value.id
call = members.get(default_name) if default_name else None
if call is None: # no DEFAULT alias resolved -> fall back to the first-declared checkpoint
call = next(iter(members.values()), None)
if call is None:
return None
arch = _lit(next((kw.value for kw in call.keywords if kw.arg == "arch"), None))
entry = _from_checkpoint_call(call)
return (arch, entry) if (arch and entry) else None


def _legacy_entries(dict_node: ast.Dict) -> Iterator[tuple[str, dict]]:
"""Yield ``(arch, row)`` for each weighted arch declared in a ``default_cfgs`` dict."""
for key, value in zip(dict_node.keys, dict_node.values, strict=True):
arch = _lit(key)
if isinstance(arch, str) and isinstance(value, ast.Dict):
entry = _from_legacy_cfg(value)
if entry:
yield arch, entry


def _is_default_cfgs(node: ast.AST) -> ast.Dict | None:
if isinstance(node, (ast.Assign, ast.AnnAssign)) and isinstance(node.value, ast.Dict):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
if any(isinstance(t, ast.Name) and t.id == "default_cfgs" for t in targets):
return node.value
return None


def collect_rows() -> dict[str, dict]:
rows: dict[str, dict] = {}
# The zoo table lives under the "Image classification" heading; segmentation/detection (a single
# legacy checkpoint and none, respectively) are described in prose next to it.
for path in sorted((MODELS_DIR / "classification").rglob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))):
if isinstance(node, ast.ClassDef) and node.name.endswith("_Checkpoint"):
entry = _checkpoint_entry(node)
if entry:
rows[entry[0]] = entry[1] # new-API checkpoints take precedence
elif (cfgs := _is_default_cfgs(node)) is not None:
for arch, row in _legacy_entries(cfgs):
rows.setdefault(arch, row)
return rows


def render_table(rows: dict[str, dict]) -> str:
lines = [
"| Model | Input | Training dataset | Top-1 acc (%) | Params (M) |",
"| --- | --- | --- | --- | --- |",
]
lines += [
f"| `{a}` | {r['input']} | {r['dataset']} | {r['acc1']} | {r['params']} |" for a, r in sorted(rows.items())
]
table = "\n".join(lines)
if any(r["legacy"] for r in rows.values()):
table += "\n\n_Rows showing `—` are legacy checkpoints whose accuracy/params are not recorded in metadata._"
return table


def apply(content: str, table: str, path: Path) -> str:
if START not in content or END not in content:
raise SystemExit(f"Missing AUTOGEN markers in {path.relative_to(ROOT)}")
head, rest = content.split(START, 1)
_, tail = rest.split(END, 1)
return f"{head}{START}\n\n{table}\n\n{END}{tail}"


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="fail if any target is out of date")
args = parser.parse_args()

table = render_table(collect_rows())
stale = []
for path in TARGETS:
current = path.read_text(encoding="utf-8")
updated = apply(current, table, path)
if updated != current:
stale.append(path.relative_to(ROOT))
if not args.check:
path.write_text(updated, encoding="utf-8")

if args.check and stale:
names = ", ".join(str(p) for p in stale)
print(f"Model zoo table is out of date in: {names}\nRun `make model-zoo` to regenerate.", file=sys.stderr)
return 1
print(f"Model zoo table {'is up to date' if args.check else 'written'} ({len(collect_rows())} models).")
return 0


if __name__ == "__main__":
raise SystemExit(main())
14 changes: 14 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ jobs:
- name: Run dependency sync checker
run: make deps-check

model-zoo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
architecture: x64
- uses: astral-sh/setup-uv@v7
with:
version: ${{ env.UV_VERSION }}
- name: Check the model zoo table is up to date
run: make model-zoo-check

headers:
runs-on: ubuntu-latest
steps:
Expand Down
4 changes: 4 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ repos:
name: check deps version sync
entry: make deps-check
language: system
- id: model-zoo-check
name: check model zoo table sync
entry: make model-zoo-check
language: system
- id: typing-check
name: check typing
entry: make typing-check
Expand Down
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ DOCKER_TAG ?= latest
DOCKER_PLATFORM ?= linux/amd64
PYTHON_REQ_FILE = /tmp/requirements.txt

.PHONY: help install install-quality lint-check lint-format precommit typing-check deps-check quality style init-gh-labels init-gh-settings install-mintlify start-mintlify
.PHONY: help install install-quality lint-check lint-format precommit typing-check deps-check model-zoo model-zoo-check quality style init-gh-labels init-gh-settings install-mintlify start-mintlify

help: ## Show this help message
@echo "Available commands:"
Expand Down Expand Up @@ -68,8 +68,14 @@ typing-check: ${PYPROJECT_FILE} ## Check type annotations
deps-check: .github/verify_deps_sync.py ## Check dependency synchronization
uv run --script .github/verify_deps_sync.py

model-zoo: .github/generate_model_zoo.py ## Regenerate the pretrained model-zoo table in README & docs
uv run --script .github/generate_model_zoo.py

model-zoo-check: .github/generate_model_zoo.py ## Check the model-zoo table is in sync with checkpoint metadata
uv run --script .github/generate_model_zoo.py --check

# this target runs checks on all files
quality: lint-check typing-check deps-check ## Run all quality checks
quality: lint-check typing-check deps-check model-zoo-check ## Run all quality checks

style: precommit ## Format code and run pre-commit hooks

Expand Down
86 changes: 80 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,35 +50,109 @@ Implementations of recent Deep Learning tricks in Computer Vision, easily paired
## Quick Tour
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/frgfm/notebooks/blob/main/holocron/quicktour.ipynb)

This project was created for quality implementations, increased developer flexibility and maximum compatibility with the PyTorch ecosystem. For instance, here is a short snippet to showcase how Holocron models are meant to be used:
This project was created for quality implementations, increased developer flexibility and a clean integration with the PyTorch ecosystem. For instance, here is a short snippet to showcase how Holocron models are meant to be used:

```python
import torch
from PIL import Image
from torchvision.transforms.v2 import Compose, ConvertImageDtype, Normalize, PILToTensor, Resize
from torchvision.transforms.v2.functional import InterpolationMode
from holocron.models.classification import repvgg_a0

# Load your model
# Load your model (weights are pretrained on Imagenette, a 10-class subset of ImageNet)
model = repvgg_a0(pretrained=True).eval()

# Read your image
img = Image.open(path_to_an_image).convert("RGB")

# Preprocessing
# Preprocessing (model.default_cfg is the Checkpoint the pretrained weights came from)
config = model.default_cfg
transform = Compose([
Resize(config["input_shape"][1:], interpolation=InterpolationMode.BILINEAR),
Resize(config.pre_processing.input_shape[1:], interpolation=InterpolationMode.BILINEAR),
PILToTensor(),
ConvertImageDtype(torch.float32),
Normalize(config["mean"], config["std"]),
Normalize(config.pre_processing.mean, config.pre_processing.std),
])

input_tensor = transform(img).unsqueeze(0)

# Inference
with torch.inference_mode():
output = model(input_tensor)
print(config["classes"][output.squeeze(0).argmax().item()], output.squeeze(0).softmax(dim=0).max())
print(config.meta.categories[output.squeeze(0).argmax().item()], output.squeeze(0).softmax(dim=0).max())
```


## Pretrained models

Holocron implements architectures directly from their papers and trains its own weights: most classification models on [Imagenette](https://github.com/fastai/imagenette) (a 10-class subset of ImageNet), and the ReXNet family on full ImageNet-1k. **These weights load through Holocron's own `pretrained=True` and are _not_ interchangeable with torchvision/`timm` checkpoints.**

> [!NOTE]
> Top-1 accuracy is measured on the listed dataset's validation split, so Imagenette (10 classes) numbers are **not** comparable to ImageNet-1k (1000 classes) ones.

<details>
<summary><b>Image classification</b> pretrained checkpoints</summary>

<!-- AUTOGEN:MODEL_ZOO START - edit via .github/generate_model_zoo.py -->

| Model | Input | Training dataset | Top-1 acc (%) | Params (M) |
| --- | --- | --- | --- | --- |
| `convnext_atto` | 224×224 | Imagenette (10) | 87.6 | 3.4 |
| `cspdarknet53` | 224×224 | Imagenette (10) | 94.5 | 26.6 |
| `cspdarknet53_mish` | 224×224 | Imagenette (10) | 94.7 | 26.6 |
| `darknet19` | 224×224 | Imagenette (10) | 93.9 | 19.8 |
| `darknet24` | 224×224 | Imagenette (10) | — | — |
| `darknet53` | 224×224 | Imagenette (10) | 94.2 | 40.6 |
| `mobileone_s0` | 224×224 | Imagenette (10) | 88.1 | 4.3 |
| `mobileone_s1` | 224×224 | Imagenette (10) | 91.3 | 3.6 |
| `mobileone_s2` | 224×224 | Imagenette (10) | 91.3 | 5.9 |
| `mobileone_s3` | 224×224 | Imagenette (10) | 91.1 | 8.1 |
| `repvgg_a0` | 224×224 | Imagenette (10) | 92.9 | 24.7 |
| `repvgg_a1` | 224×224 | Imagenette (10) | 93.8 | 30.1 |
| `repvgg_a2` | 224×224 | Imagenette (10) | 93.6 | 48.6 |
| `repvgg_b0` | 224×224 | Imagenette (10) | 92.7 | 31.8 |
| `repvgg_b1` | 224×224 | Imagenette (10) | 94.0 | 100.8 |
| `repvgg_b2` | 224×224 | Imagenette (10) | 94.1 | 157.5 |
| `res2net50_26w_4s` | 224×224 | Imagenette (10) | 93.9 | 23.7 |
| `resnet18` | 224×224 | Imagenette (10) | 93.6 | 11.2 |
| `resnet34` | 224×224 | Imagenette (10) | 93.8 | 21.3 |
| `resnet50` | 224×224 | Imagenette (10) | 93.8 | 23.5 |
| `resnet50d` | 224×224 | Imagenette (10) | 94.7 | 23.5 |
| `resnext50_32x4d` | 224×224 | Imagenette (10) | 94.5 | 23.0 |
| `rexnet1_0x` | 224×224 | ImageNet-1k (1000) | 77.9 | 4.8 |
| `rexnet1_3x` | 224×224 | ImageNet-1k (1000) | 79.5 | 7.6 |
| `rexnet1_5x` | 224×224 | ImageNet-1k (1000) | 80.3 | 9.7 |
| `rexnet2_0x` | 224×224 | ImageNet-1k (1000) | 80.3 | 16.4 |
| `rexnet2_2x` | 224×224 | Imagenette (10) | 95.4 | 16.7 |
| `sknet50` | 224×224 | Imagenette (10) | 94.4 | 35.2 |
| `tridentnet50` | 224×224 | Imagenette (10) | — | — |

_Rows showing `—` are legacy checkpoints whose accuracy/params are not recorded in metadata._

<!-- AUTOGEN:MODEL_ZOO END -->

</details>

- **Semantic segmentation:** only `unet_rexnet13` (~9.3M params) currently ships pretrained weights.
- **Object detection:** the detection models (`yolov1`, `yolov2`, `yolov4`) **ship no pretrained weights yet** — instantiate them and train with the [reference scripts](references/detection).

Every other architecture is available **untrained** (randomly initialized): calling it with `pretrained=True` emits a warning and falls back to random initialization, so train it yourself with the [reference scripts](references/).

## Loss functions

Holocron's losses follow PyTorch conventions. Classification-style losses such as [`PolyLoss`](https://arxiv.org/abs/2204.12511) expect **raw logits** as input and **`torch.int64` class indices** as target (use `ignore_index` to mask samples):

```python
import torch
from holocron.nn import PolyLoss

criterion = PolyLoss(ignore_index=-100)

logits = torch.rand(4, 10, requires_grad=True) # (N, num_classes) unnormalized scores
target = torch.tensor([0, -100, 3, 1]) # (N,) int64; -100 marks an ignored sample

loss = criterion(logits, target)
loss.backward()
```


Expand Down
Loading
Loading