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.
@@ -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,20 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # uv
13
+ uv.lock
14
+
15
+ # Pytest
16
+ .pytest_cache/
17
+
18
+ # Outputs
19
+ outputs/
20
+ main.py
@@ -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
+ ]