canvit-nnx 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- canvit_nnx-0.1.0/.github/workflows/release.yml +22 -0
- canvit_nnx-0.1.0/.gitignore +20 -0
- canvit_nnx-0.1.0/.python-version +1 -0
- canvit_nnx-0.1.0/CLAUDE.md +50 -0
- canvit_nnx-0.1.0/PKG-INFO +133 -0
- canvit_nnx-0.1.0/README.md +102 -0
- canvit_nnx-0.1.0/canvit_nnx/__init__.py +13 -0
- canvit_nnx-0.1.0/canvit_nnx/classification.py +86 -0
- canvit_nnx-0.1.0/canvit_nnx/hub.py +92 -0
- canvit_nnx-0.1.0/canvit_nnx/model.py +507 -0
- canvit_nnx-0.1.0/canvit_nnx/viewpoint.py +78 -0
- canvit_nnx-0.1.0/pyproject.toml +47 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write
|
|
14
|
+
contents: read
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
|
|
18
|
+
- uses: astral-sh/setup-uv@v5
|
|
19
|
+
|
|
20
|
+
- run: uv build
|
|
21
|
+
|
|
22
|
+
- run: uv publish
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
## Project
|
|
4
|
+
|
|
5
|
+
CanViT-NNX: JAX/Flax NNX port of the Canvas Vision Transformer.
|
|
6
|
+
PyPI package: `canvit-nnx`. Import: `canvit_nnx`.
|
|
7
|
+
Reference implementation: [CanViT-PyTorch](https://github.com/m2b3/CanViT-PyTorch) (`canvit-pytorch` on PyPI).
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv run pytest tests/ -v # unit tests
|
|
13
|
+
uv run --extra verify python scripts/verify.py # cross-framework numerical check
|
|
14
|
+
uv run --extra demo python scripts/demo.py --image path/to/img.jpg
|
|
15
|
+
PYTHONPATH=. uv run --extra convert python scripts/push_to_hub.py # re-push HF checkpoints
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Flax NNX gotchas (traps for PyTorch porters)
|
|
19
|
+
|
|
20
|
+
- **LayerNorm epsilon**: Flax NNX defaults to `1e-6`, PyTorch to `1e-5`. Always explicit.
|
|
21
|
+
- **GELU**: `jax.nn.gelu` defaults to `approximate=True`, PyTorch to exact. Always explicit.
|
|
22
|
+
- **SDPA layout**: `jax.nn.dot_product_attention` expects BTNH, not BHTN.
|
|
23
|
+
- **Linear kernel**: NNX is `(in, out)`, PyTorch is `(out, in)`. Conv: NNX `(H,W,I,O)`, PyTorch `(O,I,H,W)`.
|
|
24
|
+
- **nnx.List**: plain Python lists on nnx.Module are static. Use `nnx.List([...])`.
|
|
25
|
+
- **Optional modules**: `self.x = None` then `self.x = Module()` fails. Use `nnx.data(None)`.
|
|
26
|
+
- **Param access**: use `param[...]` not `param.value` (deprecated in Flax 0.12+).
|
|
27
|
+
- **FlatState**: iterates as `(path_tuple, leaf)` tuples, not dict. No `.items()`.
|
|
28
|
+
- **Optimizer**: `nnx.Optimizer(model, tx, wrt=nnx.Param)` and `optimizer.update(model, grads)`.
|
|
29
|
+
- **Pytree registration**: `@jax.tree_util.register_dataclass` for data containers passed through JIT.
|
|
30
|
+
- **align_corners**: PyTorch `F.grid_sample(align_corners=False)` maps to `pixel = (grid+1)*N/2 - 0.5`.
|
|
31
|
+
|
|
32
|
+
## Checkpoint loading
|
|
33
|
+
|
|
34
|
+
JAX-native safetensors on HuggingFace (no torch dependency):
|
|
35
|
+
- Pretrained: `canvit/canvitb16-add-vpe-pretrain-g128px-s512px-in21k-dv3b16-2026-02-02-nnx`
|
|
36
|
+
- Finetuned: `canvit/canvitb16-add-vpe-finetune-g128px-s512px-in1k-2026-04-06-nnx`
|
|
37
|
+
|
|
38
|
+
PyTorch-to-JAX conversion is in `scripts/convert_from_pytorch.py` (not part of the installed package).
|
|
39
|
+
The PyTorch pretrained checkpoint key prefix is `backbone.` (the actual ViTBackbone).
|
|
40
|
+
The PyTorch finetuned checkpoint wraps CanViT as `canvit.` — strip that, and inner keys match.
|
|
41
|
+
|
|
42
|
+
## Performance notes (as of 2026-04-07, may be stale)
|
|
43
|
+
|
|
44
|
+
- On CPU (M4 Pro), JAX forward was ~68ms vs torch.compile ~41ms. Initial investigation
|
|
45
|
+
suggested canvas cross-attention SDPA as the bottleneck (XLA CPU appeared to decompose
|
|
46
|
+
it into separate ops while PyTorch used a fused kernel), but this was not thoroughly
|
|
47
|
+
verified on GPU. Re-benchmark before drawing conclusions.
|
|
48
|
+
- Cross-framework backward gradient differences were observed in early blocks (0-3).
|
|
49
|
+
Hypothesis was f32 matmul accumulation order differences, but not definitively proven.
|
|
50
|
+
Run `scripts/verify.py` for current state.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: canvit-nnx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CanViT (Canvas Vision Transformer) -- JAX / Flax NNX
|
|
5
|
+
Author-email: Yohaï-Eliel Berreby <me@yberreby.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: flax>=0.12
|
|
9
|
+
Requires-Dist: huggingface-hub
|
|
10
|
+
Requires-Dist: jax>=0.9
|
|
11
|
+
Requires-Dist: jaxlib>=0.9
|
|
12
|
+
Requires-Dist: safetensors
|
|
13
|
+
Provides-Extra: convert
|
|
14
|
+
Requires-Dist: canvit-pytorch>=0.1.9; extra == 'convert'
|
|
15
|
+
Requires-Dist: torch>=2.0; extra == 'convert'
|
|
16
|
+
Provides-Extra: demo
|
|
17
|
+
Requires-Dist: pillow; extra == 'demo'
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: canvit-pytorch>=0.1.9; extra == 'dev'
|
|
20
|
+
Requires-Dist: ipython; extra == 'dev'
|
|
21
|
+
Requires-Dist: pillow; extra == 'dev'
|
|
22
|
+
Requires-Dist: pypatree; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: torch>=2.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: torchvision; extra == 'dev'
|
|
26
|
+
Provides-Extra: verify
|
|
27
|
+
Requires-Dist: canvit-pytorch>=0.1.9; extra == 'verify'
|
|
28
|
+
Requires-Dist: pillow; extra == 'verify'
|
|
29
|
+
Requires-Dist: torch>=2.0; extra == 'verify'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# CanViT-NNX
|
|
33
|
+
|
|
34
|
+
> **Experimental.** Port of [CanViT](https://arxiv.org/abs/2603.22570) to [JAX](https://github.com/jax-ml/jax) using [Flax NNX](https://flax.readthedocs.io/). Reference implementation: [CanViT-PyTorch](https://github.com/m2b3/CanViT-PyTorch). May break at any time.
|
|
35
|
+
|
|
36
|
+
[JAX](https://github.com/jax-ml/jax) / [Flax NNX](https://flax.readthedocs.io/) implementation of the [Canvas Vision Transformer (CanViT)](https://arxiv.org/abs/2603.22570).
|
|
37
|
+
|
|
38
|
+
Checkpoints: [canvit/canvit-nnx](https://huggingface.co/collections/canvit/canvit-nnx-69d4864ff6ee17d87e7b91d4) on HuggingFace.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv add canvit-nnx
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
We recommend [`uv`](https://docs.astral.sh/uv/) for dependency management.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### Classification
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from canvit_nnx import CanViTForImageClassification, Viewpoint, sample_at_viewpoint
|
|
54
|
+
|
|
55
|
+
clf = CanViTForImageClassification.from_pretrained(
|
|
56
|
+
"canvit/canvitb16-add-vpe-finetune-g128px-s512px-in1k-2026-04-06-nnx"
|
|
57
|
+
)
|
|
58
|
+
state = clf.init_state(batch_size=1, canvas_grid_size=32)
|
|
59
|
+
|
|
60
|
+
vp = Viewpoint.full_scene(batch_size=1)
|
|
61
|
+
glimpse = sample_at_viewpoint(spatial=image, viewpoint=vp, glimpse_size_px=128)
|
|
62
|
+
logits, state = clf(glimpse, state, vp)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Pretrained backbone (dense features)
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import jax.numpy as jnp
|
|
69
|
+
from canvit_nnx import from_pretrained, Viewpoint, sample_at_viewpoint
|
|
70
|
+
|
|
71
|
+
model = from_pretrained(
|
|
72
|
+
"canvit/canvitb16-add-vpe-pretrain-g128px-s512px-in21k-dv3b16-2026-02-02-nnx"
|
|
73
|
+
)
|
|
74
|
+
state = model.init_state(batch_size=1, canvas_grid_size=32)
|
|
75
|
+
|
|
76
|
+
vp = Viewpoint.full_scene(batch_size=1)
|
|
77
|
+
glimpse = sample_at_viewpoint(spatial=image, viewpoint=vp, glimpse_size_px=128)
|
|
78
|
+
out = model(glimpse, state, vp)
|
|
79
|
+
state = out.state
|
|
80
|
+
|
|
81
|
+
# Canvas features should be layernormed before downstream use (PCA, probing, etc.)
|
|
82
|
+
canvas = model.get_spatial(state.canvas)
|
|
83
|
+
mean = canvas.mean(axis=-1, keepdims=True)
|
|
84
|
+
canvas = (canvas - mean) / jnp.sqrt(canvas.var(axis=-1, keepdims=True) + 1e-5)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Structure
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
canvit_nnx/
|
|
91
|
+
model.py — CanViT architecture (self-contained, no external model deps)
|
|
92
|
+
classification.py — CanViTForImageClassification
|
|
93
|
+
viewpoint.py — sample_at_viewpoint (bilinear glimpse extraction)
|
|
94
|
+
hub.py — HuggingFace Hub loading (JAX-native safetensors, no torch)
|
|
95
|
+
|
|
96
|
+
scripts/
|
|
97
|
+
demo.py — classification demo
|
|
98
|
+
verify.py — cross-framework numerical verification
|
|
99
|
+
convert_from_pytorch.py — one-time PyTorch -> JAX checkpoint conversion
|
|
100
|
+
push_to_hub.py — convert + push to HuggingFace
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Tests
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
uv run pytest tests/ -v
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Verify against PyTorch
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uv run --extra verify python scripts/verify.py
|
|
113
|
+
uv run --extra verify python scripts/verify.py --image path/to/image.jpg
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Demo
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
uv run --extra demo python scripts/demo.py --image path/to/image.jpg
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Citation
|
|
123
|
+
|
|
124
|
+
```bibtex
|
|
125
|
+
@article{berreby2026canvit,
|
|
126
|
+
title={CanViT: Toward Active-Vision Foundation Models},
|
|
127
|
+
author={Berreby, Yoha{\"i}-Eliel and Du, Sabrina and Durand, Audrey and Krishna, B. Suresh},
|
|
128
|
+
year={2026},
|
|
129
|
+
eprint={2603.22570},
|
|
130
|
+
archivePrefix={arXiv},
|
|
131
|
+
primaryClass={cs.CV}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# CanViT-NNX
|
|
2
|
+
|
|
3
|
+
> **Experimental.** Port of [CanViT](https://arxiv.org/abs/2603.22570) to [JAX](https://github.com/jax-ml/jax) using [Flax NNX](https://flax.readthedocs.io/). Reference implementation: [CanViT-PyTorch](https://github.com/m2b3/CanViT-PyTorch). May break at any time.
|
|
4
|
+
|
|
5
|
+
[JAX](https://github.com/jax-ml/jax) / [Flax NNX](https://flax.readthedocs.io/) implementation of the [Canvas Vision Transformer (CanViT)](https://arxiv.org/abs/2603.22570).
|
|
6
|
+
|
|
7
|
+
Checkpoints: [canvit/canvit-nnx](https://huggingface.co/collections/canvit/canvit-nnx-69d4864ff6ee17d87e7b91d4) on HuggingFace.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add canvit-nnx
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
We recommend [`uv`](https://docs.astral.sh/uv/) for dependency management.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Classification
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from canvit_nnx import CanViTForImageClassification, Viewpoint, sample_at_viewpoint
|
|
23
|
+
|
|
24
|
+
clf = CanViTForImageClassification.from_pretrained(
|
|
25
|
+
"canvit/canvitb16-add-vpe-finetune-g128px-s512px-in1k-2026-04-06-nnx"
|
|
26
|
+
)
|
|
27
|
+
state = clf.init_state(batch_size=1, canvas_grid_size=32)
|
|
28
|
+
|
|
29
|
+
vp = Viewpoint.full_scene(batch_size=1)
|
|
30
|
+
glimpse = sample_at_viewpoint(spatial=image, viewpoint=vp, glimpse_size_px=128)
|
|
31
|
+
logits, state = clf(glimpse, state, vp)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Pretrained backbone (dense features)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import jax.numpy as jnp
|
|
38
|
+
from canvit_nnx import from_pretrained, Viewpoint, sample_at_viewpoint
|
|
39
|
+
|
|
40
|
+
model = from_pretrained(
|
|
41
|
+
"canvit/canvitb16-add-vpe-pretrain-g128px-s512px-in21k-dv3b16-2026-02-02-nnx"
|
|
42
|
+
)
|
|
43
|
+
state = model.init_state(batch_size=1, canvas_grid_size=32)
|
|
44
|
+
|
|
45
|
+
vp = Viewpoint.full_scene(batch_size=1)
|
|
46
|
+
glimpse = sample_at_viewpoint(spatial=image, viewpoint=vp, glimpse_size_px=128)
|
|
47
|
+
out = model(glimpse, state, vp)
|
|
48
|
+
state = out.state
|
|
49
|
+
|
|
50
|
+
# Canvas features should be layernormed before downstream use (PCA, probing, etc.)
|
|
51
|
+
canvas = model.get_spatial(state.canvas)
|
|
52
|
+
mean = canvas.mean(axis=-1, keepdims=True)
|
|
53
|
+
canvas = (canvas - mean) / jnp.sqrt(canvas.var(axis=-1, keepdims=True) + 1e-5)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Structure
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
canvit_nnx/
|
|
60
|
+
model.py — CanViT architecture (self-contained, no external model deps)
|
|
61
|
+
classification.py — CanViTForImageClassification
|
|
62
|
+
viewpoint.py — sample_at_viewpoint (bilinear glimpse extraction)
|
|
63
|
+
hub.py — HuggingFace Hub loading (JAX-native safetensors, no torch)
|
|
64
|
+
|
|
65
|
+
scripts/
|
|
66
|
+
demo.py — classification demo
|
|
67
|
+
verify.py — cross-framework numerical verification
|
|
68
|
+
convert_from_pytorch.py — one-time PyTorch -> JAX checkpoint conversion
|
|
69
|
+
push_to_hub.py — convert + push to HuggingFace
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Tests
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv run pytest tests/ -v
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Verify against PyTorch
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run --extra verify python scripts/verify.py
|
|
82
|
+
uv run --extra verify python scripts/verify.py --image path/to/image.jpg
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Demo
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
uv run --extra demo python scripts/demo.py --image path/to/image.jpg
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Citation
|
|
92
|
+
|
|
93
|
+
```bibtex
|
|
94
|
+
@article{berreby2026canvit,
|
|
95
|
+
title={CanViT: Toward Active-Vision Foundation Models},
|
|
96
|
+
author={Berreby, Yoha{\"i}-Eliel and Du, Sabrina and Durand, Audrey and Krishna, B. Suresh},
|
|
97
|
+
year={2026},
|
|
98
|
+
eprint={2603.22570},
|
|
99
|
+
archivePrefix={arXiv},
|
|
100
|
+
primaryClass={cs.CV}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""CanViT: minimal JAX/Flax NNX implementation."""
|
|
2
|
+
|
|
3
|
+
from canvit_nnx.model import (
|
|
4
|
+
CANVIT_B16,
|
|
5
|
+
CanViT,
|
|
6
|
+
CanViTConfig,
|
|
7
|
+
CanViTOutput,
|
|
8
|
+
RecurrentState,
|
|
9
|
+
Viewpoint,
|
|
10
|
+
)
|
|
11
|
+
from canvit_nnx.classification import CanViTForImageClassification
|
|
12
|
+
from canvit_nnx.viewpoint import sample_at_viewpoint
|
|
13
|
+
from canvit_nnx.hub import from_pretrained, save_state, load_state
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""CanViT for image classification: CanViT backbone + LN -> Linear head.
|
|
2
|
+
|
|
3
|
+
Mirrors the canvit_pytorch.model.classification API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import flax.nnx as nnx
|
|
13
|
+
import jax
|
|
14
|
+
import jax.numpy as jnp
|
|
15
|
+
|
|
16
|
+
from canvit_nnx.model import CanViT, CanViTConfig, RecurrentState, Viewpoint, CanViTOutput
|
|
17
|
+
from canvit_nnx.hub import load_state, FINETUNED_REPO
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
Array = jax.Array
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CanViTForImageClassification(nnx.Module):
|
|
25
|
+
"""CanViT backbone + LN -> Linear classification head.
|
|
26
|
+
|
|
27
|
+
Usage::
|
|
28
|
+
|
|
29
|
+
clf = CanViTForImageClassification.from_pretrained()
|
|
30
|
+
state = clf.init_state(batch_size=1, canvas_grid_size=32)
|
|
31
|
+
logits, state = clf(glimpse, state, viewpoint)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, cfg: CanViTConfig, n_classes: int, *, rngs: nnx.Rngs):
|
|
35
|
+
self.canvit = CanViT(cfg, rngs=rngs)
|
|
36
|
+
self.norm = nnx.LayerNorm(cfg.embed_dim, epsilon=1e-5, rngs=rngs)
|
|
37
|
+
self.head = nnx.Linear(cfg.embed_dim, n_classes, rngs=rngs)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def n_classes(self) -> int:
|
|
41
|
+
return self.head.out_features
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def cfg(self) -> CanViTConfig:
|
|
45
|
+
return self.canvit.cfg
|
|
46
|
+
|
|
47
|
+
def init_state(self, batch_size: int, canvas_grid_size: int) -> RecurrentState:
|
|
48
|
+
return self.canvit.init_state(batch_size, canvas_grid_size)
|
|
49
|
+
|
|
50
|
+
def __call__(self, glimpse: Array, state: RecurrentState,
|
|
51
|
+
viewpoint: Viewpoint) -> tuple[Array, RecurrentState]:
|
|
52
|
+
"""Returns (logits [B, n_classes], new_state)."""
|
|
53
|
+
out = self.canvit(glimpse, state, viewpoint)
|
|
54
|
+
cls = out.state.recurrent_cls[:, 0].astype(jnp.float32)
|
|
55
|
+
logits = self.head(self.norm(cls))
|
|
56
|
+
return logits, out.state
|
|
57
|
+
|
|
58
|
+
def canvit_forward(self, glimpse: Array, state: RecurrentState,
|
|
59
|
+
viewpoint: Viewpoint) -> CanViTOutput:
|
|
60
|
+
"""Run CanViT backbone only (for training with separate head_forward)."""
|
|
61
|
+
return self.canvit(glimpse, state, viewpoint)
|
|
62
|
+
|
|
63
|
+
def head_forward(self, cls: Array) -> Array:
|
|
64
|
+
"""LN -> Linear on [B, D] CLS token."""
|
|
65
|
+
return self.head(self.norm(cls.astype(jnp.float32)))
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def from_pretrained(repo_id: str = FINETUNED_REPO) -> CanViTForImageClassification:
|
|
69
|
+
"""Load finetuned CanViT classifier from HuggingFace Hub.
|
|
70
|
+
|
|
71
|
+
Uses JAX-native safetensors -- no PyTorch dependency.
|
|
72
|
+
"""
|
|
73
|
+
from huggingface_hub import hf_hub_download
|
|
74
|
+
|
|
75
|
+
config_path = Path(hf_hub_download(repo_id, "config.json"))
|
|
76
|
+
weights_path = Path(hf_hub_download(repo_id, "model.safetensors"))
|
|
77
|
+
|
|
78
|
+
config = json.loads(config_path.read_text())
|
|
79
|
+
mc = config["model_config"]
|
|
80
|
+
valid = set(CanViTConfig.__dataclass_fields__)
|
|
81
|
+
cfg = CanViTConfig(**{k: v for k, v in mc.items() if k in valid})
|
|
82
|
+
n_classes = config["n_classes"]
|
|
83
|
+
|
|
84
|
+
clf = CanViTForImageClassification(cfg, n_classes, rngs=nnx.Rngs(0))
|
|
85
|
+
load_state(clf, weights_path)
|
|
86
|
+
return clf
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""HuggingFace Hub integration: load and save JAX-native CanViT checkpoints.
|
|
2
|
+
|
|
3
|
+
No PyTorch dependency — loads directly from safetensors.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import flax.nnx as nnx
|
|
13
|
+
import jax.numpy as jnp
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from canvit_nnx.model import CanViT, CanViTConfig
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
PRETRAINED_REPO = "canvit/canvitb16-add-vpe-pretrain-g128px-s512px-in21k-dv3b16-2026-02-02-nnx"
|
|
21
|
+
FINETUNED_REPO = "canvit/canvitb16-add-vpe-finetune-g128px-s512px-in1k-2026-04-06-nnx"
|
|
22
|
+
|
|
23
|
+
def save_state(module: nnx.Module, path: str | Path) -> int:
|
|
24
|
+
"""Save all NNX module state to safetensors. Returns number of tensors saved."""
|
|
25
|
+
from safetensors.numpy import save_file
|
|
26
|
+
|
|
27
|
+
_, state = nnx.split(module)
|
|
28
|
+
params = {}
|
|
29
|
+
for path_tuple, leaf in state.flat_state():
|
|
30
|
+
key = ".".join(str(p) for p in path_tuple)
|
|
31
|
+
params[key] = np.array(leaf[...])
|
|
32
|
+
|
|
33
|
+
save_file(params, str(path))
|
|
34
|
+
log.info("Saved %d tensors to %s", len(params), path)
|
|
35
|
+
return len(params)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_state(module: nnx.Module, path: str | Path) -> int:
|
|
39
|
+
"""Load safetensors weights into an NNX module. Returns number loaded.
|
|
40
|
+
|
|
41
|
+
Every key in the checkpoint must exist in the module (and vice versa).
|
|
42
|
+
"""
|
|
43
|
+
from safetensors import safe_open
|
|
44
|
+
|
|
45
|
+
_, state = nnx.split(module)
|
|
46
|
+
|
|
47
|
+
state_map: dict[str, tuple] = {}
|
|
48
|
+
for path_tuple, leaf in state.flat_state():
|
|
49
|
+
key = ".".join(str(p) for p in path_tuple)
|
|
50
|
+
state_map[key] = leaf
|
|
51
|
+
|
|
52
|
+
loaded = 0
|
|
53
|
+
with safe_open(str(path), framework="numpy") as f:
|
|
54
|
+
ckpt_keys = set(f.keys())
|
|
55
|
+
model_keys = set(state_map.keys())
|
|
56
|
+
|
|
57
|
+
missing_in_model = ckpt_keys - model_keys
|
|
58
|
+
missing_in_ckpt = model_keys - ckpt_keys
|
|
59
|
+
if missing_in_model:
|
|
60
|
+
raise ValueError(f"Checkpoint has keys not in model: {sorted(missing_in_model)}")
|
|
61
|
+
if missing_in_ckpt:
|
|
62
|
+
raise ValueError(f"Model has keys not in checkpoint: {sorted(missing_in_ckpt)}")
|
|
63
|
+
|
|
64
|
+
for key in sorted(f.keys()):
|
|
65
|
+
leaf = state_map[key]
|
|
66
|
+
arr = jnp.array(f.get_tensor(key), dtype=jnp.float32)
|
|
67
|
+
if leaf[...].shape != arr.shape:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Shape mismatch at {key}: model={leaf[...].shape}, checkpoint={arr.shape}"
|
|
70
|
+
)
|
|
71
|
+
leaf[...] = arr
|
|
72
|
+
loaded += 1
|
|
73
|
+
|
|
74
|
+
log.info("Loaded %d tensors from %s", loaded, path)
|
|
75
|
+
return loaded
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def from_pretrained(repo_id: str = PRETRAINED_REPO) -> CanViT:
|
|
79
|
+
"""Load pretrained CanViT from HuggingFace Hub (JAX-native checkpoint)."""
|
|
80
|
+
from huggingface_hub import hf_hub_download
|
|
81
|
+
|
|
82
|
+
config_path = hf_hub_download(repo_id, "config.json")
|
|
83
|
+
weights_path = hf_hub_download(repo_id, "model.safetensors")
|
|
84
|
+
|
|
85
|
+
config = json.loads(Path(config_path).read_text())
|
|
86
|
+
mc = config["model_config"]
|
|
87
|
+
valid = set(CanViTConfig.__dataclass_fields__)
|
|
88
|
+
cfg = CanViTConfig(**{k: v for k, v in mc.items() if k in valid})
|
|
89
|
+
|
|
90
|
+
model = CanViT(cfg, rngs=nnx.Rngs(0))
|
|
91
|
+
load_state(model, weights_path)
|
|
92
|
+
return model
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""CanViT: dual-stream vision transformer with canvas cross-attention.
|
|
2
|
+
|
|
3
|
+
Self-contained Flax NNX implementation. No external model dependencies.
|
|
4
|
+
Reference: CanViT-PyTorch (authoritative) and CanViT-MLX (compact port).
|
|
5
|
+
|
|
6
|
+
Conventions:
|
|
7
|
+
- Images are NHWC (channels-last), matching JAX/XLA native layout.
|
|
8
|
+
- Coordinates are (y, x) order in [-1, 1]², matching tensor [H, W] indexing.
|
|
9
|
+
- RoPE is always computed and applied in float32.
|
|
10
|
+
- Canvas layout: [registers | spatial].
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import NamedTuple
|
|
18
|
+
|
|
19
|
+
import flax.nnx as nnx
|
|
20
|
+
import jax
|
|
21
|
+
import jax.numpy as jnp
|
|
22
|
+
|
|
23
|
+
Array = jax.Array
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# Config
|
|
28
|
+
# =============================================================================
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CanViTConfig:
|
|
33
|
+
embed_dim: int = 768
|
|
34
|
+
num_heads: int = 12
|
|
35
|
+
n_blocks: int = 12
|
|
36
|
+
patch_size: int = 16
|
|
37
|
+
ffn_ratio: float = 4.0
|
|
38
|
+
rope_base: float = 100.0
|
|
39
|
+
layerscale_init: float = 1e-5
|
|
40
|
+
|
|
41
|
+
rw_stride: int = 2
|
|
42
|
+
enable_reads: bool = True
|
|
43
|
+
n_backbone_registers: int = 5
|
|
44
|
+
n_canvas_registers: int = 16
|
|
45
|
+
canvas_num_heads: int = 8
|
|
46
|
+
canvas_head_dim: int = 128
|
|
47
|
+
enable_vpe: bool = True
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def canvas_dim(self) -> int:
|
|
51
|
+
return self.canvas_num_heads * self.canvas_head_dim
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def head_dim(self) -> int:
|
|
55
|
+
return self.embed_dim // self.num_heads
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
CANVIT_B16 = CanViTConfig()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# =============================================================================
|
|
62
|
+
# Data types
|
|
63
|
+
# =============================================================================
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@jax.tree_util.register_dataclass
|
|
67
|
+
@dataclass
|
|
68
|
+
class Viewpoint:
|
|
69
|
+
centers: Array # [B, 2] in [-1, 1], (y, x) order, float32
|
|
70
|
+
scales: Array # [B], float32
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def full_scene(batch_size: int) -> Viewpoint:
|
|
74
|
+
return Viewpoint(
|
|
75
|
+
centers=jnp.zeros((batch_size, 2), dtype=jnp.float32),
|
|
76
|
+
scales=jnp.ones((batch_size,), dtype=jnp.float32),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@jax.tree_util.register_dataclass
|
|
81
|
+
@dataclass
|
|
82
|
+
class RecurrentState:
|
|
83
|
+
canvas: Array # [B, n_canvas_registers + G², canvas_dim]
|
|
84
|
+
recurrent_cls: Array # [B, 1, local_dim]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CanViTOutput(NamedTuple):
|
|
88
|
+
state: RecurrentState
|
|
89
|
+
local_patches: Array # [B, H*W, local_dim]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# =============================================================================
|
|
93
|
+
# Coordinates
|
|
94
|
+
# =============================================================================
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def grid_coords(H: int, W: int) -> Array:
|
|
98
|
+
"""Normalized [-1, 1] cell-center coordinates. Returns [H*W, 2] in (y, x) order."""
|
|
99
|
+
y = (jnp.arange(H, dtype=jnp.float32) + 0.5) / H * 2 - 1
|
|
100
|
+
x = (jnp.arange(W, dtype=jnp.float32) + 0.5) / W * 2 - 1
|
|
101
|
+
yy, xx = jnp.meshgrid(y, x, indexing="ij")
|
|
102
|
+
return jnp.stack([yy, xx], axis=-1).reshape(H * W, 2)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def glimpse_positions(center: Array, scale: Array, H: int, W: int) -> Array:
|
|
106
|
+
"""Scene-space positions for a glimpse grid. Returns [B, H*W, 2]."""
|
|
107
|
+
retino = grid_coords(H, W) # [H*W, 2]
|
|
108
|
+
return center[:, None, :] + scale[:, None, None] * retino[None, :, :]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# =============================================================================
|
|
112
|
+
# 2D Rotary Position Embeddings
|
|
113
|
+
# =============================================================================
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _rope_periods(head_dim: int, base: float) -> Array:
|
|
117
|
+
"""Geometric frequency periods for 2D RoPE. Returns [head_dim // 4]."""
|
|
118
|
+
assert head_dim % 4 == 0
|
|
119
|
+
n = head_dim // 4
|
|
120
|
+
return base ** (jnp.arange(n, dtype=jnp.float32) / n)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class RoPE(NamedTuple):
|
|
124
|
+
sin: Array # [B, N, 1, head_dim] (BTNH layout for JAX SDPA)
|
|
125
|
+
cos: Array # [B, N, 1, head_dim]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def compute_rope(positions: Array, head_dim: int, base: float) -> RoPE:
|
|
129
|
+
"""Compute 2D RoPE sin/cos from positions [B, N, 2]. Always float32."""
|
|
130
|
+
periods = _rope_periods(head_dim, base)
|
|
131
|
+
# [B, N, 2, 1] / [n_freqs] -> [B, N, 2, n_freqs]
|
|
132
|
+
angles = 2 * math.pi * positions[..., None].astype(jnp.float32) / periods
|
|
133
|
+
B, N = angles.shape[:2]
|
|
134
|
+
angles = jnp.tile(angles.reshape(B, N, -1), (1, 1, 2)) # [B, N, head_dim]
|
|
135
|
+
sin = jnp.sin(angles)[:, :, None, :] # [B, N, 1, head_dim]
|
|
136
|
+
cos = jnp.cos(angles)[:, :, None, :]
|
|
137
|
+
return RoPE(sin, cos)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def apply_rope(x: Array, rope: RoPE) -> Array:
|
|
141
|
+
"""Apply RoPE rotation to spatial tokens, leaving prefix tokens unchanged.
|
|
142
|
+
|
|
143
|
+
x: [B, N_total, H, head_dim] (BTNH layout). RoPE covers N_spatial tokens.
|
|
144
|
+
Rotation is in float32; result cast back to x.dtype.
|
|
145
|
+
"""
|
|
146
|
+
n_prefix = x.shape[1] - rope.sin.shape[1]
|
|
147
|
+
half = x.shape[3] // 2
|
|
148
|
+
x_s = x[:, n_prefix:].astype(jnp.float32)
|
|
149
|
+
x_rot = jnp.concatenate([-x_s[..., half:], x_s[..., :half]], axis=-1)
|
|
150
|
+
spatial = (x_s * rope.cos + x_rot * rope.sin).astype(x.dtype)
|
|
151
|
+
if n_prefix > 0:
|
|
152
|
+
return jnp.concatenate([x[:, :n_prefix], spatial], axis=1)
|
|
153
|
+
return spatial
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# =============================================================================
|
|
157
|
+
# Building blocks
|
|
158
|
+
# =============================================================================
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PatchEmbed(nnx.Module):
|
|
162
|
+
def __init__(self, patch_size: int, embed_dim: int, rngs: nnx.Rngs):
|
|
163
|
+
self.proj = nnx.Conv(3, embed_dim, kernel_size=(patch_size, patch_size),
|
|
164
|
+
strides=(patch_size, patch_size), padding="VALID",
|
|
165
|
+
use_bias=True, rngs=rngs)
|
|
166
|
+
|
|
167
|
+
def __call__(self, x: Array) -> tuple[Array, int, int]:
|
|
168
|
+
"""x: [B, H, W, 3] -> patches [B, H'*W', D], H', W'."""
|
|
169
|
+
x = self.proj(x)
|
|
170
|
+
B, H, W, D = x.shape
|
|
171
|
+
return x.reshape(B, H * W, D), H, W
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class LayerScale(nnx.Module):
|
|
175
|
+
def __init__(self, dim: int, init_value: float = 1e-5):
|
|
176
|
+
self.gamma = nnx.Param(jnp.full((dim,), init_value))
|
|
177
|
+
|
|
178
|
+
def __call__(self, x: Array) -> Array:
|
|
179
|
+
return x * self.gamma
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class MLP(nnx.Module):
|
|
183
|
+
def __init__(self, dim: int, hidden_dim: int, rngs: nnx.Rngs):
|
|
184
|
+
self.fc1 = nnx.Linear(dim, hidden_dim, rngs=rngs)
|
|
185
|
+
self.fc2 = nnx.Linear(hidden_dim, dim, rngs=rngs)
|
|
186
|
+
|
|
187
|
+
def __call__(self, x: Array) -> Array:
|
|
188
|
+
# approximate=False: JAX defaults to tanh approx, PyTorch to exact
|
|
189
|
+
return self.fc2(jax.nn.gelu(self.fc1(x), approximate=False))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class SelfAttention(nnx.Module):
|
|
193
|
+
"""Multi-head self-attention with fused QKV and 2D RoPE.
|
|
194
|
+
|
|
195
|
+
Matches DINOv3 convention: K bias is zeroed out (Q and V biases active).
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, dim: int, num_heads: int, rngs: nnx.Rngs):
|
|
199
|
+
assert dim % num_heads == 0
|
|
200
|
+
self.num_heads = num_heads
|
|
201
|
+
self.head_dim = dim // num_heads
|
|
202
|
+
self.scale = self.head_dim ** -0.5
|
|
203
|
+
self.qkv = nnx.Linear(dim, dim * 3, rngs=rngs)
|
|
204
|
+
# Mask: Q bias ON, K bias OFF, V bias ON (DINOv3 convention)
|
|
205
|
+
self._bias_mask = nnx.Variable(
|
|
206
|
+
jnp.concatenate([jnp.ones(dim), jnp.zeros(dim), jnp.ones(dim)])
|
|
207
|
+
)
|
|
208
|
+
self.proj = nnx.Linear(dim, dim, rngs=rngs)
|
|
209
|
+
|
|
210
|
+
def __call__(self, x: Array, rope: RoPE) -> Array:
|
|
211
|
+
B, N, D = x.shape
|
|
212
|
+
# Manual matmul with masked bias (K bias zeroed, DINOv3 convention)
|
|
213
|
+
qkv = x @ self.qkv.kernel[...] # NNX kernel is (in, out)
|
|
214
|
+
if self.qkv.bias is not None:
|
|
215
|
+
qkv = qkv + self.qkv.bias[...] * self._bias_mask[...]
|
|
216
|
+
qkv = qkv.reshape(B, N, 3, self.num_heads, self.head_dim)
|
|
217
|
+
q = qkv[:, :, 0] # [B, N, H, D_h] — BTNH for JAX SDPA
|
|
218
|
+
k = qkv[:, :, 1]
|
|
219
|
+
v = qkv[:, :, 2]
|
|
220
|
+
q = apply_rope(q, rope)
|
|
221
|
+
k = apply_rope(k, rope)
|
|
222
|
+
out = jax.nn.dot_product_attention(q, k, v, scale=self.scale)
|
|
223
|
+
return self.proj(out.reshape(B, N, D))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class ViTBlock(nnx.Module):
|
|
227
|
+
def __init__(self, dim: int, num_heads: int, ffn_ratio: float,
|
|
228
|
+
layerscale_init: float, rngs: nnx.Rngs):
|
|
229
|
+
self.norm1 = nnx.LayerNorm(dim, epsilon=1e-5, rngs=rngs)
|
|
230
|
+
self.attn = SelfAttention(dim, num_heads, rngs=rngs)
|
|
231
|
+
self.ls1 = LayerScale(dim, layerscale_init)
|
|
232
|
+
self.norm2 = nnx.LayerNorm(dim, epsilon=1e-5, rngs=rngs)
|
|
233
|
+
self.mlp = MLP(dim, int(dim * ffn_ratio), rngs=rngs)
|
|
234
|
+
self.ls2 = LayerScale(dim, layerscale_init)
|
|
235
|
+
|
|
236
|
+
def __call__(self, x: Array, rope: RoPE) -> Array:
|
|
237
|
+
x = x + self.ls1(self.attn(self.norm1(x), rope))
|
|
238
|
+
return x + self.ls2(self.mlp(self.norm2(x)))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# =============================================================================
|
|
242
|
+
# Canvas attention (asymmetric cross-attention)
|
|
243
|
+
# =============================================================================
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _to_multihead(x: Array, num_heads: int) -> Array:
|
|
247
|
+
"""[B, N, D] -> [B, N, H, head_dim] (JAX SDPA expects BTNH)."""
|
|
248
|
+
B, N, D = x.shape
|
|
249
|
+
return x.reshape(B, N, num_heads, D // num_heads)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _from_multihead(x: Array) -> Array:
|
|
253
|
+
"""[B, N, H, head_dim] -> [B, N, D]."""
|
|
254
|
+
B, N, H, hd = x.shape
|
|
255
|
+
return x.reshape(B, N, H * hd)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class CanvasReadAttention(nnx.Module):
|
|
259
|
+
"""Local queries canvas. Dense projections on local side only (asymmetric)."""
|
|
260
|
+
|
|
261
|
+
def __init__(self, local_dim: int, canvas_dim: int, num_heads: int, rngs: nnx.Rngs):
|
|
262
|
+
assert canvas_dim % num_heads == 0
|
|
263
|
+
self.num_heads = num_heads
|
|
264
|
+
self.scale = (canvas_dim // num_heads) ** -0.5
|
|
265
|
+
self.q_norm = nnx.LayerNorm(local_dim, epsilon=1e-5, rngs=rngs)
|
|
266
|
+
self.kv_norm = nnx.LayerNorm(canvas_dim, epsilon=1e-5, rngs=rngs)
|
|
267
|
+
self.q_proj = nnx.Linear(local_dim, canvas_dim, rngs=rngs)
|
|
268
|
+
self.out_proj = nnx.Linear(canvas_dim, local_dim, rngs=rngs)
|
|
269
|
+
|
|
270
|
+
def __call__(self, query: Array, kv: Array,
|
|
271
|
+
q_rope: RoPE, kv_rope: RoPE) -> Array:
|
|
272
|
+
q = _to_multihead(self.q_proj(self.q_norm(query)), self.num_heads)
|
|
273
|
+
kv_n = self.kv_norm(kv)
|
|
274
|
+
k = _to_multihead(kv_n, self.num_heads) # Identity projection
|
|
275
|
+
v = _to_multihead(kv_n, self.num_heads)
|
|
276
|
+
q = apply_rope(q, q_rope)
|
|
277
|
+
k = apply_rope(k, kv_rope)
|
|
278
|
+
out = jax.nn.dot_product_attention(q, k.astype(q.dtype), v.astype(q.dtype),
|
|
279
|
+
scale=self.scale)
|
|
280
|
+
return self.out_proj(_from_multihead(out))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class CanvasWriteAttention(nnx.Module):
|
|
284
|
+
"""Canvas queries local. Dense projections on local side only (asymmetric).
|
|
285
|
+
|
|
286
|
+
When gate_bias_init is set, uses convex update: lerp(canvas, attn_out, gate).
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(self, local_dim: int, canvas_dim: int, num_heads: int,
|
|
290
|
+
rngs: nnx.Rngs, gate_bias_init: float | None = None):
|
|
291
|
+
assert canvas_dim % num_heads == 0
|
|
292
|
+
self.num_heads = num_heads
|
|
293
|
+
self.scale = (canvas_dim // num_heads) ** -0.5
|
|
294
|
+
self.q_norm = nnx.LayerNorm(canvas_dim, epsilon=1e-5, rngs=rngs)
|
|
295
|
+
self.kv_norm = nnx.LayerNorm(local_dim, epsilon=1e-5, rngs=rngs)
|
|
296
|
+
self.k_proj = nnx.Linear(local_dim, canvas_dim, rngs=rngs)
|
|
297
|
+
self.v_proj = nnx.Linear(local_dim, canvas_dim, rngs=rngs)
|
|
298
|
+
self.gate_linear: nnx.Linear | None = None
|
|
299
|
+
if gate_bias_init is not None:
|
|
300
|
+
self.gate_linear = nnx.Linear(canvas_dim, 1, rngs=rngs)
|
|
301
|
+
self.gate_linear.bias = nnx.Param(jnp.full((1,), gate_bias_init))
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def is_convex(self) -> bool:
|
|
305
|
+
return self.gate_linear is not None
|
|
306
|
+
|
|
307
|
+
def __call__(self, query: Array, kv: Array,
|
|
308
|
+
q_rope: RoPE, kv_rope: RoPE) -> Array:
|
|
309
|
+
q = _to_multihead(self.q_norm(query), self.num_heads) # Identity projection
|
|
310
|
+
kv_n = self.kv_norm(kv)
|
|
311
|
+
k = _to_multihead(self.k_proj(kv_n), self.num_heads)
|
|
312
|
+
v = _to_multihead(self.v_proj(kv_n), self.num_heads)
|
|
313
|
+
q = apply_rope(q, q_rope)
|
|
314
|
+
k = apply_rope(k, kv_rope)
|
|
315
|
+
attn_out = _from_multihead(
|
|
316
|
+
jax.nn.dot_product_attention(q, k.astype(q.dtype), v.astype(q.dtype),
|
|
317
|
+
scale=self.scale)
|
|
318
|
+
)
|
|
319
|
+
if self.gate_linear is None:
|
|
320
|
+
return attn_out
|
|
321
|
+
gate = jax.nn.sigmoid(self.gate_linear(attn_out))
|
|
322
|
+
return (1 - gate) * query + gate * attn_out
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# =============================================================================
|
|
326
|
+
# VPE: Viewpoint Positional Encoding
|
|
327
|
+
# =============================================================================
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class VPEEncoder(nnx.Module):
|
|
331
|
+
"""Encodes (y, x, scale) viewpoint into a high-dimensional vector via Random Fourier Features."""
|
|
332
|
+
|
|
333
|
+
def __init__(self, rff_dim: int, *, rngs: nnx.Rngs, seed: int = 42):
|
|
334
|
+
assert rff_dim > 0 and rff_dim % 2 == 0
|
|
335
|
+
self.rff_dim = rff_dim
|
|
336
|
+
# Frozen RFF projection matrix — deterministic from seed, not from rngs
|
|
337
|
+
key = jax.random.PRNGKey(seed)
|
|
338
|
+
self.B_mat = nnx.Variable(jax.random.normal(key, (rff_dim // 2, 3), dtype=jnp.float32))
|
|
339
|
+
self.norm = nnx.LayerNorm(rff_dim, epsilon=1e-5, rngs=rngs)
|
|
340
|
+
# Match PyTorch init: scale = 1/sqrt(rff_dim)
|
|
341
|
+
self.norm.scale = nnx.Param(jnp.full((rff_dim,), 1.0 / math.sqrt(rff_dim)))
|
|
342
|
+
|
|
343
|
+
def __call__(self, y: Array, x: Array, s: Array) -> Array:
|
|
344
|
+
"""y, x: [B], s: [B] -> [B, rff_dim]. Always float32."""
|
|
345
|
+
z = jnp.stack([y / s, x / s, jnp.log(s)], axis=-1).astype(jnp.float32)
|
|
346
|
+
proj = z @ self.B_mat[...].T
|
|
347
|
+
enc = jnp.concatenate([jnp.cos(proj), jnp.sin(proj)], axis=-1)
|
|
348
|
+
return self.norm(enc)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# =============================================================================
|
|
352
|
+
# CanViT: the full model
|
|
353
|
+
# =============================================================================
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _compute_rw_positions(n_blocks: int, rw_stride: int,
|
|
357
|
+
enable_reads: bool) -> tuple[list[int], list[int]]:
|
|
358
|
+
"""Schedule read/write operations across blocks.
|
|
359
|
+
|
|
360
|
+
Pattern: [rw_stride blocks] R [rw_stride blocks] W ... Always ends with W.
|
|
361
|
+
"""
|
|
362
|
+
read_after: list[int] = []
|
|
363
|
+
write_after: list[int] = []
|
|
364
|
+
for i, pos in enumerate(range(rw_stride - 1, n_blocks, rw_stride)):
|
|
365
|
+
if i % 2 == 0:
|
|
366
|
+
read_after.append(pos)
|
|
367
|
+
else:
|
|
368
|
+
write_after.append(pos)
|
|
369
|
+
if not write_after or write_after[-1] != n_blocks - 1:
|
|
370
|
+
write_after.append(n_blocks - 1)
|
|
371
|
+
if not enable_reads:
|
|
372
|
+
read_after = []
|
|
373
|
+
return read_after, write_after
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class CanViT(nnx.Module):
|
|
377
|
+
"""Dual-stream vision transformer with canvas cross-attention.
|
|
378
|
+
|
|
379
|
+
Processes NHWC glimpses. Canvas and CLS token persist across timesteps.
|
|
380
|
+
Generalizes to any canvas and glimpse grid sizes at runtime via RoPE.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(self, cfg: CanViTConfig = CANVIT_B16, *, rngs: nnx.Rngs):
|
|
384
|
+
self.cfg = cfg
|
|
385
|
+
d = cfg.embed_dim
|
|
386
|
+
cd = cfg.canvas_dim
|
|
387
|
+
|
|
388
|
+
# Backbone
|
|
389
|
+
self.patch_embed = PatchEmbed(cfg.patch_size, d, rngs)
|
|
390
|
+
self.blocks = nnx.List([
|
|
391
|
+
ViTBlock(d, cfg.num_heads, cfg.ffn_ratio, cfg.layerscale_init, rngs)
|
|
392
|
+
for _ in range(cfg.n_blocks)
|
|
393
|
+
])
|
|
394
|
+
|
|
395
|
+
# Read/write schedule
|
|
396
|
+
read_after, write_after = _compute_rw_positions(
|
|
397
|
+
cfg.n_blocks, cfg.rw_stride, cfg.enable_reads)
|
|
398
|
+
self._read_after = tuple(read_after)
|
|
399
|
+
self._write_after = tuple(write_after)
|
|
400
|
+
|
|
401
|
+
# Canvas attention layers
|
|
402
|
+
self.canvas_read = nnx.List([
|
|
403
|
+
CanvasReadAttention(d, cd, cfg.canvas_num_heads, rngs)
|
|
404
|
+
for _ in read_after
|
|
405
|
+
])
|
|
406
|
+
self.canvas_write = nnx.List([
|
|
407
|
+
CanvasWriteAttention(d, cd, cfg.canvas_num_heads, rngs)
|
|
408
|
+
for _ in write_after
|
|
409
|
+
])
|
|
410
|
+
|
|
411
|
+
# Learnable inits
|
|
412
|
+
canvas_scale = 1.0 / math.sqrt(cd)
|
|
413
|
+
local_scale = 1.0 / math.sqrt(d)
|
|
414
|
+
self.canvas_register_init = nnx.Param(
|
|
415
|
+
jax.random.normal(rngs.params(), (1, cfg.n_canvas_registers, cd)) * canvas_scale
|
|
416
|
+
)
|
|
417
|
+
self.canvas_spatial_init = nnx.Param(
|
|
418
|
+
jax.random.normal(rngs.params(), (1, 1, cd)) * canvas_scale
|
|
419
|
+
)
|
|
420
|
+
self.recurrent_cls_init = nnx.Param(
|
|
421
|
+
jax.random.normal(rngs.params(), (1, 1, d)) * local_scale
|
|
422
|
+
)
|
|
423
|
+
self.backbone_registers = nnx.Param(
|
|
424
|
+
jax.random.normal(rngs.params(), (1, cfg.n_backbone_registers, d)) * 0.02
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# VPE
|
|
428
|
+
if cfg.enable_vpe:
|
|
429
|
+
self.vpe = VPEEncoder(rff_dim=d, rngs=rngs)
|
|
430
|
+
else:
|
|
431
|
+
self.vpe = nnx.data(None)
|
|
432
|
+
|
|
433
|
+
def init_state(self, batch_size: int, canvas_grid_size: int) -> RecurrentState:
|
|
434
|
+
cfg = self.cfg
|
|
435
|
+
n_spatial = canvas_grid_size ** 2
|
|
436
|
+
regs = jnp.broadcast_to(self.canvas_register_init[...], (batch_size, cfg.n_canvas_registers, cfg.canvas_dim))
|
|
437
|
+
spatial = jnp.broadcast_to(self.canvas_spatial_init[...], (batch_size, n_spatial, cfg.canvas_dim))
|
|
438
|
+
return RecurrentState(
|
|
439
|
+
canvas=jnp.concatenate([regs, spatial], axis=1),
|
|
440
|
+
recurrent_cls=jnp.broadcast_to(self.recurrent_cls_init[...], (batch_size, 1, cfg.embed_dim)),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def get_spatial(self, canvas: Array) -> Array:
|
|
444
|
+
return canvas[:, self.cfg.n_canvas_registers:]
|
|
445
|
+
|
|
446
|
+
def __call__(self, glimpse: Array, state: RecurrentState,
|
|
447
|
+
viewpoint: Viewpoint) -> CanViTOutput:
|
|
448
|
+
"""Process one glimpse. glimpse: [B, H, W, 3] NHWC."""
|
|
449
|
+
B = glimpse.shape[0]
|
|
450
|
+
cfg = self.cfg
|
|
451
|
+
canvas = state.canvas
|
|
452
|
+
|
|
453
|
+
# Patch embed
|
|
454
|
+
patches, H, W = self.patch_embed(glimpse)
|
|
455
|
+
assert H == W
|
|
456
|
+
n_patches = H * W
|
|
457
|
+
|
|
458
|
+
# Assemble local tokens: [vpe?, cls, registers, patches]
|
|
459
|
+
parts: list[Array] = []
|
|
460
|
+
has_vpe = self.vpe is not None
|
|
461
|
+
if has_vpe:
|
|
462
|
+
vpe_tok = self.vpe(
|
|
463
|
+
viewpoint.centers[:, 0], viewpoint.centers[:, 1], viewpoint.scales
|
|
464
|
+
)
|
|
465
|
+
parts.append(vpe_tok[:, None, :].astype(patches.dtype))
|
|
466
|
+
parts.extend([
|
|
467
|
+
state.recurrent_cls,
|
|
468
|
+
jnp.broadcast_to(self.backbone_registers[...], (B, cfg.n_backbone_registers, cfg.embed_dim)),
|
|
469
|
+
patches,
|
|
470
|
+
])
|
|
471
|
+
local = jnp.concatenate(parts, axis=1)
|
|
472
|
+
|
|
473
|
+
# RoPE for local and canvas positions
|
|
474
|
+
local_pos = glimpse_positions(viewpoint.centers, viewpoint.scales, H, W)
|
|
475
|
+
bb_rope = compute_rope(local_pos, cfg.head_dim, cfg.rope_base)
|
|
476
|
+
ca_local_rope = compute_rope(local_pos, cfg.canvas_head_dim, cfg.rope_base)
|
|
477
|
+
|
|
478
|
+
n_spatial = canvas.shape[1] - cfg.n_canvas_registers
|
|
479
|
+
cg = int(math.sqrt(n_spatial))
|
|
480
|
+
assert cg * cg == n_spatial
|
|
481
|
+
canvas_pos = jnp.broadcast_to(grid_coords(cg, cg)[None], (B, n_spatial, 2))
|
|
482
|
+
ca_canvas_rope = compute_rope(canvas_pos, cfg.canvas_head_dim, cfg.rope_base)
|
|
483
|
+
|
|
484
|
+
# Process through blocks with interleaved read/write
|
|
485
|
+
ri, wi = 0, 0
|
|
486
|
+
for bi in range(cfg.n_blocks):
|
|
487
|
+
local = self.blocks[bi](local, bb_rope)
|
|
488
|
+
|
|
489
|
+
if ri < len(self._read_after) and bi == self._read_after[ri]:
|
|
490
|
+
local = local + self.canvas_read[ri](local, canvas, ca_local_rope, ca_canvas_rope)
|
|
491
|
+
ri += 1
|
|
492
|
+
|
|
493
|
+
if wi < len(self._write_after) and bi == self._write_after[wi]:
|
|
494
|
+
write_out = self.canvas_write[wi](canvas, local, ca_canvas_rope, ca_local_rope)
|
|
495
|
+
canvas = write_out if self.canvas_write[wi].is_convex else canvas + write_out
|
|
496
|
+
wi += 1
|
|
497
|
+
|
|
498
|
+
# Unpack
|
|
499
|
+
idx = (1 if has_vpe else 0)
|
|
500
|
+
new_cls = local[:, idx:idx + 1]
|
|
501
|
+
idx += 1 + cfg.n_backbone_registers
|
|
502
|
+
out_patches = local[:, idx:idx + n_patches]
|
|
503
|
+
|
|
504
|
+
return CanViTOutput(
|
|
505
|
+
state=RecurrentState(canvas=canvas, recurrent_cls=new_cls),
|
|
506
|
+
local_patches=out_patches,
|
|
507
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Glimpse extraction via viewpoint sampling.
|
|
2
|
+
|
|
3
|
+
Coordinate convention (shared with canvit_nnx.model):
|
|
4
|
+
- Coordinates are in [-1, 1]²
|
|
5
|
+
- (0, 0) is image center, (-1, -1) is top-left, (1, 1) is bottom-right
|
|
6
|
+
- Axis order is (y, x) — matrix indexing convention, NOT Cartesian
|
|
7
|
+
- scales: 1 = full scene, <1 = zoomed in
|
|
8
|
+
|
|
9
|
+
Matches PyTorch F.grid_sample with align_corners=False.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import jax
|
|
15
|
+
import jax.numpy as jnp
|
|
16
|
+
|
|
17
|
+
from canvit_nnx.model import Viewpoint
|
|
18
|
+
|
|
19
|
+
Array = jax.Array
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sample_at_viewpoint(
|
|
23
|
+
spatial: Array,
|
|
24
|
+
viewpoint: Viewpoint,
|
|
25
|
+
glimpse_size_px: int,
|
|
26
|
+
) -> Array:
|
|
27
|
+
"""Bilinear crop from spatial tensor at viewpoint position.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
spatial: [B, H, W, C] image or feature map (NHWC)
|
|
31
|
+
viewpoint: where to look (centers [B, 2], scales [B])
|
|
32
|
+
glimpse_size_px: output spatial size (square)
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
[B, glimpse_size_px, glimpse_size_px, C] bilinearly sampled crop
|
|
36
|
+
"""
|
|
37
|
+
B, H, W, C = spatial.shape
|
|
38
|
+
g = glimpse_size_px
|
|
39
|
+
|
|
40
|
+
# Sampling grid in [-1, 1] — cell centers
|
|
41
|
+
ys = (jnp.arange(g, dtype=jnp.float32) + 0.5) / g * 2 - 1
|
|
42
|
+
xs = (jnp.arange(g, dtype=jnp.float32) + 0.5) / g * 2 - 1
|
|
43
|
+
grid_y, grid_x = jnp.meshgrid(ys, xs, indexing="ij")
|
|
44
|
+
grid = jnp.stack([grid_y, grid_x], axis=-1) # [g, g, 2]
|
|
45
|
+
|
|
46
|
+
# Scene-space positions: center + scale * grid
|
|
47
|
+
center = viewpoint.centers[:, None, None, :] # [B, 1, 1, 2]
|
|
48
|
+
scale = viewpoint.scales[:, None, None, None] # [B, 1, 1, 1]
|
|
49
|
+
scene_pos = center + scale * grid[None] # [B, g, g, 2]
|
|
50
|
+
|
|
51
|
+
# [-1, 1] → pixel coords (align_corners=False: pixel = (grid + 1) * N / 2 - 0.5)
|
|
52
|
+
pixel_y = (scene_pos[..., 0] + 1) * H / 2 - 0.5
|
|
53
|
+
pixel_x = (scene_pos[..., 1] + 1) * W / 2 - 0.5
|
|
54
|
+
|
|
55
|
+
# Bilinear interpolation
|
|
56
|
+
y0 = jnp.floor(pixel_y).astype(jnp.int32)
|
|
57
|
+
x0 = jnp.floor(pixel_x).astype(jnp.int32)
|
|
58
|
+
y1 = y0 + 1
|
|
59
|
+
x1 = x0 + 1
|
|
60
|
+
|
|
61
|
+
y0c = jnp.clip(y0, 0, H - 1)
|
|
62
|
+
y1c = jnp.clip(y1, 0, H - 1)
|
|
63
|
+
x0c = jnp.clip(x0, 0, W - 1)
|
|
64
|
+
x1c = jnp.clip(x1, 0, W - 1)
|
|
65
|
+
|
|
66
|
+
wy = pixel_y - y0.astype(jnp.float32)
|
|
67
|
+
wx = pixel_x - x0.astype(jnp.float32)
|
|
68
|
+
|
|
69
|
+
b_idx = jnp.arange(B)[:, None, None]
|
|
70
|
+
val00 = spatial[b_idx, y0c, x0c]
|
|
71
|
+
val01 = spatial[b_idx, y0c, x1c]
|
|
72
|
+
val10 = spatial[b_idx, y1c, x0c]
|
|
73
|
+
val11 = spatial[b_idx, y1c, x1c]
|
|
74
|
+
|
|
75
|
+
return (val00 * (1 - wy[..., None]) * (1 - wx[..., None]) +
|
|
76
|
+
val01 * (1 - wy[..., None]) * wx[..., None] +
|
|
77
|
+
val10 * wy[..., None] * (1 - wx[..., None]) +
|
|
78
|
+
val11 * wy[..., None] * wx[..., None])
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[tool.hatch.build.targets.wheel]
|
|
6
|
+
packages = ["canvit_nnx"]
|
|
7
|
+
|
|
8
|
+
[tool.hatch.build.targets.sdist]
|
|
9
|
+
exclude = ["throwaway/", "scripts/", "tests/"]
|
|
10
|
+
|
|
11
|
+
[project]
|
|
12
|
+
name = "canvit-nnx"
|
|
13
|
+
version = "0.1.0"
|
|
14
|
+
description = "CanViT (Canvas Vision Transformer) -- JAX / Flax NNX"
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
license = "MIT"
|
|
17
|
+
authors = [
|
|
18
|
+
{ name = "Yohaï-Eliel Berreby", email = "me@yberreby.com" },
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.12"
|
|
21
|
+
dependencies = [
|
|
22
|
+
"jax>=0.9",
|
|
23
|
+
"flax>=0.12",
|
|
24
|
+
"jaxlib>=0.9",
|
|
25
|
+
"safetensors",
|
|
26
|
+
"huggingface-hub",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
convert = [
|
|
31
|
+
"torch>=2.0",
|
|
32
|
+
"canvit-pytorch>=0.1.9",
|
|
33
|
+
]
|
|
34
|
+
verify = [
|
|
35
|
+
"canvit-nnx[convert]",
|
|
36
|
+
"pillow",
|
|
37
|
+
]
|
|
38
|
+
demo = [
|
|
39
|
+
"pillow",
|
|
40
|
+
]
|
|
41
|
+
dev = [
|
|
42
|
+
"canvit-nnx[convert,verify,demo]",
|
|
43
|
+
"torchvision",
|
|
44
|
+
"ipython",
|
|
45
|
+
"pytest",
|
|
46
|
+
"pypatree",
|
|
47
|
+
]
|