mad-bot commited on
Commit
eae424a
·
verified ·
1 Parent(s): 1233ab7

Upload folder using huggingface_hub (part 2)

Browse files
Files changed (50) hide show
  1. environment/training-source/source-snapshot/Cargo.lock +0 -0
  2. environment/training-source/source-snapshot/Cargo.toml +74 -0
  3. environment/training-source/source-snapshot/LICENSE +21 -0
  4. environment/training-source/source-snapshot/README.md +363 -0
  5. environment/training-source/source-snapshot/REPORT.md +228 -0
  6. environment/training-source/source-snapshot/android-xr/src/lib.rs +898 -0
  7. environment/training-source/source-snapshot/examples/bench.rs +44 -0
  8. environment/training-source/source-snapshot/examples/common/mod.rs +310 -0
  9. environment/training-source/source-snapshot/examples/evaluate_decoder.rs +559 -0
  10. environment/training-source/source-snapshot/examples/preview.rs +138 -0
  11. environment/training-source/source-snapshot/examples/train_decoder.rs +436 -0
  12. environment/training-source/source-snapshot/examples/verify.rs +638 -0
  13. environment/training-source/source-snapshot/examples/viewer.rs +288 -0
  14. environment/training-source/source-snapshot/experiments/AUDIT.md +74 -0
  15. environment/training-source/source-snapshot/experiments/README.md +163 -0
  16. environment/training-source/source-snapshot/huggingface/DINOv3-LICENSE.md +66 -0
  17. environment/training-source/source-snapshot/huggingface/NOTICE.md +17 -0
  18. environment/training-source/source-snapshot/huggingface/README.md +131 -0
  19. environment/training-source/source-snapshot/src/bench.rs +472 -0
  20. environment/training-source/source-snapshot/src/camera.rs +731 -0
  21. environment/training-source/source-snapshot/src/decoder.rs +385 -0
  22. environment/training-source/source-snapshot/src/dinov3.rs +532 -0
  23. environment/training-source/source-snapshot/src/inference.rs +370 -0
  24. environment/training-source/source-snapshot/src/lib.rs +70 -0
  25. environment/training-source/source-snapshot/src/pca.rs +401 -0
  26. environment/training-source/source-snapshot/src/preprocess.rs +191 -0
  27. environment/training-source/source-snapshot/src/render.rs +324 -0
  28. environment/training-source/source-snapshot/src/shaders/grid_view.wgsl +73 -0
  29. environment/training-source/source-snapshot/src/source.rs +289 -0
  30. environment/training-source/source-snapshot/src/weights.rs +172 -0
  31. environment/training-source/source-snapshot/tests/dinov3_ops.rs +142 -0
  32. environment/training-source/source-snapshot/tests/semantics.rs +133 -0
  33. environment/training-source/source-snapshot/tests/threading.rs +29 -0
  34. environment/training-source/source-snapshot/tools/build_android_artifacts.ps1 +121 -0
  35. environment/training-source/source-snapshot/tools/collect_artifact_metadata.ps1 +156 -0
  36. environment/training-source/source-snapshot/tools/compare_f32.py +170 -0
  37. environment/training-source/source-snapshot/tools/convert_decoder.py +96 -0
  38. environment/training-source/source-snapshot/tools/dump_reference.py +228 -0
  39. environment/training-source/source-snapshot/tools/extract_json_records.py +49 -0
  40. environment/training-source/source-snapshot/tools/make_correctness_frame.py +38 -0
  41. environment/training-source/source-snapshot/tools/make_dataset_manifest.py +144 -0
  42. environment/training-source/source-snapshot/tools/pull_quest_artifacts.ps1 +89 -0
  43. environment/training-source/source-snapshot/tools/run_cross_device_correctness.ps1 +160 -0
  44. environment/training-source/source-snapshot/tools/run_decoder_matrix.ps1 +142 -0
  45. environment/training-source/source-snapshot/tools/run_quest_bench.ps1 +149 -0
  46. environment/training-source/source-snapshot/tools/run_xr_sweep.ps1 +239 -0
  47. environment/training-source/source-snapshot/tools/stage_huggingface_artifact.ps1 +230 -0
  48. environment/training-source/source-snapshot/tools/summarize_bench.py +144 -0
  49. environment/training-source/source-snapshot/tools/summarize_dataset.py +126 -0
  50. environment/training-source/source-snapshot/tools/summarize_quality.py +202 -0
environment/training-source/source-snapshot/Cargo.lock ADDED
The diff for this file is too large to render. See raw diff
 
environment/training-source/source-snapshot/Cargo.toml ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "dinovision"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+
6
+ [workspace]
7
+ members = ["android", "android-xr"]
8
+
9
+ [dependencies]
10
+ # Pinned to the exact blade-graphics revision meganeura depends on. Both
11
+ # crates must resolve to the *same* blade-graphics so the
12
+ # `Arc<blade_graphics::Context>` we create for rendering is the same type
13
+ # meganeura's `SessionConfig::gpu` accepts. Bump both together.
14
+ blade-graphics = { version = "0.8.4", git = "https://github.com/kvark/blade", rev = "ba0fb5a6f2b5462c3e5796f8ce06b3b3d580adac" }
15
+ meganeura = { version = "0.2", default-features = false }
16
+ bytemuck = { version = "1", features = ["derive"] }
17
+ blade-macros = "0.3"
18
+ half = "2"
19
+ log = "0.4"
20
+
21
+ # Desktop screen capture. Optional so the Android crate never sees it —
22
+ # it drags in a windowing stack that has no business on a headset.
23
+ xcap = { version = "0.9", optional = true }
24
+
25
+ [dev-dependencies]
26
+ env_logger = "0.11"
27
+ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
28
+ serde = { version = "1", features = ["derive"] }
29
+ serde_json = "1"
30
+ sha2 = "0.10"
31
+
32
+ # The viewer's windowing, kept off Android: winit drags in android-activity,
33
+ # which then fails to build for aarch64-linux-android when no backend
34
+ # feature is selected — and it has no business there anyway, since the
35
+ # on-device benchmark is a plain native binary and the XR app has its own
36
+ # activity.
37
+ [target.'cfg(not(target_os = "android"))'.dev-dependencies]
38
+ winit = "0.30"
39
+
40
+ [features]
41
+ default = []
42
+ # Desktop-only: lets the tools fetch weights straight from the Hub. The
43
+ # on-device crate never enables it.
44
+ hub = ["meganeura/hub"]
45
+ # Live screen capture as a `source::FrameSource`.
46
+ capture = ["dep:xcap"]
47
+
48
+ # Tools live in examples/ rather than src/bin/ so they can use
49
+ # dev-dependencies, matching meganeura's own layout.
50
+ [[example]]
51
+ name = "bench"
52
+ path = "examples/bench.rs"
53
+
54
+ [[example]]
55
+ name = "verify"
56
+ path = "examples/verify.rs"
57
+
58
+ # Local checkout: the Android build fix (hf-hub behind a feature,
59
+ # tokenizers moved to dev-dependencies) is not upstream yet.
60
+ [patch.crates-io]
61
+ meganeura = { path = "../meganeura" }
62
+
63
+ [[example]]
64
+ name = "preview"
65
+ path = "examples/preview.rs"
66
+
67
+ [[example]]
68
+ name = "viewer"
69
+ path = "examples/viewer.rs"
70
+ required-features = ["capture"]
71
+
72
+ [[example]]
73
+ name = "train_decoder"
74
+ path = "examples/train_decoder.rs"
environment/training-source/source-snapshot/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dzmitry Malyshau
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
environment/training-source/source-snapshot/README.md ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # dinovision
2
+
3
+ DINOv3 inference on Meta Quest, using [Blade](https://github.com/kvark/blade)
4
+ for graphics and [Meganeura](https://github.com/kvark/meganeura) for the
5
+ network. The camera image is encoded to DINOv3 patch features and those
6
+ features are turned back into something viewable, so the wearer sees the
7
+ world through a DINO roundtrip.
8
+
9
+ Status: **the full stereo passthrough roundtrip is implemented.** The corrected
10
+ encoder and Meganeura-trained decoder pass host validation; fresh Quest
11
+ correctness and timing runs are tracked in `REPORT.md`. No historical device
12
+ number in Git history is paper evidence unless that report explicitly admits
13
+ it into the frozen artifact.
14
+
15
+ **Meganeura does both halves.** The encoder is Meganeura inference; the
16
+ decoder was *trained* in Meganeura too — its autodiff builds the backward
17
+ pass (47 forward nodes → 122 with backward), its Adam runs the updates, and
18
+ `session.step()` does forward, backward, and optimizer in one GPU
19
+ submission. PyTorch is not used to train or export the decoder; it appears
20
+ only in the independent Hugging Face numerical-reference check.
21
+
22
+ The test-pattern fallback remains available when camera permission or hardware
23
+ is absent, but it is not evidence for the passthrough result.
24
+
25
+ ## Layout
26
+
27
+ | Path | What |
28
+ |---|---|
29
+ | `src/dinov3.rs` | The encoder as a meganeura graph, plus config and RoPE tables |
30
+ | `src/preprocess.rs` | Image → patch tensor, in the order the folded patch-embedding matmul needs |
31
+ | `src/weights.rs` | Binding a HuggingFace checkpoint to graph parameters |
32
+ | `src/pca.rs` | Features → RGB: on-device PCA fit, folded into the graph as one matmul |
33
+ | `src/inference.rs` | The worker thread that owns the session and publishes feature grids |
34
+ | `src/render.rs` | Full-screen view of the coloured grid |
35
+ | `src/source.rs` | Where frames come from: test pattern, screen capture, and the seam the Quest camera slots into |
36
+ | `src/bench.rs` | Throughput measurement, shared by desktop and device |
37
+ | `examples/viewer.rs` | Live windowed viewer over screen capture |
38
+ | `examples/preview.rs` | Runs the whole pipeline on desktop and writes a PNG |
39
+ | `examples/bench.rs` | Desktop benchmark runner |
40
+ | `examples/verify.rs` | Numerics comparison against the HuggingFace reference |
41
+ | `android/` | The headless benchmark APK |
42
+ | `android-xr/` | The XR application |
43
+ | `tools/dump_reference.py` | Produces the reference dump `verify` consumes |
44
+
45
+ ## Running
46
+
47
+ **Live, in a window, no headset needed.** Screen capture through the same
48
+ pipeline the headset runs:
49
+
50
+ ```sh
51
+ cargo run --release --features capture --example viewer -- [model.safetensors] [monitor]
52
+ ```
53
+
54
+ `R` refits the colour basis (worth doing after pointing it at something
55
+ new), `Esc` quits. The window title reports display and inference rates.
56
+
57
+ This is the demo, and it is also how the passthrough-camera work gets
58
+ de-risked: capture loop, downscale, and asynchronous-frame structure are all
59
+ identical, so the Quest camera becomes one more `source::FrameSource` with
60
+ nothing around it changing.
61
+
62
+ Run the whole pipeline on desktop and look at the result — inference
63
+ worker, PCA fit, colour projection, everything but OpenXR:
64
+
65
+ ```sh
66
+ cargo run --release --example preview -- preview.png [model.safetensors]
67
+ ```
68
+
69
+ Without a checkpoint it uses synthetic weights. The colours are then
70
+ meaningless, but the *structure* is not: a ViT with random weights still
71
+ preserves patch identity, so the scene's layout must still show through. If
72
+ it comes out scrambled, patch extraction or token ordering is broken.
73
+
74
+ Desktop benchmark:
75
+
76
+ ```sh
77
+ cargo run --release --example bench -- 20
78
+ ```
79
+
80
+ Weights. Accept Meta's DINOv3 License on the canonical gated repository, then
81
+ download the exact upstream checkpoint:
82
+
83
+ ```sh
84
+ hf download facebook/dinov3-vits16-pretrain-lvd1689m model.safetensors \
85
+ --local-dir ref/
86
+ ```
87
+
88
+ The paper artifact records SHA-256
89
+ `4610ad75edef83e75afdebf162d148dc628045ea6cbb83d67d4708c709c4f91d`.
90
+
91
+ Semantic check — that the features actually separate content:
92
+
93
+ ```sh
94
+ DINOVISION_WEIGHTS=model.safetensors cargo test --release --test semantics -- --nocapture
95
+ ```
96
+
97
+ On a scene of four flat regions, every same-material patch pair must score
98
+ closer than every different-material pair (measured: worst within 0.984,
99
+ best across 0.959). This catches more than a numerical diff would: a
100
+ transposed projection, a mis-paired RoPE half, or a patch flattening in the
101
+ wrong channel order all leave magnitudes healthy while collapsing that
102
+ margin — and it needs no torch on the machine.
103
+
104
+ A strict numerical comparison against HuggingFace is also available, if you
105
+ have `torch`/`transformers`:
106
+
107
+ ```sh
108
+ python tools/dump_reference.py --out ref/
109
+ cargo run --release --example verify -- ref/
110
+ ```
111
+
112
+ On device. Needs `cargo-apk`, the `aarch64-linux-android` target, and
113
+ `ANDROID_HOME` / `ANDROID_NDK_HOME` set.
114
+
115
+ **Benchmarking: skip the APK.** Vulkan compute works from a plain adb shell
116
+ binary — no activity, no window, no compositor, and the headset does not
117
+ even need to be awake. This is much faster to iterate on than an APK, and it
118
+ measures compute without the window system anywhere near it:
119
+
120
+ ```sh
121
+ cargo build --release --target aarch64-linux-android --example bench
122
+ adb shell mkdir -p /data/local/tmp/dinovision
123
+ adb push target/aarch64-linux-android/release/examples/bench /data/local/tmp/dinovision/
124
+ adb shell "cd /data/local/tmp/dinovision && chmod 755 bench && RUST_LOG=info ./bench 5"
125
+ ```
126
+
127
+ (On Git Bash, prefix with `MSYS_NO_PATHCONV=1` or `/data/...` gets rewritten
128
+ to `C:/data/...`.)
129
+
130
+ The `android/` benchmark APK exists but is the wrong shape for Horizon OS —
131
+ see the note below.
132
+
133
+ The XR application:
134
+
135
+ ```sh
136
+ # Optional: real weights, and a writable directory for the plan cache.
137
+ adb shell mkdir -p /data/local/tmp/dinovision
138
+ adb push ref/model.safetensors /data/local/tmp/dinovision/
139
+
140
+ cargo apk run --manifest-path android-xr/Cargo.toml --release --no-logcat
141
+ adb logcat -v time | grep -E "dinovision|RustStdoutStderr"
142
+ ```
143
+
144
+ It falls back to synthetic weights if the checkpoint is absent, and logs
145
+ render and inference rates separately every five seconds.
146
+
147
+ **The headset must be worn**, and awake is not enough. `adb shell am start`
148
+ silently caches the launch instead of running it:
149
+
150
+ ```text
151
+ Launch is blocked because: a Reprojected OS dialog is currently showing
152
+ ```
153
+
154
+ That message is misleading. Check what is actually on top:
155
+
156
+ ```sh
157
+ adb shell dumpsys activity activities | grep topResumedActivity
158
+ ```
159
+
160
+ `com.oculus.os.vrlockscreen/.SensorLockActivity` means the proximity sensor
161
+ has locked the headset because nobody is wearing it. `mWakefulness=Awake`
162
+ does not help, `wm dismiss-keyguard` does nothing, and there is no adb-side
163
+ override — only putting the headset on clears it. With `mWakefulness=Asleep`
164
+ the OpenXR session additionally never reaches `READY`.
165
+
166
+ Two Horizon OS behaviours worth knowing, both learned the hard way:
167
+
168
+ * A plain 2D activity that never creates a surface — a headless compute
169
+ benchmark, say — is killed by the volumetric window manager with
170
+ `Timeout while requesting window placement`. Use the native binary above
171
+ instead.
172
+ * Without a hand-tracking declaration, launches are blocked behind
173
+ `LaunchCheckControllerRequiredDialogActivity` when no controllers are on.
174
+ `android-xr/Cargo.toml` declares `oculus.software.handtracking` as
175
+ optional to avoid it.
176
+
177
+ ## Architecture notes
178
+
179
+ ### One shared GPU context
180
+
181
+ The renderer and the network run on a **single**
182
+ `blade_graphics::Context`, created by `dinovision::init_context` and handed
183
+ to meganeura through `SessionConfig::gpu`. No external-memory interop is
184
+ involved: `Session::input_buffer` returns a `BufferPiece` that a render pass
185
+ can write to directly, and both sides share one device and queue.
186
+
187
+ This is why the root `Cargo.toml` pins the *exact* blade-graphics revision
188
+ meganeura depends on. Two resolved copies of the crate would make the
189
+ `Arc<Context>` types incompatible, and the error would be a confusing type
190
+ mismatch rather than anything about versions. **Bump both together.**
191
+
192
+ ### Threading, and the constraint that shapes it
193
+
194
+ The renderer runs free at the headset's refresh rate, always drawing the
195
+ most recent completed grid; inference runs on a worker thread and lands
196
+ whenever it lands. The display loop never waits synchronously for a model
197
+ result.
198
+
199
+ `blade_graphics::Context` is `Send + Sync`, so an `Arc<Context>` can be
200
+ shared. `meganeura::Session` is **not** `Send`, so the worker receives the
201
+ context and builds its session *in place* rather than being handed one.
202
+ `tests/threading.rs` pins this down.
203
+
204
+ The reason is narrower than it first appears, and fixable: the only
205
+ non-`Send` field anywhere in the chain is `ScratchBuffer::mapped`, a raw
206
+ pointer into a persistently mapped allocation. `Buffer` carries the same
207
+ kind of pointer and has `unsafe impl Send`/`Sync` already. Command buffers
208
+ are not thread-affine in Vulkan — the spec asks only that a command pool be
209
+ externally synchronized, which `&mut CommandEncoder` guarantees. A one-line
210
+ `unsafe impl Send for ScratchBuffer` in blade makes `Session` `Send`
211
+ (verified); the branch `command-encoder-send` carries it. The worker does
212
+ not need it, so it is not pulled in here.
213
+
214
+ Threading decouples CPU orchestration, not GPU occupancy: both threads submit
215
+ to the same Vulkan queue, so a long compute submission can still delay a
216
+ render submission behind it. The audited live-worn sweep measures that
217
+ co-tenancy effect with each eye counted separately; pre-audit rates are not
218
+ retained here.
219
+
220
+ The fix is now in meganeura: `Session::set_submission_chunks` spreads the
221
+ plan over several submissions so the renderer can interleave. The app
222
+ defaults to 12, roughly one per transformer layer.
223
+
224
+ That boundary needs no extra synchronization, which is the whole risk of
225
+ the change and worth stating explicitly: blade closes every command buffer
226
+ with a conservative global memory barrier, opens each new one assuming an
227
+ unknown prior producer, and a Vulkan pipeline barrier's scopes cover
228
+ commands submitted to the same queue *before and after* it — not merely
229
+ those in one command buffer. `meganeura/tests/submission_chunks.rs` checks
230
+ a deep dependent chain over several steps and requires chunked output to
231
+ match the single-submission result **exactly**, at 2, 3, 5, and 16 chunks.
232
+ The isolated benchmark reports the throughput cost beside the live sweep.
233
+
234
+ ### The roundtrip
235
+
236
+ `src/decoder.rs` reconstructs RGB from patch features — the thing the
237
+ project is named for. A plain convolutional upsampler, four ×2 stages from
238
+ `[384, 14, 14]` to `[3, 224, 224]`, about 1.3M parameters and ~0.9 GMAC, so
239
+ roughly a fifth of the encoder. No skip connections and nothing clever on
240
+ purpose: the question is what the *features* carry, and a decoder with its
241
+ own path to the input would answer a different one.
242
+
243
+ ```sh
244
+ cargo run --release --example train_decoder -- \
245
+ <dataset-dir-or-manifest> <model.safetensors> \
246
+ [steps] [images] [layers] [size] [seed] [output-dir]
247
+ cargo run --release --features capture --example viewer -- model.safetensors decoder.bin
248
+ ```
249
+
250
+ **The training is Meganeura's own**, which is half the point of the
251
+ exercise: `train::build` with `Mode::Training` runs the autodiff that turns
252
+ 47 forward nodes into a 122-node forward-plus-backward graph, `set_adam`
253
+ drives the optimizer, and each `session.step()` is forward, backward, and
254
+ parameter update in one submission. The same runtime that serves the model
255
+ on a headset trained the model on a desktop.
256
+
257
+ Training precomputes features once per image rather than re-encoding every
258
+ step — the encoder is frozen, so running it in the loop would cost ~30× the
259
+ decoder's forward pass for no gradient — and caches targets as u8, because
260
+ as f32 they are 900 KB an image and a few thousand no longer fit in RAM.
261
+
262
+ Expect softness. 14×14×384 is only about 2× the raw pixel count, and DINOv3
263
+ features are trained for semantic invariance, which means colour and texture
264
+ detail are discarded *by design*. Layout and dominant colour come back well;
265
+ fine detail does not. That is the result, not a defect in the decoder.
266
+
267
+ ### Features to pixels
268
+
269
+ The first display mode is the classic DINO visualization: project patch
270
+ features onto their top three principal components and read those off as
271
+ RGB. No trained decoder, one `[hidden, 3]` matmul, and semantically
272
+ meaningful — patches of the same object land on the same colour.
273
+
274
+ The basis is fitted **on device** from a captured frame rather than shipped
275
+ as an asset, because feature statistics depend on what the camera is looking
276
+ at. It is folded into the graph as a parameter, so the per-frame readback is
277
+ `tokens × 3` floats (~3 KB) instead of `tokens × 384` (~300 KB), and
278
+ refitting is a `set_parameter` call rather than a graph rebuild.
279
+
280
+ One case worth knowing about: when a component carries almost no variance —
281
+ a blank wall — normalizing it to full range would amplify numerical noise
282
+ into a psychedelic channel. Such components are mapped to flat mid-grey
283
+ instead.
284
+
285
+ ### Why the graph is hand-built
286
+
287
+ `meganeura::load_onnx` recognizes `Add`, `Gemm`, `MatMul`, and `Relu` — not
288
+ close to enough for a ViT. Every op DINOv3 needs already exists in the IR,
289
+ so `src/dinov3.rs` transcribes
290
+ `transformers/models/dinov3_vit/modeling_dinov3_vit.py` directly, following
291
+ the pattern of meganeura's own `src/models/`.
292
+
293
+ ### The three things that make DINOv3 not a plain ViT
294
+
295
+ 1. **No learned position embedding.** Position enters only as 2D axial RoPE
296
+ on Q and K inside every layer. The `pos_embed_rescale` in the config is
297
+ applied under `if self.training` in the reference, so inference ignores
298
+ it.
299
+ 2. **Prefix tokens.** A CLS token and 4 register tokens precede the patch
300
+ tokens, and RoPE deliberately skips them. Rather than split the token
301
+ dimension in the graph, the RoPE tables carry `(cos, sin) = (1, 0)` on
302
+ prefix rows, making the rotation an identity there.
303
+ 3. **LayerScale.** Each residual branch is scaled by a learned hidden-channel
304
+ vector. Meganeura's broadcast operator is NCHW-flat, so the token-major
305
+ matrix is transposed to hidden-major layout before `mul_per_channel` and
306
+ transposed back afterward. Exact-shape tests pin this convention.
307
+
308
+ Two conventions differ from the checkpoint and are fixed up in
309
+ `src/weights.rs`: `nn.Linear` stores `[out, in]` where meganeura's `matmul`
310
+ wants `[in, out]`, and the patch embedding is a 4D conv weight that gets
311
+ flattened and transposed. The checkpoint's `k_proj` genuinely has no bias
312
+ (`key_bias: false`).
313
+
314
+ Patch flattening order is **channel-major** (`c * patch_size² + ky *
315
+ patch_size + kx`) because that is how PyTorch's `[out, 3, k, k]` conv weight
316
+ flattens. Getting it wrong still runs and produces confident nonsense, so
317
+ `src/preprocess.rs` pins it with tests.
318
+
319
+ ## Evidence status
320
+
321
+ The original exploratory measurements are intentionally removed from this
322
+ README. An audit found that the encoder misapplied LayerScale after the first
323
+ token; the old decoder weights, reconstruction scores, and device timings are
324
+ therefore invalid. The corrected 1-, 3-, and 12-layer graphs pass an
325
+ independent Transformers comparison under predeclared numerical thresholds.
326
+
327
+ See [REPORT.md](REPORT.md) for the paper-facing case study,
328
+ [experiments/AUDIT.md](experiments/AUDIT.md) for rejected evidence, and
329
+ [experiments/README.md](experiments/README.md) for the frozen protocol. Only
330
+ machine-readable results produced after the LayerScale correction are
331
+ admissible.
332
+
333
+ ## Local dependency revisions
334
+
335
+ The workspace uses the sibling `../meganeura` checkout. Its Android branch
336
+ makes host-only Hub/tokenizer dependencies optional, adds Android
337
+ cross-compilation support, and exposes chunked plan submission so Blade
338
+ rendering can interleave with inference on a shared Vulkan queue. Artifact
339
+ metadata records both that checkout's full revision and the Blade revision
340
+ actually resolved by Cargo; the neighboring Blade working tree is not assumed
341
+ to be the selected dependency.
342
+
343
+ These revisions must be frozen as clean commits before a paper artifact cites
344
+ them. Until then, `tools/collect_artifact_metadata.ps1` stores the dirty-tree
345
+ patch and hashes every tracked or untracked, non-ignored source file.
346
+
347
+ ## Android toolchain
348
+
349
+ The camera path requires Android API 34 symbols and NDK r27. Paper builds set
350
+ both `ANDROID_NDK_ROOT` and the Android target linker to
351
+ `27.0.12077973`; NDK r23 is insufficient for the vendor-tag camera API.
352
+
353
+ ## Remaining paper run
354
+
355
+ 1. Complete three corrected decoder initializations on the frozen Imagenette
356
+ manifest and aggregate all 3,925 validation images per seed.
357
+ 2. Compare one fixed public frame across the RTX host and Quest before
358
+ admitting device timing.
359
+ 3. Run the isolated multi-process benchmark and live-worn chunk sweep with raw
360
+ samples and device-state snapshots.
361
+ 4. Freeze source revisions and publish the corrected decoder artifacts and
362
+ audit records without the gated encoder, Imagenette images, or private
363
+ headset captures.
environment/training-source/source-snapshot/REPORT.md ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DinoVision: host training and Android XR deployment with Meganeura
2
+
3
+ ## Merge recommendation
4
+
5
+ **Status: not yet ready to merge into the Meganeura paper.** The experiment
6
+ now has a defensible claim and an audited protocol, and the corrected DINOv3
7
+ encoder agrees with an independent Transformers implementation. Corrected
8
+ decoder replicates and fresh Quest measurements are still in progress. No
9
+ historical quality or device-performance number from the previous version of
10
+ this report should be cited.
11
+
12
+ The experiment is best used as a deployment case study, complementary to a
13
+ matched framework benchmark:
14
+
15
+ > Meganeura trains an image decoder through its graph autodiff and Adam path
16
+ > on an NVIDIA host, then compiles the decoder's forward path, joined to a
17
+ > frozen DINOv3 encoder, through the same graph compiler and Vulkan runtime in
18
+ > an Android XR application sharing Blade's graphics context and queue.
19
+
20
+ This demonstrates a useful span of the stack: transformer inference,
21
+ convolutional decoder training, parameter serialization, Android
22
+ cross-compilation, and compute/graphics co-tenancy. It does not establish
23
+ performance portability relative to PyTorch, capture-to-photon latency,
24
+ zero-copy camera integration, or a general image-generation result.
25
+
26
+ ## 1. System boundary
27
+
28
+ DinoVision converts each passthrough frame to a 224 by 224 RGB image, runs the
29
+ first three layers of DINOv3 ViT-S/16, decodes the 14 by 14 patch-feature grid
30
+ back to RGB, and displays the reconstruction in both eyes.
31
+
32
+ | Component | Training host | Android XR device |
33
+ |---|---|---|
34
+ | DINOv3 encoder | Frozen; run once per corpus image to cache features | Joined to the decoder in one inference graph |
35
+ | RGB decoder | Meganeura training graph, batch 8, autodiff and Adam | Same decoder builder and parameters, forward only, batch 1 |
36
+ | Compiler/runtime | Meganeura graph compiler and Vulkan runtime | Meganeura graph compiler and Vulkan runtime |
37
+ | GPU integration | Dedicated compute context | Blade graphics context and Vulkan queue shared with rendering |
38
+
39
+ The wording “same graph” would be too strong. Training caches frozen encoder
40
+ features and optimizes a decoder-only batch graph. Deployment builds a
41
+ batch-one joined encoder/decoder graph. The decoder construction, learned
42
+ parameters, IR operators, compiler, and runtime are shared across the two
43
+ paths.
44
+
45
+ The frozen encoder is the gated
46
+ `facebook/dinov3-vits16-pretrain-lvd1689m` checkpoint (SHA-256
47
+ `4610ad75edef83e75afdebf162d148dc628045ea6cbb83d67d4708c709c4f91d`).
48
+ At 224 pixels it produces 196 patch tokens plus one CLS and four register
49
+ tokens, each 384-dimensional. Only patch tokens enter the decoder.
50
+
51
+ The decoder has 2,012,547 trainable parameters. Four stages successively
52
+ upsample 14 to 28, 56, 112, and 224 pixels while reducing channels from 384
53
+ through 256, 128, 64, and 32. Each stage uses a 3 by 3 convolution, group
54
+ normalization, SiLU, and 2x upsampling; the first two stages include a second
55
+ blend convolution. A final 3 by 3 convolution and sigmoid produce RGB. Its
56
+ forward pass is 1.142 GMAC at 224 pixels.
57
+
58
+ ## 2. Correctness gate
59
+
60
+ The first audit run found that the previous application was not executing the
61
+ intended transformer. DinoVision passed a `[tokens, hidden]` matrix to
62
+ Meganeura's NCHW-flat `mul_per_channel` operator as if it broadcast over the
63
+ trailing hidden dimension. Consequently, only the first token received the
64
+ intended learned LayerScale update; residual branches for later tokens were
65
+ effectively suppressed. Visually plausible reconstructions and a semantic
66
+ smoke test did not expose this error.
67
+
68
+ The corrected graph transposes each residual branch to `[hidden, tokens]`,
69
+ flattens it for the NCHW operator, applies the learned hidden-coordinate gain
70
+ with all tokens as the spatial extent, reshapes, and transposes back. Exact
71
+ shape tests compare both attention and this LayerScale path against CPU
72
+ implementations.
73
+
74
+ An independent reference run uses Torch 2.13.0+cpu and Transformers 5.14.1.
75
+ Both implementations consume the same normalized f32 pixel tensor and exact
76
+ checkpoint. The acceptance thresholds were relative output L2 at most 0.01,
77
+ CLS cosine above 0.999, and every patch-token cosine above 0.999.
78
+
79
+ | Encoder depth | Relative L2 | CLS cosine | Worst patch cosine | Result |
80
+ |---:|---:|---:|---:|---|
81
+ | 1 layer | 0.000781 | 1.000000 | 0.999990 | pass |
82
+ | 3 layers (deployed) | 0.001403 | 1.000000 | 0.999995 | pass |
83
+ | 12 layers | 0.002253 | 0.999997 | 0.999996 | pass |
84
+
85
+ At the first layer, embeddings, LayerNorm, Q/K/V projections, and RoPE match
86
+ to the displayed six decimal places. The attention output differs by 0.005852
87
+ relative L2; learned LayerScale reduces the attention-residual difference to
88
+ 0.000566. The final three-layer error is comfortably inside the declared
89
+ threshold.
90
+
91
+ All decoder weights and device measurements produced before this correction
92
+ are invalid. They are excluded rather than “corrected” by reinterpretation.
93
+
94
+ ## 3. Host training and held-out reconstruction
95
+
96
+ The corrected experiment uses the public Imagenette-320 archive. Its archive
97
+ SHA-256 is
98
+ `569b4497c98db6dd29f335d1f109cf315fe127053cedf69010d047f0188e158c`.
99
+ An immutable manifest records every image path, byte count, SHA-256, source,
100
+ split, and leakage group; its SHA-256 is
101
+ `2071f89b2a7b077d7729641fc858e1225d057d11a797d16198c0d195d989bb89`.
102
+ The upstream train/validation boundary is preserved.
103
+
104
+ Each run trains on the same class-interleaved prefix of 2,500 training images,
105
+ exactly 250 from each of Imagenette's ten classes, for 12,000 batch-8 updates.
106
+ The objective is mean absolute error. Adam uses
107
+ beta1 0.9, beta2 0.999, epsilon 1e-8, and a learning rate that decays linearly
108
+ from 0.002 to 0.0001 over the first 95% of updates, then remains at 0.0001.
109
+ Seeds 0, 1, and 2 independently control initialization and data order. The
110
+ selected deployment artifact is seed 0 by prior rule, not the best validation
111
+ seed.
112
+
113
+ Every photograph is center-cropped to its largest square and resized to 224
114
+ by 224 with a Catmull-Rom filter; there is no stochastic augmentation. The
115
+ encoder input uses ImageNet RGB mean `[0.485, 0.456, 0.406]` and standard
116
+ deviation `[0.229, 0.224, 0.225]`. The decoder target is the same resized RGB8
117
+ image mapped to `[0,1]`, so preprocessing cannot create a train/evaluation
118
+ resolution mismatch.
119
+
120
+ Evaluation verifies every input hash and processes all 3,925 held-out
121
+ validation images. It retains per-image MSE, MAE, PSNR, and RGB SSIM. RGB SSIM
122
+ uses an 11 by 11 Gaussian window with sigma 1.5 over the valid region. Global
123
+ PSNR is computed from aggregate squared error; seed dispersion is reported
124
+ across independently initialized decoders.
125
+
126
+ **Corrected results are pending.** The former 20.94 dB claim was the mean PSNR
127
+ of six training images selected for a diagnostic strip and is not a
128
+ generalization result. Pre-fix held-out runs are also invalid because their
129
+ encoder was wrong.
130
+
131
+ Training wall times are retained diagnostically but excluded from performance
132
+ claims: the host clock and interactive workload were not controlled. The
133
+ experiment demonstrates functional host training, not competitive training
134
+ throughput.
135
+
136
+ ## 4. Device experiment
137
+
138
+ The deployment graph contains the corrected three-layer encoder, patch-token
139
+ to NCHW rearrangement, and decoder. Intermediate features remain on the GPU
140
+ inside this graph. The application creates one inference worker per eye; each
141
+ worker owns its Meganeura session. The render loop never waits for inference
142
+ and drops submissions while a worker is busy instead of queuing stale camera
143
+ frames.
144
+
145
+ Both inference workers and Blade rendering submit to one Vulkan queue. The
146
+ Meganeura branch adds `Session::set_submission_chunks(n)`, allowing a long
147
+ plan to be split so rendering can interleave between compute submissions.
148
+ This is the main systems question for the live experiment: chunking trades
149
+ some isolated throughput for queue fairness under graphics co-tenancy.
150
+
151
+ Fresh device results must satisfy two separate protocols:
152
+
153
+ 1. **Isolated native benchmark.** At least five untimed warmups, 20 retained
154
+ synchronized samples, median/IQR/min/max/raw samples, and three fresh
155
+ processes in one declared headset state.
156
+ 2. **Live-worn XR sweep.** `submission_chunks = 1, 2, 4, 8, 12, 16` at a fixed
157
+ inference interval, retaining every application JSON window, each eye's
158
+ update rate, render-submission rate, worker wall latency, thermal/power
159
+ snapshots, display configuration, and runtime toggles.
160
+
161
+ The host and Quest must also execute one fixed public RGB frame with identical
162
+ weights. Preprocessed patches, full encoder output, spatial decoder input, and
163
+ reconstruction are compared by relative L2, maximum absolute error, and
164
+ cosine similarity before any device timing is admitted.
165
+
166
+ **Corrected Quest correctness and timing results are pending.** Historical
167
+ figures such as 125 ms, 12.5 Hz, the claimed 45% elementwise share, f16
168
+ conclusions, and the 224-to-240 comparison must not appear in the paper unless
169
+ they are reproduced under the audited protocol on the corrected graph.
170
+
171
+ ## 5. What the case study can support
172
+
173
+ If the pending runs pass, DinoVision provides evidence for the following
174
+ Meganeura properties:
175
+
176
+ - The graph IR expresses a ViT with patch projection, learned prefix tokens,
177
+ axial RoPE, multi-head attention, LayerNorm, GELU MLPs, LayerScale, and
178
+ residual connections, as well as a convolutional upsampling decoder.
179
+ - The training stack differentiates and optimizes the decoder with Adam on an
180
+ NVIDIA Vulkan device; no decoder is trained or imported through PyTorch.
181
+ - The same decoder definition and serialized parameters execute through the
182
+ inference compiler/runtime on an Android Adreno device.
183
+ - Meganeura sessions can share Blade's Vulkan context and queue inside a live
184
+ OpenXR application, and chunked plan submission exposes a runtime mechanism
185
+ for compute/graphics co-tenancy.
186
+ - The runtime cross-compiles without pulling host-only Hub/tokenizer
187
+ dependencies into Android.
188
+
189
+ The strongest evidence is breadth and integration, not peak speed. This is an
190
+ application-level complement to controlled kernel or framework comparisons.
191
+
192
+ ## 6. Limitations
193
+
194
+ - No matched PyTorch-on-Android baseline is included. The experiment does not
195
+ estimate speedup over another framework.
196
+ - Decoder quality is measured on held-out photographs. Until independent
197
+ headset capture sessions are grouped and held out, no quantitative
198
+ camera-domain quality claim is supported.
199
+ - DINOv3 is trained for semantic invariance, not invertibility. Reconstruction
200
+ quality is a probe of retained information, not a measure of DINOv3's
201
+ intended task quality.
202
+ - Camera conversion, CPU patchification, GPU upload, output readback,
203
+ smoothing, and renderer upload are not zero-copy. Worker wall time is not
204
+ capture-to-photon latency.
205
+ - Stereo comfort and perceived stability are single-user observations unless
206
+ instrumented or evaluated with multiple participants.
207
+ - The source revisions are not citable until DinoVision changes are committed
208
+ and the Meganeura Android branch is frozen or merged. The Blade checkout in
209
+ the neighboring repository is not necessarily the revision selected by
210
+ Cargo; artifact metadata records the resolved dependency instead.
211
+
212
+ ## 7. Artifact and licensing
213
+
214
+ `experiments/README.md` defines the complete protocol and
215
+ `experiments/AUDIT.md` records rejected evidence. Machine-readable training,
216
+ quality, correctness, benchmark, and environment records are generated by the
217
+ tools in `tools/`; summaries are derived from those records rather than
218
+ manually transcribed.
219
+
220
+ The public Hugging Face repository will contain corrected decoder weights,
221
+ replicate metadata, held-out metrics, the public dataset manifest, and the
222
+ fixed correctness frame. It will not duplicate Meta's gated DINOv3 checkpoint,
223
+ Imagenette images, or private headset captures.
224
+
225
+ DinoVision source code is MIT-licensed. The encoder is governed by Meta's
226
+ DINOv3 License. Decoder weights are treated conservatively as DINOv3-derived:
227
+ their distribution includes that agreement and a prominent “Built with
228
+ DINOv3” notice, and the Meganeura paper must acknowledge DINOv3.
environment/training-source/source-snapshot/android-xr/src/lib.rs ADDED
@@ -0,0 +1,898 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! The DinoVision XR application.
2
+ //!
3
+ //! Renders the DINO feature view to both eyes at the headset's refresh
4
+ //! rate, while inference runs on a worker thread at whatever rate it can
5
+ //! manage. The renderer never waits for the encoder: it draws the most
6
+ //! recent completed grid, so a 40 ms inference does not become a 40 ms
7
+ //! frame.
8
+ //!
9
+ //! ```text
10
+ //! cargo apk run --manifest-path android-xr/Cargo.toml --release --no-logcat
11
+ //! adb logcat -v time | grep -E "dinovision|RustStdoutStderr"
12
+ //! ```
13
+ //!
14
+ //! Frames come from the passthrough camera when it opens, and from
15
+ //! `dinovision::source::TestPattern` when it does not — a missing runtime
16
+ //! permission or an older Horizon OS should degrade to something visible
17
+ //! rather than a black screen. The camera needs a grant that the manifest
18
+ //! alone does not provide:
19
+ //!
20
+ //! ```text
21
+ //! adb shell pm grant rust.dinovision_xr horizonos.permission.HEADSET_CAMERA
22
+ //! ```
23
+
24
+ #![cfg(target_os = "android")]
25
+
26
+ use std::sync::Arc;
27
+ use std::sync::atomic::{AtomicBool, Ordering};
28
+ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
29
+
30
+ use blade_graphics as gpu;
31
+ use dinovision::dinov3::Config;
32
+ use dinovision::inference::{self, Weights, Worker};
33
+ use dinovision::render::GridView;
34
+ use dinovision::source::{FrameSource, TestPattern};
35
+ use log::{info, warn};
36
+ use openxr as xr;
37
+
38
+ const VIEW_TYPE: xr::ViewConfigurationType = xr::ViewConfigurationType::PRIMARY_STEREO;
39
+ const MAX_EYES: usize = 2;
40
+
41
+ /// Where the app looks for weights pushed by `adb push`.
42
+ ///
43
+ /// Bundling 43 MB of f16 weights as an asset is the eventual answer; for
44
+ /// bring-up, a path outside the APK avoids a rebuild per weight change.
45
+ const WEIGHTS_PATH: &str = "/data/local/tmp/dinovision/model.safetensors";
46
+
47
+ /// Trained RGB decoder. When present the app shows a real reconstruction
48
+ /// instead of the PCA colouring — the roundtrip the project is for.
49
+ const DECODER_PATH: &str = "/data/local/tmp/dinovision/decoder.bin";
50
+
51
+ /// Encoder depth used with the decoder.
52
+ ///
53
+ /// Measured: against a 3-layer encoder the decoder reconstructs at 20.94 dB
54
+ /// versus 19.82 dB against the full 12, and the encoder runs 3.69× faster
55
+ /// (37.9 ms against 139.7 ms on this device). The deep layers build semantic
56
+ /// invariance by discarding colour and texture, which is exactly what a
57
+ /// reconstruction needs back. The decoder weights are trained for this depth
58
+ /// and are not valid at any other.
59
+ const RECONSTRUCTION_LAYERS: usize = 3;
60
+
61
+ const CAPTURE_SIZE: (i32, i32) = (1280, 960);
62
+
63
+ /// Show the camera frame directly instead of the DINO roundtrip, for
64
+ /// checking geometry independently of the model:
65
+ ///
66
+ /// ```text
67
+ /// adb shell touch /data/local/tmp/dinovision/raw_camera
68
+ /// ```
69
+ const RAW_CAMERA_PATH: &str = "/data/local/tmp/dinovision/raw_camera";
70
+
71
+ /// Dump camera frames to disk for retraining, one raw RGB file each:
72
+ ///
73
+ /// ```text
74
+ /// adb shell "echo room-a-01 > /data/local/tmp/dinovision/capture"
75
+ /// # …wear the headset and look around…
76
+ /// adb pull /sdcard/Android/data/rust.dinovision_xr/files/captures/room-a-01
77
+ /// ```
78
+ ///
79
+ /// The decoder has only ever been trained on clean, well-lit colour
80
+ /// photographs, while these cameras produce noisy, wide-angle, nearly
81
+ /// monochrome frames. That mismatch is the most likely single cause of poor
82
+ /// reconstruction on the headset, and no amount of architecture work
83
+ /// addresses it — the model has to see the distribution it will be used on.
84
+ /// The flag is read from `/data/local/tmp` — apps may read there — but the
85
+ /// frames go to the app's own external directory. SELinux forbids an
86
+ /// `untrusted_app` writing to `shell_data_file` no matter how permissive
87
+ /// the mode bits look:
88
+ ///
89
+ /// ```text
90
+ /// avc: denied { write } … tcontext=u:object_r:shell_data_file
91
+ /// ```
92
+ const CAPTURE_FLAG_PATH: &str = "/data/local/tmp/dinovision/capture";
93
+ const CAPTURE_DIR_BASE: &str = "/sdcard/Android/data/rust.dinovision_xr/files/captures";
94
+
95
+ fn capture_directory() -> Option<String> {
96
+ if !std::path::Path::new(CAPTURE_FLAG_PATH).exists() {
97
+ return None;
98
+ }
99
+ let requested = std::fs::read_to_string(CAPTURE_FLAG_PATH).unwrap_or_default();
100
+ let requested = requested.trim();
101
+ let session = if requested.is_empty() {
102
+ format!(
103
+ "session-{}",
104
+ SystemTime::now()
105
+ .duration_since(UNIX_EPOCH)
106
+ .unwrap_or_default()
107
+ .as_secs()
108
+ )
109
+ } else if requested
110
+ .chars()
111
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
112
+ {
113
+ requested.to_string()
114
+ } else {
115
+ warn!("invalid capture session {requested:?}; use only ASCII letters, digits, '-' and '_'");
116
+ return None;
117
+ };
118
+ let directory = format!("{CAPTURE_DIR_BASE}/{session}");
119
+ if std::fs::read_dir(&directory)
120
+ .ok()
121
+ .and_then(|mut entries| entries.next())
122
+ .is_some()
123
+ {
124
+ warn!("capture session directory is not empty; refusing to overwrite {directory}");
125
+ return None;
126
+ }
127
+ match std::fs::create_dir_all(&directory) {
128
+ Ok(()) => {
129
+ info!("capturing session {session:?} to {directory}");
130
+ Some(directory)
131
+ }
132
+ Err(error) => {
133
+ warn!("could not create capture session directory {directory}: {error}");
134
+ None
135
+ }
136
+ }
137
+ }
138
+
139
+ /// Frames to keep, and how many to skip between them.
140
+ ///
141
+ /// Consecutive frames of a barely-moving head are near-duplicates and teach
142
+ /// almost nothing, so sampling sparsely buys far more variety per byte. At
143
+ /// 224² RGB a frame is 150 KB, so this caps the dump around 225 MB.
144
+ const CAPTURE_LIMIT: usize = 1500;
145
+ const CAPTURE_STRIDE: u64 = 8;
146
+
147
+ /// Run one camera and one roundtrip, shown to both eyes:
148
+ ///
149
+ /// ```text
150
+ /// adb shell touch /data/local/tmp/dinovision/mono
151
+ /// ```
152
+ ///
153
+ /// Halves the work, since encoder and decoder both run once instead of
154
+ /// twice, and roughly doubles the update rate. The cost is that the right
155
+ /// eye sees the world from the left camera's position, so there is no
156
+ /// stereo depth — worth it when the question is latency rather than
157
+ /// whether the roundtrip fuses.
158
+ const MONO_PATH: &str = "/data/local/tmp/dinovision/mono";
159
+
160
+ /// How far each new result moves the displayed image. 1.0 shows the raw
161
+ /// result and flickers; lower is steadier but lags. At ~12 Hz inference,
162
+ /// 0.35 settles within a few frames while killing most of the jitter.
163
+ const SMOOTHING: f32 = 0.35;
164
+
165
+ /// Optional override for the camera image's vertical half-angle, in
166
+ /// degrees.
167
+ ///
168
+ /// Unset — the normal case — the image simply spans each eye's field of
169
+ /// view, which is what passthrough itself does with these cameras. Only
170
+ /// worth setting if the camera turns out to see meaningfully more or less
171
+ /// than the display shows:
172
+ ///
173
+ /// ```text
174
+ /// adb shell "echo 40 > /data/local/tmp/dinovision/camera_half_fov_deg"
175
+ /// ```
176
+ ///
177
+ /// The full frame is resampled rather than cropped, so the horizontal
178
+ /// extent follows from the sensor aspect: for a rectilinear lens the
179
+ /// tangents scale with the sensor dimensions, giving
180
+ /// `tan(hfov/2) = (w/h) · tan(vfov/2)`.
181
+ const HALF_FOV_PATH: &str = "/data/local/tmp/dinovision/camera_half_fov_deg";
182
+
183
+ fn read_half_fov() -> Option<f32> {
184
+ let deg = std::fs::read_to_string(HALF_FOV_PATH)
185
+ .ok()
186
+ .and_then(|s| s.trim().parse::<f32>().ok())?;
187
+ info!("camera half-FOV override: {deg} deg");
188
+ Some(deg.to_radians())
189
+ }
190
+
191
+ /// Minimum gap between inference submissions, in milliseconds, read from a
192
+ /// pushed file so it can be changed without rebuilding:
193
+ ///
194
+ /// ```text
195
+ /// adb shell "echo 300 > /data/local/tmp/dinovision/inference_interval_ms"
196
+ /// ```
197
+ ///
198
+ /// Inference and rendering share one Vulkan queue, so a long compute
199
+ /// submission delays the frame queued behind it. Left unthrottled the
200
+ /// worker resubmits the instant it finishes, so the GPU is never free and
201
+ /// the render loop is dragged down to the encoder's cadence. Raising this
202
+ /// trades feature-update rate for frame rate. A very large value disables
203
+ /// inference entirely, which is how the renderer's standalone cost is
204
+ /// measured.
205
+ const INTERVAL_PATH: &str = "/data/local/tmp/dinovision/inference_interval_ms";
206
+
207
+ /// How many submissions the encoder is split across, same override
208
+ /// mechanism as the interval.
209
+ const CHUNKS_PATH: &str = "/data/local/tmp/dinovision/submission_chunks";
210
+
211
+ /// One chunk per transformer layer, roughly. At ~135 ms for the whole
212
+ /// encoder that is ~11 ms of queue occupancy at a time, which fits inside a
213
+ /// 90 Hz frame budget instead of blowing through eleven of them.
214
+ const DEFAULT_CHUNKS: usize = 12;
215
+
216
+ fn read_chunks() -> usize {
217
+ let n = std::fs::read_to_string(CHUNKS_PATH)
218
+ .ok()
219
+ .and_then(|s| s.trim().parse::<usize>().ok())
220
+ .unwrap_or(DEFAULT_CHUNKS);
221
+ info!("submission chunks: {n}");
222
+ n
223
+ }
224
+
225
+ /// Measured on a Quest 3S at 224², where the encoder takes ~135 ms:
226
+ ///
227
+ /// | interval | render | inference |
228
+ /// |---------:|-------:|----------:|
229
+ /// | off | 90 Hz | — |
230
+ /// | 500 ms | 67 Hz | 2.0 Hz |
231
+ /// | 250 ms | 27 Hz | 3.7 Hz |
232
+ /// | 0 | 15 Hz | 7.6 Hz |
233
+ ///
234
+ /// Render rate tracks `(1 - duty cycle) × 90 Hz` fairly closely, which is
235
+ /// the whole story: throttling does not avoid the stall, it just chooses
236
+ /// how often to pay it. 500 ms keeps rendering comfortable, and the view
237
+ /// is nearly static anyway.
238
+ const DEFAULT_INTERVAL_MS: u64 = 500;
239
+
240
+ fn read_interval() -> Duration {
241
+ let ms = std::fs::read_to_string(INTERVAL_PATH)
242
+ .ok()
243
+ .and_then(|s| s.trim().parse::<u64>().ok())
244
+ .unwrap_or(DEFAULT_INTERVAL_MS);
245
+ info!("inference interval: {ms} ms");
246
+ Duration::from_millis(ms)
247
+ }
248
+
249
+ /// One eye's chain: its own camera, its own encode, its own image.
250
+ ///
251
+ /// Kept entirely separate per eye because the experiment is whether the
252
+ /// roundtrip survives stereo fusion. Sharing anything — one camera shown
253
+ /// twice, or one encode reused — would answer a different and easier
254
+ /// question.
255
+ struct EyePipeline {
256
+ source: Box<dyn FrameSource>,
257
+ worker: Worker,
258
+ view: GridView,
259
+ /// Exponentially smoothed image, damping per-frame feature jitter.
260
+ smoothed: Vec<f32>,
261
+ last_shown: u64,
262
+ last_submit: Instant,
263
+ eye: dinovision::camera::Eye,
264
+ /// Head orientation when the in-flight frame was captured, and when
265
+ /// the frame currently on screen was. The difference between the
266
+ /// latter and the live pose is what keeps the image world-locked.
267
+ pending_orientation: Option<[f32; 4]>,
268
+ shown_orientation: Option<[f32; 4]>,
269
+ }
270
+
271
+ struct App {
272
+ surface: gpu::XrSurface,
273
+ /// One per eye when both cameras open, otherwise a single shared chain
274
+ /// drawn to both.
275
+ eyes: Vec<EyePipeline>,
276
+ config: Config,
277
+ frames: u64,
278
+ last_report: Instant,
279
+ /// Completed results per eye pipeline in the current reporting window.
280
+ /// Keeping these separate avoids calling two asynchronous eye updates a
281
+ /// single "inference Hz" figure.
282
+ inference_counts: Vec<u64>,
283
+ /// Wall latency for every completed worker result in the current window.
284
+ inference_latencies_ms: Vec<Vec<f64>>,
285
+ /// Minimum gap between inference submissions. See [`INTERVAL_PATH`].
286
+ interval: Duration,
287
+ submission_chunks: usize,
288
+ camera_active: bool,
289
+ /// Bypass inference and show the camera frame. See [`RAW_CAMERA_PATH`].
290
+ raw_camera: bool,
291
+ raw_scratch: Vec<f32>,
292
+ /// Tangent of the image half-angle, per axis. See [`HALF_FOV_PATH`].
293
+ camera_half_tan: Option<[f32; 2]>,
294
+ logged_fov: bool,
295
+ /// Frames written so far, and the counter that strides between them.
296
+ captured: usize,
297
+ capture_tick: u64,
298
+ capturing: bool,
299
+ capture_dir: Option<String>,
300
+ /// Head orientation from the previous frame. Used when submitting, so
301
+ /// it is one frame stale — around 10 ms against the 100 ms the
302
+ /// inference itself takes, which is the lag that actually matters.
303
+ last_head: Option<[f32; 4]>,
304
+ }
305
+
306
+ impl App {
307
+ fn new(context: &Arc<gpu::Context>, config: Config) -> Self {
308
+ let surface = context
309
+ .create_xr_surface()
310
+ .expect("unable to create XR surface");
311
+
312
+ // A trained decoder turns this into the actual roundtrip, at a
313
+ // shallower and much cheaper encoder depth.
314
+ let decoder_path = std::path::PathBuf::from(DECODER_PATH);
315
+ let (config, display, view_grid) = if decoder_path.exists() {
316
+ info!("reconstructing with {}", decoder_path.display());
317
+ let config = config.with_layers(RECONSTRUCTION_LAYERS);
318
+ let grid = config.image_size;
319
+ (
320
+ config,
321
+ inference::Display::Reconstruction(decoder_path),
322
+ grid,
323
+ )
324
+ } else {
325
+ let grid = config.grid();
326
+ (config, inference::Display::PcaColour, grid)
327
+ };
328
+ // Raw camera frames are full resolution regardless of what the
329
+ // model would have produced.
330
+ let raw_camera = std::path::Path::new(RAW_CAMERA_PATH).exists();
331
+ if raw_camera {
332
+ info!("raw camera mode — inference bypassed");
333
+ }
334
+ let view_grid = if raw_camera {
335
+ config.image_size
336
+ } else {
337
+ view_grid
338
+ };
339
+
340
+ // Real weights if they have been pushed, synthetic otherwise, so
341
+ // the render path can be brought up before the checkpoint is on
342
+ // the device.
343
+ let weights_path = std::path::PathBuf::from(WEIGHTS_PATH);
344
+ let have_weights = weights_path.exists();
345
+ if have_weights {
346
+ info!("using weights from {}", weights_path.display());
347
+ } else {
348
+ warn!(
349
+ "no weights at {WEIGHTS_PATH} — running synthetic; \
350
+ adb push the checkpoint there for real features"
351
+ );
352
+ }
353
+
354
+ // One chain per eye if both cameras open. Two encodes cost twice the
355
+ // GPU, which is the price of the question being asked: whether a
356
+ // reconstructed world still fuses into a single stereo percept. A
357
+ // single camera shown to both eyes cannot answer it — that is
358
+ // monocular, and would look flat and offset no matter how good the
359
+ // reconstruction is.
360
+ let chunks = read_chunks();
361
+ let mono = std::path::Path::new(MONO_PATH).exists();
362
+ if mono {
363
+ info!("monocular: one camera and one roundtrip for both eyes");
364
+ }
365
+ let wanted: &[dinovision::camera::Eye] = if mono {
366
+ &[dinovision::camera::Eye::Left]
367
+ } else {
368
+ &[
369
+ dinovision::camera::Eye::Left,
370
+ dinovision::camera::Eye::Right,
371
+ ]
372
+ };
373
+ let mut sources: Vec<(dinovision::camera::Eye, Box<dyn FrameSource>)> = Vec::new();
374
+ for &eye in wanted {
375
+ match dinovision::camera::PassthroughCamera::new(config.image_size, eye, CAPTURE_SIZE) {
376
+ Ok(camera) => {
377
+ info!("{eye:?} camera open");
378
+ sources.push((eye, Box::new(camera)));
379
+ }
380
+ Err(e) => warn!("{eye:?} camera unavailable: {e}"),
381
+ }
382
+ }
383
+ if sources.is_empty() {
384
+ warn!(
385
+ "no camera opened; falling back to the test pattern. Grant it with: \
386
+ adb shell pm grant rust.dinovision_xr horizonos.permission.HEADSET_CAMERA"
387
+ );
388
+ sources.push((
389
+ dinovision::camera::Eye::Left,
390
+ Box::new(TestPattern::new(config.image_size)),
391
+ ));
392
+ }
393
+
394
+ let eyes: Vec<EyePipeline> = sources
395
+ .into_iter()
396
+ .map(|(eye, source)| EyePipeline {
397
+ source,
398
+ // No plan cache: two sessions would race on the same file.
399
+ worker: inference::spawn(
400
+ Arc::clone(context),
401
+ config.clone(),
402
+ if have_weights {
403
+ Weights::SafeTensors(weights_path.clone())
404
+ } else {
405
+ Weights::Synthetic
406
+ },
407
+ None,
408
+ chunks,
409
+ display.clone(),
410
+ ),
411
+ view: GridView::new(context, surface.format(), view_grid),
412
+ smoothed: Vec::new(),
413
+ last_shown: 0,
414
+ last_submit: Instant::now(),
415
+ eye,
416
+ pending_orientation: None,
417
+ shown_orientation: None,
418
+ })
419
+ .collect();
420
+ info!(
421
+ "{} eye pipeline(s) — {}",
422
+ eyes.len(),
423
+ if eyes.len() > 1 {
424
+ "stereo"
425
+ } else {
426
+ "monocular"
427
+ }
428
+ );
429
+ let eye_count = eyes.len();
430
+ let interval = read_interval();
431
+
432
+ let capture_dir = capture_directory();
433
+ Self {
434
+ surface,
435
+ eyes,
436
+ config,
437
+ frames: 0,
438
+ last_report: Instant::now(),
439
+ inference_counts: vec![0; eye_count],
440
+ inference_latencies_ms: vec![Vec::new(); eye_count],
441
+ interval,
442
+ submission_chunks: chunks,
443
+ camera_active: true,
444
+ raw_camera,
445
+ raw_scratch: Vec::new(),
446
+ last_head: None,
447
+ captured: 0,
448
+ capture_tick: 0,
449
+ capturing: capture_dir.is_some(),
450
+ capture_dir,
451
+ camera_half_tan: read_half_fov().map(|v| {
452
+ let t = v.tan();
453
+ let aspect = CAPTURE_SIZE.0 as f32 / CAPTURE_SIZE.1 as f32;
454
+ [t * aspect, t]
455
+ }),
456
+ logged_fov: false,
457
+ }
458
+ }
459
+
460
+ /// Acquire or release the camera as focus comes and goes.
461
+ ///
462
+ /// Android hands the camera to the foreground app and takes it back
463
+ /// otherwise; holding it while backgrounded is what produced
464
+ /// `ACameraDevice` error 3. Releasing on the way out also means the
465
+ /// headset camera stops streaming to an app the wearer has navigated
466
+ /// away from, which is the behaviour anyone would expect of it.
467
+ fn set_camera_active(&mut self, active: bool) {
468
+ if active == self.camera_active {
469
+ return;
470
+ }
471
+ self.camera_active = active;
472
+ let size = self.config.image_size;
473
+ for pipeline in &mut self.eyes {
474
+ if active {
475
+ match dinovision::camera::PassthroughCamera::new(size, pipeline.eye, CAPTURE_SIZE) {
476
+ Ok(camera) => {
477
+ info!("{:?} camera reacquired on focus", pipeline.eye);
478
+ pipeline.source = Box::new(camera);
479
+ }
480
+ Err(e) => warn!("could not reacquire the {:?} camera: {e}", pipeline.eye),
481
+ }
482
+ } else {
483
+ // Dropping the camera closes the device and stops the stream.
484
+ info!("releasing the {:?} camera on focus loss", pipeline.eye);
485
+ pipeline.source = Box::new(TestPattern::new(size));
486
+ }
487
+ }
488
+ }
489
+
490
+ fn render(&mut self, context: &gpu::Context, encoder: &mut gpu::CommandEncoder) {
491
+ let interval = self.interval;
492
+ let raw = self.raw_camera;
493
+ let config = self.config.clone();
494
+ let head = self.last_head;
495
+
496
+ // Snapshot frames for retraining, from the first eye only — the two
497
+ // cameras see nearly the same scene, so the second would mostly
498
+ // duplicate the first.
499
+ if self.capturing && self.captured < CAPTURE_LIMIT {
500
+ self.capture_tick += 1;
501
+ if self.capture_tick.is_multiple_of(CAPTURE_STRIDE)
502
+ && let Some(pipeline) = self.eyes.first_mut()
503
+ && let Some(rgb) = pipeline.source.next_frame()
504
+ {
505
+ let path = format!(
506
+ "{}/{:05}.rgb",
507
+ self.capture_dir.as_deref().expect("capture directory"),
508
+ self.captured
509
+ );
510
+ match std::fs::write(&path, rgb) {
511
+ Ok(()) => {
512
+ self.captured += 1;
513
+ if self.captured.is_multiple_of(100) {
514
+ info!("captured {} / {CAPTURE_LIMIT} frames", self.captured);
515
+ }
516
+ }
517
+ Err(e) => {
518
+ warn!("capture failed ({e}); stopping");
519
+ self.capturing = false;
520
+ }
521
+ }
522
+ }
523
+ }
524
+ for (pipeline_index, pipeline) in self.eyes.iter_mut().enumerate() {
525
+ if raw {
526
+ // Straight to the display, no model in the way, for judging
527
+ // geometry and render submission rate separately from the
528
+ // network. Keep this before worker submission: "raw" must
529
+ // not leave hidden inference contending for the same queue.
530
+ if let Some(rgb) = pipeline.source.next_frame() {
531
+ self.raw_scratch.clear();
532
+ self.raw_scratch
533
+ .extend(rgb.iter().map(|&b| b as f32 / 255.0));
534
+ pipeline.view.upload(context, &self.raw_scratch);
535
+ }
536
+ continue;
537
+ }
538
+
539
+ // Keep each worker fed. They report not-ready while busy, and
540
+ // frames offered meanwhile are dropped rather than queued — a
541
+ // queued frame would be stale by the time it ran.
542
+ if pipeline.worker.is_ready()
543
+ && pipeline.last_submit.elapsed() >= interval
544
+ && let Some(rgb) = pipeline.source.next_frame()
545
+ {
546
+ let patches = dinovision::preprocess::patches_from_rgb8(rgb, &config);
547
+ if pipeline.worker.submit(patches) {
548
+ pipeline.last_submit = Instant::now();
549
+ // Remember where the head was pointing, so the result
550
+ // can be put back in the world where it was seen.
551
+ pipeline.pending_orientation = head;
552
+ }
553
+ }
554
+
555
+ let generation = pipeline.worker.generation();
556
+ if generation != pipeline.last_shown
557
+ && let Some(grid) = pipeline.worker.latest()
558
+ {
559
+ // Blend towards the new frame rather than snapping to it.
560
+ // DINO features are sensitive enough that sensor noise and
561
+ // auto-exposure make individual patches jump between
562
+ // consecutive frames, which reads as constant flickering
563
+ // even with the head still.
564
+ let new = grid.patch_rgb();
565
+ if pipeline.smoothed.len() != new.len() {
566
+ pipeline.smoothed = new.to_vec();
567
+ } else {
568
+ for (s, &n) in pipeline.smoothed.iter_mut().zip(new) {
569
+ *s += (n - *s) * SMOOTHING;
570
+ }
571
+ }
572
+ pipeline.view.upload(context, &pipeline.smoothed);
573
+ pipeline.last_shown = generation;
574
+ pipeline.shown_orientation = pipeline.pending_orientation;
575
+ self.inference_counts[pipeline_index] += 1;
576
+ self.inference_latencies_ms[pipeline_index].push(grid.latency_ms);
577
+ }
578
+ }
579
+
580
+ let Some(frame) = self.surface.acquire_frame(context) else {
581
+ return;
582
+ };
583
+
584
+ encoder.start();
585
+ encoder.init_texture(frame.texture());
586
+
587
+ let eyes = frame.xr_view_count().min(MAX_EYES as u32);
588
+ for eye in 0..eyes {
589
+ // Each eye's frustum is asymmetric and differs from the other's,
590
+ // so the overlay has to be placed per eye. Skipping this is what
591
+ // made the two views refuse to fuse.
592
+ let xr_view = frame.xr_view(eye);
593
+ let fov = [
594
+ xr_view.fov.angle_left,
595
+ xr_view.fov.angle_right,
596
+ xr_view.fov.angle_up,
597
+ xr_view.fov.angle_down,
598
+ ];
599
+ if !self.logged_fov {
600
+ info!(
601
+ "eye {eye} frustum: L {:.1} R {:.1} U {:.1} D {:.1} deg \
602
+ (axis offset x {:.2} y {:.2})",
603
+ fov[0].to_degrees(),
604
+ fov[1].to_degrees(),
605
+ fov[2].to_degrees(),
606
+ fov[3].to_degrees(),
607
+ -(fov[1].tan() + fov[0].tan()) / (fov[1].tan() - fov[0].tan()),
608
+ -(fov[2].tan() + fov[3].tan()) / (fov[2].tan() - fov[3].tan()),
609
+ );
610
+ if eye + 1 == eyes {
611
+ self.logged_fov = true;
612
+ }
613
+ }
614
+ let now = xr_view.pose.orientation;
615
+ if eye == 0 {
616
+ self.last_head = Some(now);
617
+ }
618
+
619
+ // With two cameras each eye shows its own; with one, both show
620
+ // the same and the view is monocular.
621
+ let index = (eye as usize).min(self.eyes.len() - 1);
622
+
623
+ // Put the image back where the head was pointing when the
624
+ // camera saw it, so it holds still in the world while the view
625
+ // sweeps across it. Without this the picture is glued to the
626
+ // screen and drags a tenth of a second behind every turn.
627
+ let centre = self.eyes[index]
628
+ .shown_orientation
629
+ .and_then(|then| dinovision::render::reprojection_offset(then, now))
630
+ .unwrap_or([0.0, 0.0]);
631
+ self.eyes[index].view.set_transform(
632
+ context,
633
+ eye as usize,
634
+ // Same size as filling the buffer, but recentred on each
635
+ // eye's own axis. Still a 2D scale and offset; nothing is
636
+ // projected.
637
+ //
638
+ // The recentring is not optional, and this device says so
639
+ // with numbers. Its frusta are asymmetric and mirrored —
640
+ // left eye L -49.0 R +45.0, right eye L -45.0 R +49.0 — so
641
+ // an image filling the buffer has its centre at -2.0 deg in
642
+ // the left eye and +2.0 deg in the right. That is 4 deg of
643
+ // *divergence*, pulling the eyes apart, against a fusion
644
+ // limit of roughly 1 deg. It reads exactly as "the object
645
+ // is further left in my left eye" and it cannot converge.
646
+ match self.camera_half_tan {
647
+ Some(half_tan) => dinovision::render::EyeTransform::for_eye(fov, half_tan),
648
+ None => dinovision::render::EyeTransform::filling_at(fov, centre),
649
+ },
650
+ );
651
+
652
+ let mut pass = encoder.render(
653
+ "eye",
654
+ gpu::RenderTargetSet {
655
+ colors: &[gpu::RenderTarget {
656
+ view: frame.xr_texture_view(eye),
657
+ // The view covers every pixel, so clearing would only
658
+ // be wasted bandwidth on a tiler.
659
+ init_op: gpu::InitOp::Clear(gpu::TextureColor::OpaqueBlack),
660
+ finish_op: gpu::FinishOp::Store,
661
+ }],
662
+ depth_stencil: None,
663
+ },
664
+ );
665
+ self.eyes[index].view.draw(&mut pass, eye as usize);
666
+ }
667
+
668
+ encoder.present(frame);
669
+ let _sync_point = context.submit(encoder);
670
+ self.frames += 1;
671
+
672
+ if self.last_report.elapsed() >= Duration::from_secs(5) {
673
+ let secs = self.last_report.elapsed().as_secs_f64();
674
+ let render_hz = self.frames as f64 / secs;
675
+ let per_eye_hz: Vec<f64> = self
676
+ .inference_counts
677
+ .iter()
678
+ .map(|&count| count as f64 / secs)
679
+ .collect();
680
+ // This is the lower eye update rate, not a synchronization claim:
681
+ // the two cameras and workers are currently independent.
682
+ let min_eye_hz = per_eye_hz.iter().copied().fold(f64::INFINITY, f64::min);
683
+ let latencies: Vec<Option<f64>> = self
684
+ .eyes
685
+ .iter()
686
+ .map(|eye| eye.worker.latest().map(|grid| grid.latency_ms))
687
+ .collect();
688
+ info!(
689
+ "render {:.1} Hz | per-eye updates {:?} Hz | min-eye {:.1} Hz | worker latency {:?} ms",
690
+ render_hz, per_eye_hz, min_eye_hz, latencies,
691
+ );
692
+ let counts_json = self
693
+ .inference_counts
694
+ .iter()
695
+ .map(u64::to_string)
696
+ .collect::<Vec<_>>()
697
+ .join(",");
698
+ let rates_json = per_eye_hz
699
+ .iter()
700
+ .map(|value| format!("{value:.6}"))
701
+ .collect::<Vec<_>>()
702
+ .join(",");
703
+ let latency_json = latencies
704
+ .iter()
705
+ .map(|value| value.map_or_else(|| "null".into(), |v| format!("{v:.6}")))
706
+ .collect::<Vec<_>>()
707
+ .join(",");
708
+ let latency_samples_json = self
709
+ .inference_latencies_ms
710
+ .iter()
711
+ .map(|samples| {
712
+ format!(
713
+ "[{}]",
714
+ samples
715
+ .iter()
716
+ .map(|value| format!("{value:.6}"))
717
+ .collect::<Vec<_>>()
718
+ .join(",")
719
+ )
720
+ })
721
+ .collect::<Vec<_>>()
722
+ .join(",");
723
+ info!(
724
+ "DINOVISION_APP_JSON {{\"schema_version\":1,\"kind\":\"dinovision_app_window\",\
725
+ \"window_seconds\":{secs:.6},\"render_frames\":{},\"render_hz\":{render_hz:.6},\
726
+ \"per_eye_update_counts\":[{counts_json}],\"per_eye_update_hz\":[{rates_json}],\
727
+ \"min_eye_update_hz\":{min_eye_hz:.6},\"worker_latest_latency_ms\":[{latency_json}],\
728
+ \"worker_latency_samples_ms\":[{latency_samples_json}],\"submission_chunks\":{},\
729
+ \"inference_interval_ms\":{},\"raw_camera\":{}}}",
730
+ self.frames,
731
+ self.submission_chunks,
732
+ self.interval.as_millis(),
733
+ self.raw_camera,
734
+ );
735
+ self.frames = 0;
736
+ self.inference_counts.fill(0);
737
+ self.inference_latencies_ms.iter_mut().for_each(Vec::clear);
738
+ self.last_report = Instant::now();
739
+ }
740
+ }
741
+
742
+ fn destroy(mut self, context: &gpu::Context) {
743
+ // Drop the workers first: each holds an `Arc<Context>` and must
744
+ // finish any in-flight submission before the surface goes away.
745
+ for pipeline in self.eyes.drain(..) {
746
+ drop(pipeline.worker);
747
+ pipeline.view.destroy(context);
748
+ }
749
+ context.destroy_xr_surface(&mut self.surface);
750
+ }
751
+ }
752
+
753
+ fn spawn_event_pump() -> Arc<AtomicBool> {
754
+ let should_exit = Arc::new(AtomicBool::new(false));
755
+ let flag = Arc::clone(&should_exit);
756
+ std::thread::spawn(move || {
757
+ while let Some(event) = ndk_glue::poll_events() {
758
+ if matches!(event, ndk_glue::Event::Destroy) {
759
+ flag.store(true, Ordering::Relaxed);
760
+ break;
761
+ }
762
+ }
763
+ });
764
+ should_exit
765
+ }
766
+
767
+ #[ndk_glue::main]
768
+ pub fn main() {
769
+ android_logger::init_once(
770
+ android_logger::Config::default()
771
+ .with_max_level(log::LevelFilter::Info)
772
+ .with_tag("dinovision"),
773
+ );
774
+ std::panic::set_hook(Box::new(|info| log::error!("panic: {info}")));
775
+ info!("=== dinovision starting ===");
776
+
777
+ let entry = unsafe { xr::Entry::load().expect("no OpenXR loader") };
778
+ entry.initialize_android_loader().unwrap();
779
+
780
+ let available = entry.enumerate_extensions().unwrap();
781
+ assert!(
782
+ available.khr_vulkan_enable2,
783
+ "runtime lacks XR_KHR_vulkan_enable2"
784
+ );
785
+ let mut extensions = xr::ExtensionSet::default();
786
+ extensions.khr_vulkan_enable2 = true;
787
+ extensions.khr_android_create_instance = true;
788
+
789
+ let xr_instance = entry
790
+ .create_instance(
791
+ &xr::ApplicationInfo {
792
+ application_name: "DinoVision",
793
+ application_version: 0,
794
+ engine_name: "Blade",
795
+ engine_version: 0,
796
+ api_version: xr::Version::new(1, 0, 0),
797
+ },
798
+ &extensions,
799
+ &[],
800
+ )
801
+ .unwrap();
802
+ let system = xr_instance
803
+ .system(xr::FormFactor::HEAD_MOUNTED_DISPLAY)
804
+ .unwrap();
805
+
806
+ // One context, shared by the renderer and by meganeura. This is the
807
+ // whole reason the crate pins meganeura's blade revision.
808
+ let context = Arc::new(unsafe {
809
+ gpu::Context::init(gpu::ContextDesc {
810
+ xr: Some(gpu::XrDesc {
811
+ instance: xr_instance.clone(),
812
+ system_id: system,
813
+ }),
814
+ ..Default::default()
815
+ })
816
+ .expect("failed to initialize GPU context")
817
+ });
818
+ let info = context.device_information();
819
+ info!("GPU: {} ({})", info.device_name, info.driver_name);
820
+
821
+ // 224, measured. Desktop suggested 256 was nearly free (+15% time for
822
+ // +30% tokens) because it was dispatch-bound there. On the Adreno it is
823
+ // compute-bound, and 256 costs +51% — 85 ms versus 130 ms — for one
824
+ // extra row and column of features. Not worth it.
825
+ let config = Config::vits16().at_resolution(224);
826
+
827
+ let mut encoder = context.create_command_encoder(gpu::CommandEncoderDesc {
828
+ name: "dinovision",
829
+ buffer_count: 2,
830
+ manual_barriers: false,
831
+ });
832
+
833
+ let mut app: Option<App> = None;
834
+ let should_exit = spawn_event_pump();
835
+ let mut events = xr::EventDataBuffer::new();
836
+
837
+ 'main: loop {
838
+ if should_exit.load(Ordering::Relaxed) {
839
+ break 'main;
840
+ }
841
+
842
+ while let Some(event) = xr_instance.poll_event(&mut events).unwrap() {
843
+ use xr::Event::*;
844
+ match event {
845
+ SessionStateChanged(e) => {
846
+ info!("XR session state: {:?}", e.state());
847
+ match e.state() {
848
+ xr::SessionState::READY => {
849
+ if app.is_none() {
850
+ app = Some(App::new(&context, config.clone()));
851
+ }
852
+ context.xr_session().unwrap().begin(VIEW_TYPE).unwrap();
853
+ }
854
+ xr::SessionState::STOPPING => {
855
+ context.xr_session().unwrap().end().unwrap();
856
+ if let Some(app) = app.take() {
857
+ app.destroy(&context);
858
+ }
859
+ }
860
+ // Release the camera the moment we stop being the
861
+ // focused app. Android revokes it from background
862
+ // apps anyway — that is what error code 3 was — and
863
+ // holding it is both rude to whatever wants it next
864
+ // and a privacy question, since a headset camera
865
+ // should not keep streaming to an app the wearer has
866
+ // navigated away from.
867
+ xr::SessionState::VISIBLE | xr::SessionState::SYNCHRONIZED => {
868
+ if let Some(app) = app.as_mut() {
869
+ app.set_camera_active(false);
870
+ }
871
+ }
872
+ xr::SessionState::FOCUSED => {
873
+ if let Some(app) = app.as_mut() {
874
+ app.set_camera_active(true);
875
+ }
876
+ }
877
+ xr::SessionState::EXITING | xr::SessionState::LOSS_PENDING => break 'main,
878
+ _ => {}
879
+ }
880
+ }
881
+ InstanceLossPending(_) => break 'main,
882
+ _ => {}
883
+ }
884
+ }
885
+
886
+ match &mut app {
887
+ Some(app) => app.render(&context, &mut encoder),
888
+ // Not in session yet; idle rather than spin.
889
+ None => std::thread::sleep(Duration::from_millis(50)),
890
+ }
891
+ }
892
+
893
+ if let Some(app) = app.take() {
894
+ app.destroy(&context);
895
+ }
896
+ context.destroy_command_encoder(&mut encoder);
897
+ info!("=== dinovision stopped ===");
898
+ }
environment/training-source/source-snapshot/examples/bench.rs ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Desktop benchmark runner.
2
+ //!
3
+ //! The same [`dinovision::bench`] code runs on the Quest through the
4
+ //! `android` crate; running it here first gives a reference point from a
5
+ //! GPU that *does* have cooperative matrix, which makes the Adreno numbers
6
+ //! interpretable rather than just small.
7
+ //!
8
+ //! ```text
9
+ //! cargo run --release --example bench -- [iters]
10
+ //! DINOVISION_BENCH=shapes cargo run --release --example bench -- [iters]
11
+ //! ```
12
+ //!
13
+ //! With `DINOVISION_BENCH=shapes`, runs only the matmul shape sweep — the diagnostic for
14
+ //! what the register-tiled kernel is short of. It takes a couple of minutes
15
+ //! instead of the full suite's twenty, which matters when the target is a
16
+ //! headset on a flaky wireless adb link.
17
+
18
+ fn main() {
19
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
20
+
21
+ let iters = std::env::args()
22
+ .nth(1)
23
+ .and_then(|s| s.parse().ok())
24
+ .unwrap_or(20);
25
+ // Selected by environment, not by argument: `std::env::args()` does not
26
+ // survive the trip to an adb shell on Horizon OS, and silently running
27
+ // the wrong benchmark is worse than an ugly interface. `RUST_LOG`
28
+ // already proves the environment arrives intact.
29
+ let shapes_only = std::env::var("DINOVISION_BENCH").as_deref() == Ok("shapes");
30
+
31
+ let gpu = dinovision::init_context(None).expect("failed to initialize GPU context");
32
+ let results = if shapes_only {
33
+ dinovision::bench::describe_device(&gpu);
34
+ dinovision::bench::matmul_shapes(gpu, iters)
35
+ } else {
36
+ dinovision::bench::run_all(gpu, iters)
37
+ };
38
+
39
+ println!("\n{:-<80}", "");
40
+ for t in &results {
41
+ println!("{t}");
42
+ println!("DINOVISION_BENCH_JSON {}", t.json_line());
43
+ }
44
+ }
environment/training-source/source-snapshot/examples/common/mod.rs ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Dataset handling shared by the decoder trainer and evaluator.
2
+
3
+ #![allow(dead_code)] // Each example uses a different subset of these helpers.
4
+
5
+ use std::collections::HashMap;
6
+ use std::io::Read;
7
+ use std::path::{Path, PathBuf};
8
+
9
+ use serde::{Deserialize, Serialize};
10
+ use sha2::{Digest, Sha256};
11
+
12
+ #[derive(Debug, Clone, Serialize, Deserialize)]
13
+ pub struct DatasetManifest {
14
+ pub schema_version: u32,
15
+ pub name: String,
16
+ /// Relative to the manifest file unless absolute. May be overridden by
17
+ /// `DINOVISION_DATA_ROOT` on another machine.
18
+ pub root: PathBuf,
19
+ pub images: Vec<ManifestImage>,
20
+ }
21
+
22
+ #[derive(Debug, Clone, Serialize, Deserialize)]
23
+ pub struct ManifestImage {
24
+ pub path: PathBuf,
25
+ pub split: String,
26
+ pub source: String,
27
+ /// Capture session, photo collection, or other leakage boundary. A group
28
+ /// must occur in exactly one split.
29
+ pub group: String,
30
+ pub sha256: String,
31
+ pub bytes: u64,
32
+ }
33
+
34
+ pub struct LoadedManifest {
35
+ pub manifest: DatasetManifest,
36
+ pub root: PathBuf,
37
+ }
38
+
39
+ pub fn load_manifest(path: &Path) -> Result<LoadedManifest, Box<dyn std::error::Error>> {
40
+ let bytes = std::fs::read(path)?;
41
+ let manifest: DatasetManifest = serde_json::from_slice(&bytes)?;
42
+ if manifest.schema_version != 1 {
43
+ return Err(format!(
44
+ "{} uses unsupported manifest schema {}",
45
+ path.display(),
46
+ manifest.schema_version
47
+ )
48
+ .into());
49
+ }
50
+ let root = match std::env::var_os("DINOVISION_DATA_ROOT") {
51
+ Some(value) => PathBuf::from(value),
52
+ None if manifest.root.is_absolute() => manifest.root.clone(),
53
+ None => path
54
+ .parent()
55
+ .unwrap_or_else(|| Path::new("."))
56
+ .join(&manifest.root),
57
+ };
58
+
59
+ validate_manifest(&manifest)?;
60
+ Ok(LoadedManifest { manifest, root })
61
+ }
62
+
63
+ fn validate_manifest(manifest: &DatasetManifest) -> Result<(), Box<dyn std::error::Error>> {
64
+ if manifest.images.is_empty() {
65
+ return Err("dataset manifest contains no images".into());
66
+ }
67
+
68
+ let mut group_splits: HashMap<(&str, &str), &str> = HashMap::new();
69
+ let mut hashes: HashMap<&str, (&str, &Path)> = HashMap::new();
70
+ let mut paths: HashMap<&Path, &str> = HashMap::new();
71
+ for image in &manifest.images {
72
+ if image.split.is_empty()
73
+ || image.source.is_empty()
74
+ || image.group.is_empty()
75
+ || image.sha256.len() != 64
76
+ {
77
+ return Err(format!("invalid manifest entry for {}", image.path.display()).into());
78
+ }
79
+ let key = (image.source.as_str(), image.group.as_str());
80
+ if let Some(old) = group_splits.insert(key, image.split.as_str())
81
+ && old != image.split
82
+ {
83
+ return Err(format!(
84
+ "group {}/{} leaks across splits {old:?} and {:?}",
85
+ image.source, image.group, image.split
86
+ )
87
+ .into());
88
+ }
89
+ if let Some(old_split) = paths.insert(&image.path, image.split.as_str()) {
90
+ return Err(format!(
91
+ "path {} is listed more than once ({old_split} and {})",
92
+ image.path.display(),
93
+ image.split
94
+ )
95
+ .into());
96
+ }
97
+ if let Some((old_split, old_path)) =
98
+ hashes.insert(image.sha256.as_str(), (image.split.as_str(), &image.path))
99
+ {
100
+ return Err(format!(
101
+ "identical files occur more than once: {} ({old_split}) and {} ({})",
102
+ old_path.display(),
103
+ image.path.display(),
104
+ image.split
105
+ )
106
+ .into());
107
+ }
108
+ }
109
+ Ok(())
110
+ }
111
+
112
+ pub fn images_for_split(
113
+ path: &Path,
114
+ split: &str,
115
+ limit: usize,
116
+ ) -> Result<Vec<(PathBuf, ManifestImage)>, Box<dyn std::error::Error>> {
117
+ let loaded = load_manifest(path)?;
118
+ let mut result = Vec::new();
119
+ for image in loaded
120
+ .manifest
121
+ .images
122
+ .into_iter()
123
+ .filter(|image| image.split == split)
124
+ .take(limit)
125
+ {
126
+ result.push((loaded.root.join(&image.path), image));
127
+ }
128
+ if result.is_empty() {
129
+ return Err(format!("manifest has no images in split {split:?}").into());
130
+ }
131
+ Ok(result)
132
+ }
133
+
134
+ /// Collect image paths, round-robin across subdirectories.
135
+ pub fn find_images(root: &Path, limit: usize) -> Vec<PathBuf> {
136
+ let mut groups: Vec<Vec<PathBuf>> = Vec::new();
137
+ let mut stack = vec![root.to_path_buf()];
138
+ while let Some(dir) = stack.pop() {
139
+ let Ok(entries) = std::fs::read_dir(&dir) else {
140
+ continue;
141
+ };
142
+ let mut here = Vec::new();
143
+ let mut items: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
144
+ items.sort();
145
+ for path in items {
146
+ if path.is_dir() {
147
+ stack.push(path);
148
+ } else if matches!(
149
+ path.extension().and_then(|e| e.to_str()),
150
+ Some("JPEG" | "jpeg" | "jpg" | "png" | "rgb")
151
+ ) {
152
+ here.push(path);
153
+ }
154
+ }
155
+ if !here.is_empty() {
156
+ groups.push(here);
157
+ }
158
+ }
159
+ groups.sort();
160
+
161
+ let mut out = Vec::new();
162
+ let deepest = groups.iter().map(|g| g.len()).max().unwrap_or(0);
163
+ 'outer: for i in 0..deepest {
164
+ for group in &groups {
165
+ if let Some(path) = group.get(i) {
166
+ out.push(path.clone());
167
+ if out.len() >= limit {
168
+ break 'outer;
169
+ }
170
+ }
171
+ }
172
+ }
173
+ out
174
+ }
175
+
176
+ /// Load a photograph or a raw headset capture as interleaved RGB8.
177
+ pub fn load_frame(path: &Path, size: u32) -> Option<Vec<u8>> {
178
+ if path.extension().and_then(|e| e.to_str()) == Some("rgb") {
179
+ let bytes = std::fs::read(path).ok()?;
180
+ let expected = (size * size * 3) as usize;
181
+ if bytes.len() != expected {
182
+ log::warn!(
183
+ "{}: {} bytes, expected {expected} — captured at a different resolution?",
184
+ path.display(),
185
+ bytes.len()
186
+ );
187
+ return None;
188
+ }
189
+ return Some(bytes);
190
+ }
191
+ load_rgb(path, size)
192
+ }
193
+
194
+ fn load_rgb(path: &Path, size: u32) -> Option<Vec<u8>> {
195
+ let img = image::open(path).ok()?.to_rgb8();
196
+ let (w, h) = (img.width(), img.height());
197
+ let side = w.min(h);
198
+ let cropped = image::imageops::crop_imm(&img, (w - side) / 2, (h - side) / 2, side, side);
199
+ let resized = image::imageops::resize(
200
+ &cropped.to_image(),
201
+ size,
202
+ size,
203
+ image::imageops::FilterType::CatmullRom,
204
+ );
205
+ Some(resized.into_raw())
206
+ }
207
+
208
+ pub fn sha256(path: &Path) -> Result<String, std::io::Error> {
209
+ let mut file = std::fs::File::open(path)?;
210
+ let mut hasher = Sha256::new();
211
+ let mut buffer = vec![0u8; 1024 * 1024];
212
+ loop {
213
+ let n = file.read(&mut buffer)?;
214
+ if n == 0 {
215
+ break;
216
+ }
217
+ hasher.update(&buffer[..n]);
218
+ }
219
+ Ok(format!("{:x}", hasher.finalize()))
220
+ }
221
+
222
+ pub fn verify_image(path: &Path, image: &ManifestImage) -> Result<(), Box<dyn std::error::Error>> {
223
+ let metadata = std::fs::metadata(path)?;
224
+ if metadata.len() != image.bytes {
225
+ return Err(format!(
226
+ "{} is {} bytes; manifest records {}",
227
+ path.display(),
228
+ metadata.len(),
229
+ image.bytes
230
+ )
231
+ .into());
232
+ }
233
+ let digest = sha256(path)?;
234
+ if digest != image.sha256 {
235
+ return Err(format!(
236
+ "{} has SHA-256 {digest}; manifest records {}",
237
+ path.display(),
238
+ image.sha256
239
+ )
240
+ .into());
241
+ }
242
+ Ok(())
243
+ }
244
+
245
+ #[cfg(test)]
246
+ mod tests {
247
+ use super::*;
248
+
249
+ fn image(path: &str, split: &str, group: &str, hash: char) -> ManifestImage {
250
+ ManifestImage {
251
+ path: PathBuf::from(path),
252
+ split: split.to_string(),
253
+ source: "camera".to_string(),
254
+ group: group.to_string(),
255
+ sha256: std::iter::repeat_n(hash, 64).collect(),
256
+ bytes: 1,
257
+ }
258
+ }
259
+
260
+ fn manifest(images: Vec<ManifestImage>) -> DatasetManifest {
261
+ DatasetManifest {
262
+ schema_version: 1,
263
+ name: "test".to_string(),
264
+ root: PathBuf::from("."),
265
+ images,
266
+ }
267
+ }
268
+
269
+ #[test]
270
+ fn independent_groups_and_splits_are_valid() {
271
+ validate_manifest(&manifest(vec![
272
+ image("a.rgb", "train", "session-a", 'a'),
273
+ image("b.rgb", "test", "session-b", 'b'),
274
+ ]))
275
+ .unwrap();
276
+ }
277
+
278
+ #[test]
279
+ fn group_cannot_cross_a_split() {
280
+ let error = validate_manifest(&manifest(vec![
281
+ image("a.rgb", "train", "session-a", 'a'),
282
+ image("b.rgb", "test", "session-a", 'b'),
283
+ ]))
284
+ .unwrap_err()
285
+ .to_string();
286
+ assert!(error.contains("leaks across splits"), "{error}");
287
+ }
288
+
289
+ #[test]
290
+ fn duplicate_content_is_rejected_even_within_one_split() {
291
+ let error = validate_manifest(&manifest(vec![
292
+ image("a.rgb", "train", "session-a", 'a'),
293
+ image("b.rgb", "train", "session-a", 'a'),
294
+ ]))
295
+ .unwrap_err()
296
+ .to_string();
297
+ assert!(error.contains("identical files"), "{error}");
298
+ }
299
+
300
+ #[test]
301
+ fn duplicate_path_is_rejected() {
302
+ let error = validate_manifest(&manifest(vec![
303
+ image("a.rgb", "train", "session-a", 'a'),
304
+ image("a.rgb", "train", "session-a", 'b'),
305
+ ]))
306
+ .unwrap_err()
307
+ .to_string();
308
+ assert!(error.contains("listed more than once"), "{error}");
309
+ }
310
+ }
environment/training-source/source-snapshot/examples/evaluate_decoder.rs ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Evaluate a trained decoder on an immutable held-out dataset manifest.
2
+ //!
3
+ //! Unlike `train_decoder`, this program never updates parameters and never
4
+ //! samples from the training cache. It verifies every input hash and writes
5
+ //! per-image metrics so aggregate claims can be regenerated from raw data.
6
+ //!
7
+ //! ```text
8
+ //! cargo run --release --example evaluate_decoder -- \
9
+ //! --manifest experiments/dataset.json \
10
+ //! --model model.safetensors --decoder decoder.bin \
11
+ //! --split test --layers 3 --output artifacts/quality.json
12
+ //! ```
13
+
14
+ use std::path::{Path, PathBuf};
15
+ use std::time::{Instant, SystemTime, UNIX_EPOCH};
16
+
17
+ use dinovision::decoder;
18
+ use dinovision::dinov3::Config;
19
+ use meganeura::train::{Mode, SessionConfig};
20
+ use serde::Serialize;
21
+
22
+ mod common;
23
+
24
+ #[derive(Debug)]
25
+ struct Args {
26
+ manifest: PathBuf,
27
+ model: PathBuf,
28
+ decoder: PathBuf,
29
+ split: String,
30
+ layers: usize,
31
+ size: usize,
32
+ limit: usize,
33
+ output: PathBuf,
34
+ samples: usize,
35
+ }
36
+
37
+ fn require_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, String> {
38
+ args.next()
39
+ .ok_or_else(|| format!("{flag} requires a value"))
40
+ }
41
+
42
+ impl Args {
43
+ fn parse() -> Result<Self, String> {
44
+ let mut manifest = None;
45
+ let mut model = None;
46
+ let mut decoder = None;
47
+ let mut split = "test".to_string();
48
+ let mut layers = 3usize;
49
+ let mut size = 224usize;
50
+ let mut limit = usize::MAX;
51
+ let mut output = PathBuf::from("artifacts/quality.json");
52
+ let mut samples = 12usize;
53
+
54
+ let mut args = std::env::args().skip(1);
55
+ while let Some(flag) = args.next() {
56
+ match flag.as_str() {
57
+ "--manifest" => manifest = Some(PathBuf::from(require_value(&mut args, &flag)?)),
58
+ "--model" => model = Some(PathBuf::from(require_value(&mut args, &flag)?)),
59
+ "--decoder" => decoder = Some(PathBuf::from(require_value(&mut args, &flag)?)),
60
+ "--split" => split = require_value(&mut args, &flag)?,
61
+ "--layers" => {
62
+ layers = require_value(&mut args, &flag)?
63
+ .parse()
64
+ .map_err(|_| "invalid --layers")?
65
+ }
66
+ "--size" => {
67
+ size = require_value(&mut args, &flag)?
68
+ .parse()
69
+ .map_err(|_| "invalid --size")?
70
+ }
71
+ "--limit" => {
72
+ limit = require_value(&mut args, &flag)?
73
+ .parse()
74
+ .map_err(|_| "invalid --limit")?
75
+ }
76
+ "--output" => output = PathBuf::from(require_value(&mut args, &flag)?),
77
+ "--samples" => {
78
+ samples = require_value(&mut args, &flag)?
79
+ .parse()
80
+ .map_err(|_| "invalid --samples")?
81
+ }
82
+ "-h" | "--help" => {
83
+ return Err(
84
+ "usage: evaluate_decoder --manifest FILE --model FILE --decoder FILE \
85
+ [--split test] [--layers 3] [--size 224] [--limit N] \
86
+ [--output FILE] [--samples N]"
87
+ .to_string(),
88
+ );
89
+ }
90
+ _ => return Err(format!("unknown argument {flag:?}")),
91
+ }
92
+ }
93
+
94
+ Ok(Self {
95
+ manifest: manifest.ok_or("--manifest is required")?,
96
+ model: model.ok_or("--model is required")?,
97
+ decoder: decoder.ok_or("--decoder is required")?,
98
+ split,
99
+ layers,
100
+ size,
101
+ limit,
102
+ output,
103
+ samples,
104
+ })
105
+ }
106
+ }
107
+
108
+ #[derive(Serialize)]
109
+ struct PerImage {
110
+ path: String,
111
+ source: String,
112
+ group: String,
113
+ sha256: String,
114
+ pixels: usize,
115
+ mse: f64,
116
+ mae: f64,
117
+ psnr_db: f64,
118
+ ssim: f64,
119
+ }
120
+
121
+ #[derive(Serialize)]
122
+ struct Distribution {
123
+ mean: f64,
124
+ median: f64,
125
+ p25: f64,
126
+ p75: f64,
127
+ min: f64,
128
+ max: f64,
129
+ }
130
+
131
+ #[derive(Serialize)]
132
+ struct Summary {
133
+ count: usize,
134
+ global_psnr_db: f64,
135
+ psnr_db: Distribution,
136
+ ssim: Distribution,
137
+ mae: Distribution,
138
+ }
139
+
140
+ #[derive(Serialize)]
141
+ struct Evaluation {
142
+ schema_version: u32,
143
+ created_unix_seconds: u64,
144
+ dataset_name: String,
145
+ manifest: String,
146
+ manifest_sha256: String,
147
+ split: String,
148
+ image_size: usize,
149
+ encoder_layers: usize,
150
+ model: String,
151
+ model_sha256: String,
152
+ decoder: String,
153
+ decoder_sha256: String,
154
+ elapsed_seconds: f64,
155
+ summary: Summary,
156
+ images: Vec<PerImage>,
157
+ }
158
+
159
+ fn distribution(values: impl IntoIterator<Item = f64>) -> Distribution {
160
+ let mut values: Vec<f64> = values.into_iter().collect();
161
+ values.sort_by(f64::total_cmp);
162
+ let n = values.len();
163
+ assert!(n > 0);
164
+ let quantile = |q: f64| {
165
+ let at = q * (n - 1) as f64;
166
+ let lo = at.floor() as usize;
167
+ let hi = at.ceil() as usize;
168
+ values[lo] + (values[hi] - values[lo]) * (at - lo as f64)
169
+ };
170
+ Distribution {
171
+ mean: values.iter().sum::<f64>() / n as f64,
172
+ median: quantile(0.5),
173
+ p25: quantile(0.25),
174
+ p75: quantile(0.75),
175
+ min: values[0],
176
+ max: values[n - 1],
177
+ }
178
+ }
179
+
180
+ fn target_chw(rgb: &[u8], size: usize) -> Vec<f32> {
181
+ let pixels = size * size;
182
+ let mut target = vec![0.0f32; 3 * pixels];
183
+ for c in 0..3 {
184
+ for p in 0..pixels {
185
+ target[c * pixels + p] = rgb[p * 3 + c] as f32 / 255.0;
186
+ }
187
+ }
188
+ target
189
+ }
190
+
191
+ fn metrics(recon: &[f32], target: &[f32], size: usize) -> (f64, f64, f64, f64) {
192
+ let mut squared = 0.0f64;
193
+ let mut absolute = 0.0f64;
194
+ for (&a, &b) in recon.iter().zip(target) {
195
+ let d = (a - b) as f64;
196
+ squared += d * d;
197
+ absolute += d.abs();
198
+ }
199
+ let mse = squared / recon.len() as f64;
200
+ let mae = absolute / recon.len() as f64;
201
+ let psnr = 10.0 * (1.0 / mse).log10();
202
+ (mse, mae, psnr, ssim_rgb(recon, target, size))
203
+ }
204
+
205
+ /// RGB SSIM with the conventional 11x11 Gaussian window (sigma 1.5),
206
+ /// computed independently per channel over the valid image region.
207
+ fn ssim_rgb(a: &[f32], b: &[f32], size: usize) -> f64 {
208
+ const RADIUS: usize = 5;
209
+ const SIGMA: f64 = 1.5;
210
+ const C1: f64 = 0.01 * 0.01;
211
+ const C2: f64 = 0.03 * 0.03;
212
+ assert!(size > 2 * RADIUS);
213
+ let mut kernel = [0.0f64; 2 * RADIUS + 1];
214
+ let mut kernel_sum = 0.0;
215
+ for (index, weight) in kernel.iter_mut().enumerate() {
216
+ let x = index as isize - RADIUS as isize;
217
+ *weight = (-(x * x) as f64 / (2.0 * SIGMA * SIGMA)).exp();
218
+ kernel_sum += *weight;
219
+ }
220
+ for w in &mut kernel {
221
+ *w /= kernel_sum;
222
+ }
223
+
224
+ let plane = size * size;
225
+ let valid = size - 2 * RADIUS;
226
+ let mut total = 0.0;
227
+ let mut count = 0usize;
228
+ for c in 0..3 {
229
+ // Five Gaussian-filtered moments, stored together to reuse both the
230
+ // input reads and kernel coefficients. The separable implementation
231
+ // is mathematically equivalent to the 11x11 2-D Gaussian window.
232
+ let mut horizontal = vec![[0.0f64; 5]; size * valid];
233
+ for y in 0..size {
234
+ for x in 0..valid {
235
+ let mut moments = [0.0f64; 5];
236
+ for (kx, &weight) in kernel.iter().enumerate() {
237
+ let index = c * plane + y * size + x + kx;
238
+ let va = a[index] as f64;
239
+ let vb = b[index] as f64;
240
+ moments[0] += weight * va;
241
+ moments[1] += weight * vb;
242
+ moments[2] += weight * va * va;
243
+ moments[3] += weight * vb * vb;
244
+ moments[4] += weight * va * vb;
245
+ }
246
+ horizontal[y * valid + x] = moments;
247
+ }
248
+ }
249
+ for y in 0..valid {
250
+ for x in 0..valid {
251
+ let mut moments = [0.0f64; 5];
252
+ for (ky, &weight) in kernel.iter().enumerate() {
253
+ for i in 0..5 {
254
+ moments[i] += weight * horizontal[(y + ky) * valid + x][i];
255
+ }
256
+ }
257
+ let [mean_a, mean_b, aa, bb, ab] = moments;
258
+ let var_a = (aa - mean_a * mean_a).max(0.0);
259
+ let var_b = (bb - mean_b * mean_b).max(0.0);
260
+ let covariance = ab - mean_a * mean_b;
261
+ total += ((2.0 * mean_a * mean_b + C1) * (2.0 * covariance + C2))
262
+ / ((mean_a * mean_a + mean_b * mean_b + C1) * (var_a + var_b + C2));
263
+ count += 1;
264
+ }
265
+ }
266
+ }
267
+ total / count as f64
268
+ }
269
+
270
+ fn save_sample(
271
+ path: &Path,
272
+ rgb: &[u8],
273
+ patches: &[f32],
274
+ encoder_output: &[f32],
275
+ features: &[f32],
276
+ recon: &[f32],
277
+ size: usize,
278
+ ) {
279
+ let mut image = image::RgbImage::new((2 * size) as u32, size as u32);
280
+ let pixels = size * size;
281
+ for y in 0..size {
282
+ for x in 0..size {
283
+ let p = y * size + x;
284
+ image.put_pixel(
285
+ x as u32,
286
+ y as u32,
287
+ image::Rgb([rgb[p * 3], rgb[p * 3 + 1], rgb[p * 3 + 2]]),
288
+ );
289
+ let value = |c: usize| (recon[c * pixels + p].clamp(0.0, 1.0) * 255.0) as u8;
290
+ image.put_pixel(
291
+ (size + x) as u32,
292
+ y as u32,
293
+ image::Rgb([value(0), value(1), value(2)]),
294
+ );
295
+ }
296
+ }
297
+ image.save(path).expect("write reconstruction sample");
298
+ let stem = path
299
+ .file_stem()
300
+ .expect("sample path has a stem")
301
+ .to_string_lossy();
302
+ let parent = path.parent().unwrap_or_else(|| Path::new("."));
303
+ std::fs::write(
304
+ parent.join(format!("{stem}-patches.f32")),
305
+ bytemuck::cast_slice(patches),
306
+ )
307
+ .expect("write preprocessed patches");
308
+ std::fs::write(
309
+ parent.join(format!("{stem}-encoder.f32")),
310
+ bytemuck::cast_slice(encoder_output),
311
+ )
312
+ .expect("write raw encoder output");
313
+ std::fs::write(
314
+ parent.join(format!("{stem}-features.f32")),
315
+ bytemuck::cast_slice(features),
316
+ )
317
+ .expect("write raw encoder features");
318
+ std::fs::write(
319
+ parent.join(format!("{stem}-reconstruction.f32")),
320
+ bytemuck::cast_slice(recon),
321
+ )
322
+ .expect("write raw reconstruction sample");
323
+ }
324
+
325
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
326
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
327
+ let args = Args::parse().map_err(|e| format!("{e}"))?;
328
+ let loaded = common::load_manifest(&args.manifest)?;
329
+ let dataset_name = loaded.manifest.name.clone();
330
+ let entries = common::images_for_split(&args.manifest, &args.split, args.limit)?;
331
+ log::info!(
332
+ "evaluating {} images from split {:?}",
333
+ entries.len(),
334
+ args.split
335
+ );
336
+
337
+ let config = Config::vits16()
338
+ .at_resolution(args.size)
339
+ .with_layers(args.layers);
340
+ let gpu = dinovision::init_context(None).expect("GPU context");
341
+ let (mut encoder, _) = dinovision::bench::build_encoder_session(gpu.clone(), &config, None);
342
+ let model = meganeura::data::safetensors::SafeTensorsModel::load(args.model.clone())?;
343
+ dinovision::weights::load_encoder(&mut encoder, &model, &config)?;
344
+
345
+ let feat_len = config.hidden_size * config.num_patches();
346
+ let img_len = 3 * config.image_size * config.image_size;
347
+ let mut graph = meganeura::Graph::new();
348
+ let features = graph.input("feat", &[feat_len]);
349
+ let reconstruction = decoder::build_decoder(&mut graph, &config, features, 1);
350
+ graph.set_outputs(vec![reconstruction]);
351
+ let (mut decoder_session, _) = meganeura::train::build(
352
+ &graph,
353
+ SessionConfig {
354
+ mode: Mode::Inference,
355
+ gpu: Some(gpu),
356
+ ..Default::default()
357
+ },
358
+ );
359
+ decoder::load_parameters(&mut decoder_session, &graph, &args.decoder)?;
360
+
361
+ let sample_dir = args
362
+ .output
363
+ .parent()
364
+ .unwrap_or_else(|| Path::new("."))
365
+ .join("quality_samples");
366
+ if args.samples > 0 {
367
+ std::fs::create_dir_all(&sample_dir)?;
368
+ }
369
+ if let Some(parent) = args.output.parent()
370
+ && !parent.as_os_str().is_empty()
371
+ {
372
+ std::fs::create_dir_all(parent)?;
373
+ }
374
+
375
+ let start = Instant::now();
376
+ let total_entries = entries.len();
377
+ let mut encoder_out = vec![0.0f32; config.num_tokens() * config.hidden_size];
378
+ let mut recon = vec![0.0f32; img_len];
379
+ let mut results = Vec::new();
380
+ let mut total_squared_error = 0.0f64;
381
+ let mut total_values = 0usize;
382
+
383
+ for (index, (path, entry)) in entries.into_iter().enumerate() {
384
+ common::verify_image(&path, &entry)?;
385
+ let rgb = common::load_frame(&path, config.image_size as u32)
386
+ .ok_or_else(|| format!("failed to load {}", path.display()))?;
387
+ let target = target_chw(&rgb, config.image_size);
388
+ let patches = dinovision::preprocess::patches_from_rgb8(&rgb, &config);
389
+ encoder.set_input("patches", &patches);
390
+ encoder.step();
391
+ encoder.wait();
392
+ encoder.read_output_by_index(0, &mut encoder_out);
393
+ let features = decoder::patch_features_to_nchw(&encoder_out, &config);
394
+
395
+ decoder_session.set_input("feat", &features);
396
+ decoder_session.step();
397
+ decoder_session.wait();
398
+ decoder_session.read_output_by_index(0, &mut recon);
399
+
400
+ let (mse, mae, psnr_db, ssim) = metrics(&recon, &target, config.image_size);
401
+ total_squared_error += mse * recon.len() as f64;
402
+ total_values += recon.len();
403
+ results.push(PerImage {
404
+ path: entry.path.to_string_lossy().replace('\\', "/"),
405
+ source: entry.source,
406
+ group: entry.group,
407
+ sha256: entry.sha256,
408
+ pixels: config.image_size * config.image_size,
409
+ mse,
410
+ mae,
411
+ psnr_db,
412
+ ssim,
413
+ });
414
+
415
+ if index < args.samples {
416
+ save_sample(
417
+ &sample_dir.join(format!("{index:04}.png")),
418
+ &rgb,
419
+ &patches,
420
+ &encoder_out,
421
+ &features,
422
+ &recon,
423
+ config.image_size,
424
+ );
425
+ }
426
+ log::info!(
427
+ "{}/{} PSNR {:.2} dB SSIM {:.4} {}",
428
+ index + 1,
429
+ total_entries,
430
+ psnr_db,
431
+ ssim,
432
+ path.display()
433
+ );
434
+ }
435
+
436
+ let global_mse = total_squared_error / total_values as f64;
437
+ let summary = Summary {
438
+ count: results.len(),
439
+ global_psnr_db: 10.0 * (1.0 / global_mse).log10(),
440
+ psnr_db: distribution(results.iter().map(|x| x.psnr_db)),
441
+ ssim: distribution(results.iter().map(|x| x.ssim)),
442
+ mae: distribution(results.iter().map(|x| x.mae)),
443
+ };
444
+ let evaluation = Evaluation {
445
+ schema_version: 1,
446
+ created_unix_seconds: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
447
+ dataset_name,
448
+ manifest: args.manifest.to_string_lossy().replace('\\', "/"),
449
+ manifest_sha256: common::sha256(&args.manifest)?,
450
+ split: args.split,
451
+ image_size: config.image_size,
452
+ encoder_layers: config.num_hidden_layers,
453
+ model: args.model.to_string_lossy().replace('\\', "/"),
454
+ model_sha256: common::sha256(&args.model)?,
455
+ decoder: args.decoder.to_string_lossy().replace('\\', "/"),
456
+ decoder_sha256: common::sha256(&args.decoder)?,
457
+ elapsed_seconds: start.elapsed().as_secs_f64(),
458
+ summary,
459
+ images: results,
460
+ };
461
+ std::fs::write(&args.output, serde_json::to_vec_pretty(&evaluation)?)?;
462
+ println!(
463
+ "wrote {}: {} images, global PSNR {:.2} dB, median SSIM {:.4}",
464
+ args.output.display(),
465
+ evaluation.summary.count,
466
+ evaluation.summary.global_psnr_db,
467
+ evaluation.summary.ssim.median
468
+ );
469
+ Ok(())
470
+ }
471
+
472
+ #[cfg(test)]
473
+ mod tests {
474
+ use super::*;
475
+
476
+ fn reference_ssim(a: &[f32], b: &[f32], size: usize) -> f64 {
477
+ const RADIUS: isize = 5;
478
+ const SIGMA: f64 = 1.5;
479
+ const C1: f64 = 0.01 * 0.01;
480
+ const C2: f64 = 0.03 * 0.03;
481
+ let mut kernel = Vec::new();
482
+ let mut sum = 0.0;
483
+ for y in -RADIUS..=RADIUS {
484
+ for x in -RADIUS..=RADIUS {
485
+ let weight = (-((x * x + y * y) as f64) / (2.0 * SIGMA * SIGMA)).exp();
486
+ kernel.push(weight);
487
+ sum += weight;
488
+ }
489
+ }
490
+ kernel.iter_mut().for_each(|weight| *weight /= sum);
491
+
492
+ let plane = size * size;
493
+ let mut total = 0.0;
494
+ let mut count = 0;
495
+ for c in 0..3 {
496
+ for y in RADIUS as usize..size - RADIUS as usize {
497
+ for x in RADIUS as usize..size - RADIUS as usize {
498
+ let mut moments = [0.0f64; 5];
499
+ let mut wi = 0;
500
+ for ky in -RADIUS..=RADIUS {
501
+ for kx in -RADIUS..=RADIUS {
502
+ let index = c * plane
503
+ + (y as isize + ky) as usize * size
504
+ + (x as isize + kx) as usize;
505
+ let va = a[index] as f64;
506
+ let vb = b[index] as f64;
507
+ let weight = kernel[wi];
508
+ moments[0] += weight * va;
509
+ moments[1] += weight * vb;
510
+ moments[2] += weight * va * va;
511
+ moments[3] += weight * vb * vb;
512
+ moments[4] += weight * va * vb;
513
+ wi += 1;
514
+ }
515
+ }
516
+ let [mean_a, mean_b, aa, bb, ab] = moments;
517
+ let var_a = (aa - mean_a * mean_a).max(0.0);
518
+ let var_b = (bb - mean_b * mean_b).max(0.0);
519
+ let covariance = ab - mean_a * mean_b;
520
+ total += ((2.0 * mean_a * mean_b + C1) * (2.0 * covariance + C2))
521
+ / ((mean_a * mean_a + mean_b * mean_b + C1) * (var_a + var_b + C2));
522
+ count += 1;
523
+ }
524
+ }
525
+ }
526
+ total / count as f64
527
+ }
528
+
529
+ #[test]
530
+ fn identical_images_have_unit_ssim() {
531
+ let size = 16;
532
+ let image: Vec<f32> = (0..3 * size * size)
533
+ .map(|i| (i % 251) as f32 / 250.0)
534
+ .collect();
535
+ assert!((ssim_rgb(&image, &image, size) - 1.0).abs() < 1e-10);
536
+ }
537
+
538
+ #[test]
539
+ fn separable_ssim_matches_direct_window() {
540
+ let size = 16;
541
+ let a: Vec<f32> = (0..3 * size * size)
542
+ .map(|i| ((i * 37 + 11) % 251) as f32 / 250.0)
543
+ .collect();
544
+ let b: Vec<f32> = (0..3 * size * size)
545
+ .map(|i| ((i * 19 + 7) % 241) as f32 / 240.0)
546
+ .collect();
547
+ let expected = reference_ssim(&a, &b, size);
548
+ let actual = ssim_rgb(&a, &b, size);
549
+ assert!((actual - expected).abs() < 1e-12, "{actual} vs {expected}");
550
+ }
551
+
552
+ #[test]
553
+ fn distribution_interpolates_quartiles() {
554
+ let d = distribution([1.0, 2.0, 3.0, 4.0]);
555
+ assert_eq!(d.median, 2.5);
556
+ assert_eq!(d.p25, 1.75);
557
+ assert_eq!(d.p75, 3.25);
558
+ }
559
+ }
environment/training-source/source-snapshot/examples/preview.rs ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Run the full pipeline on desktop and write the result as an image.
2
+ //!
3
+ //! Exercises exactly what the headset does — inference worker, PCA fit,
4
+ //! colour projection — minus OpenXR, and writes a side-by-side PPM so the
5
+ //! output can actually be looked at. Rendering a feature view you cannot
6
+ //! see is a poor way to find out it is wrong.
7
+ //!
8
+ //! ```text
9
+ //! cargo run --release --example preview -- [out.ppm] [model.safetensors]
10
+ //! ```
11
+ //!
12
+ //! Without a checkpoint it runs on synthetic weights: the pipeline is
13
+ //! exercised end to end, but the colours mean nothing. With real weights,
14
+ //! regions that belong to the same object should share a colour.
15
+
16
+ use std::path::PathBuf;
17
+ use std::time::{Duration, Instant};
18
+
19
+ use dinovision::dinov3::Config;
20
+ use dinovision::inference::{self, Weights};
21
+ use dinovision::pca::COMPONENTS;
22
+ use dinovision::source::{FrameSource, TestPattern};
23
+
24
+ /// Nearest-neighbour upscale of the feature grid, so each patch reads as a
25
+ /// solid block. Bilinear would look prettier and hide the true resolution.
26
+ fn upscale(grid_rgb: &[f32], grid: usize, out: usize) -> Vec<u8> {
27
+ let mut px = vec![0u8; out * out * 3];
28
+ for y in 0..out {
29
+ let gy = (y * grid) / out;
30
+ for x in 0..out {
31
+ let gx = (x * grid) / out;
32
+ let src = (gy * grid + gx) * COMPONENTS;
33
+ let dst = (y * out + x) * 3;
34
+ for c in 0..3 {
35
+ px[dst + c] = (grid_rgb[src + c].clamp(0.0, 1.0) * 255.0) as u8;
36
+ }
37
+ }
38
+ }
39
+ px
40
+ }
41
+
42
+ /// Source on the left, feature view on the right.
43
+ fn write_side_by_side(path: &PathBuf, left: &[u8], right: &[u8], size: usize) {
44
+ let mut joined = vec![0u8; size * size * 6];
45
+ for y in 0..size {
46
+ let src = y * size * 3;
47
+ let dst = y * size * 6;
48
+ joined[dst..dst + size * 3].copy_from_slice(&left[src..src + size * 3]);
49
+ joined[dst + size * 3..dst + size * 6].copy_from_slice(&right[src..src + size * 3]);
50
+ }
51
+ image::RgbImage::from_raw(size as u32 * 2, size as u32, joined)
52
+ .expect("image dimensions do not match buffer")
53
+ .save(path)
54
+ .expect("failed to write image");
55
+ }
56
+
57
+ fn main() {
58
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
59
+
60
+ let mut args = std::env::args().skip(1);
61
+ let out_path = PathBuf::from(args.next().unwrap_or_else(|| "preview.png".into()));
62
+ let weights = match args.next().map(PathBuf::from) {
63
+ Some(p) if p.exists() => {
64
+ log::info!("using weights from {}", p.display());
65
+ Weights::SafeTensors(p)
66
+ }
67
+ Some(p) => panic!("{} does not exist", p.display()),
68
+ None => Weights::Synthetic,
69
+ };
70
+
71
+ let config = Config::vits16().at_resolution(256);
72
+ let gpu = dinovision::init_context(None).expect("failed to initialize GPU context");
73
+ let worker = inference::spawn(gpu, config.clone(), weights, None, 1, dinovision::inference::Display::PcaColour);
74
+
75
+ // `DINOVISION_SOURCE=screen` grabs the desktop instead of the synthetic
76
+ // scene, which is the quickest way to see the colouring against real
77
+ // imagery without opening a window.
78
+ let mut source: Box<dyn FrameSource> = match std::env::var("DINOVISION_SOURCE").as_deref() {
79
+ #[cfg(feature = "capture")]
80
+ Ok("screen") => match dinovision::source::ScreenCapture::new(config.image_size, 0) {
81
+ Ok(s) => Box::new(s),
82
+ Err(e) => panic!("screen capture unavailable: {e}"),
83
+ },
84
+ #[cfg(not(feature = "capture"))]
85
+ Ok("screen") => panic!("rebuild with --features capture for screen capture"),
86
+ _ => Box::new(TestPattern::new(config.image_size)),
87
+ };
88
+ let mut last_frame: Vec<u8> = Vec::new();
89
+
90
+ // Let the session build, then run a few frames so the PCA basis is
91
+ // refitted from real features rather than the placeholder.
92
+ let deadline = Instant::now() + Duration::from_secs(180);
93
+ let mut completed = 0;
94
+ while completed < 5 && Instant::now() < deadline {
95
+ if worker.is_ready() {
96
+ let rgb = source.next_frame().unwrap();
97
+ last_frame = rgb.to_vec();
98
+ let patches = dinovision::preprocess::patches_from_rgb8(rgb, &config);
99
+ worker.submit(patches);
100
+ }
101
+ std::thread::sleep(Duration::from_millis(20));
102
+ completed = worker.generation();
103
+ }
104
+
105
+ let grid = worker
106
+ .latest()
107
+ .expect("inference produced no result before the deadline");
108
+ log::info!(
109
+ "{} completed encodes, last took {:.1} ms ({}x{} grid)",
110
+ completed,
111
+ grid.latency_ms,
112
+ grid.grid,
113
+ grid.grid
114
+ );
115
+
116
+ let size = config.image_size;
117
+ let features = upscale(grid.patch_rgb(), grid.grid, size);
118
+ write_side_by_side(&out_path, &last_frame, &features, size);
119
+
120
+ // A view that is one flat colour means the projection collapsed, which
121
+ // is easy to miss by eye in a small image.
122
+ let spread = {
123
+ let mut lo = [255u8; 3];
124
+ let mut hi = [0u8; 3];
125
+ for px in features.chunks_exact(3) {
126
+ for c in 0..3 {
127
+ lo[c] = lo[c].min(px[c]);
128
+ hi[c] = hi[c].max(px[c]);
129
+ }
130
+ }
131
+ (0..3).map(|c| hi[c] as i32 - lo[c] as i32).max().unwrap()
132
+ };
133
+ println!("wrote {} (source | features)", out_path.display());
134
+ println!("colour spread: {spread}/255");
135
+ if spread < 16 {
136
+ println!("WARNING: the feature view is nearly flat — projection may have collapsed");
137
+ }
138
+ }
environment/training-source/source-snapshot/examples/train_decoder.rs ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Train the DINOv3 feature → RGB decoder, using meganeura for both halves.
2
+ //!
3
+ //! ```text
4
+ //! cargo run --release --example train_decoder -- \
5
+ //! <dataset-dir-or-manifest> <model.safetensors> \
6
+ //! [steps] [images] [layers] [size] [seed] [output-dir]
7
+ //! ```
8
+ //!
9
+ //! Two phases. First every image is encoded once and its features cached in
10
+ //! memory, because the encoder is frozen and running it inside the training
11
+ //! loop would cost ~30× the decoder's own forward pass for no gradient.
12
+ //! Then the decoder trains on those pairs with Adam.
13
+ //!
14
+ //! Writes `decoder.bin` — raw f32, parameters in graph declaration order —
15
+ //! next to the working directory, plus a PNG strip of reconstructions so
16
+ //! the result can be judged by eye rather than by loss alone.
17
+
18
+ use std::path::{Path, PathBuf};
19
+ use std::time::{Instant, SystemTime, UNIX_EPOCH};
20
+
21
+ use dinovision::decoder;
22
+ use dinovision::dinov3::Config;
23
+ use meganeura::graph::Op;
24
+ use meganeura::train::{Mode, SessionConfig};
25
+ use meganeura::{Graph, Session};
26
+ use serde::Serialize;
27
+
28
+ mod common;
29
+
30
+ const BATCH: usize = 8;
31
+
32
+ fn random_u64(state: &mut u64) -> u64 {
33
+ *state ^= *state >> 12;
34
+ *state ^= *state << 25;
35
+ *state ^= *state >> 27;
36
+ state.wrapping_mul(0x2545_F491_4F6C_DD1D)
37
+ }
38
+
39
+ fn seeded_state(base: u64, seed: u64) -> u64 {
40
+ let state = base ^ seed;
41
+ if state == 0 {
42
+ 0xA076_1D64_78BD_642F
43
+ } else {
44
+ state
45
+ }
46
+ }
47
+
48
+ fn shuffle(order: &mut [usize], state: &mut u64) {
49
+ for i in (1..order.len()).rev() {
50
+ order.swap(i, random_u64(state) as usize % (i + 1));
51
+ }
52
+ }
53
+
54
+ /// Deterministic small init. Kaiming-ish: scaled by fan-in so activations
55
+ /// neither vanish nor explode through four stages.
56
+ fn init_parameters(session: &mut Session, graph: &Graph, seed: u64) {
57
+ let mut state = seeded_state(0x9E37_79B9_7F4A_7C15, seed);
58
+ let mut next = move || ((random_u64(&mut state) >> 40) as f32 / (1u32 << 24) as f32) - 0.5;
59
+ for node in graph.nodes() {
60
+ let Op::Parameter { name } = &node.op else {
61
+ continue;
62
+ };
63
+ let n = node.ty.num_elements();
64
+ let shape = &node.ty.shape;
65
+ let data: Vec<f32> = if name.ends_with("norm.weight") {
66
+ vec![1.0; n]
67
+ } else if name.ends_with(".bias") {
68
+ vec![0.0; n]
69
+ } else {
70
+ // shape is [out, in, kh, kw]; fan_in = in * kh * kw.
71
+ let fan_in: usize = shape.iter().skip(1).product::<usize>().max(1);
72
+ let scale = (2.0 / fan_in as f32).sqrt() * 2.0;
73
+ (0..n).map(|_| next() * scale).collect()
74
+ };
75
+ session.set_parameter(name, &data);
76
+ }
77
+ }
78
+
79
+ #[derive(Serialize)]
80
+ struct TrainingRecord {
81
+ schema_version: u32,
82
+ completed_unix_seconds: u64,
83
+ dataset: String,
84
+ dataset_manifest_sha256: Option<String>,
85
+ requested_images: usize,
86
+ training_images: usize,
87
+ model: String,
88
+ model_sha256: String,
89
+ initialization: String,
90
+ initialization_sha256: Option<String>,
91
+ seed: u64,
92
+ image_size: usize,
93
+ encoder_layers: usize,
94
+ batch_size: usize,
95
+ steps: usize,
96
+ data_order: &'static str,
97
+ objective: &'static str,
98
+ optimizer: &'static str,
99
+ initial_learning_rate: f32,
100
+ final_learning_rate: f32,
101
+ decoder_parameters: usize,
102
+ encoding_seconds: f64,
103
+ training_seconds: f64,
104
+ final_l1: f32,
105
+ decoder: String,
106
+ decoder_sha256: String,
107
+ diagnostic_samples: String,
108
+ }
109
+
110
+ fn portable_path(path: &Path) -> String {
111
+ path.to_string_lossy().replace('\\', "/")
112
+ }
113
+
114
+ fn save_parameters(session: &Session, graph: &Graph, path: &Path) -> std::io::Result<()> {
115
+ let mut bytes = Vec::new();
116
+ for node in graph.nodes() {
117
+ let Op::Parameter { name } = &node.op else {
118
+ continue;
119
+ };
120
+ let mut buf = vec![0.0f32; node.ty.num_elements()];
121
+ session.read_param(name, &mut buf);
122
+ bytes.extend_from_slice(bytemuck::cast_slice(&buf));
123
+ }
124
+ std::fs::write(path, bytes)
125
+ }
126
+
127
+ fn main() {
128
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
129
+
130
+ let mut args = std::env::args().skip(1);
131
+ let image_dir = PathBuf::from(args.next().expect(
132
+ "usage: train_decoder <dataset-dir-or-manifest> <model.safetensors> \
133
+ [steps] [images] [layers] [size] [seed] [output-dir]",
134
+ ));
135
+ let weights = PathBuf::from(args.next().expect("need the DINOv3 checkpoint"));
136
+ let steps: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(3000);
137
+ let max_images: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(2500);
138
+ // Truncating the encoder is the cheapest way to speed it up on a
139
+ // headset, and the later layers are where colour gets discarded, so a
140
+ // shallower encoder may reconstruct better as well as faster.
141
+ let layers: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(12);
142
+ let size: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(224);
143
+ let seed: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0);
144
+ let output_dir = args
145
+ .next()
146
+ .map(PathBuf::from)
147
+ .unwrap_or_else(|| PathBuf::from("."));
148
+ assert!(steps > 0, "steps must be positive");
149
+ std::fs::create_dir_all(&output_dir).expect("create output directory");
150
+
151
+ let config = Config::vits16().at_resolution(size).with_layers(layers);
152
+ let gpu = dinovision::init_context(None).expect("GPU context");
153
+
154
+ // ---- Phase 1: encode the dataset once ----
155
+ let paths = if image_dir.extension().and_then(|e| e.to_str()) == Some("json") {
156
+ common::images_for_split(&image_dir, "train", max_images)
157
+ .unwrap_or_else(|e| panic!("{}: {e}", image_dir.display()))
158
+ .into_iter()
159
+ .map(|(path, image)| {
160
+ common::verify_image(&path, &image)
161
+ .unwrap_or_else(|e| panic!("dataset verification failed: {e}"));
162
+ path
163
+ })
164
+ .collect()
165
+ } else {
166
+ common::find_images(&image_dir, max_images)
167
+ };
168
+ assert!(!paths.is_empty(), "no images under {}", image_dir.display());
169
+ log::info!("encoding {} images", paths.len());
170
+
171
+ let (mut encoder, _) = dinovision::bench::build_encoder_session(gpu.clone(), &config, None);
172
+ let model = meganeura::data::safetensors::SafeTensorsModel::load(weights.clone())
173
+ .expect("read weights");
174
+ dinovision::weights::load_encoder(&mut encoder, &model, &config).expect("bind weights");
175
+
176
+ let feat_len = config.hidden_size * config.num_patches();
177
+ let img_len = 3 * config.image_size * config.image_size;
178
+ let mut features: Vec<f32> = Vec::with_capacity(paths.len() * feat_len);
179
+ // Targets stay u8 and are widened per batch. As f32 the cache would be
180
+ // 900 KB an image, and a few thousand images then no longer fit in RAM.
181
+ let mut targets: Vec<u8> = Vec::with_capacity(paths.len() * img_len);
182
+
183
+ let start = Instant::now();
184
+ let mut kept = 0usize;
185
+ let mut scratch = vec![0.0f32; config.num_tokens() * config.hidden_size];
186
+ for (i, path) in paths.iter().enumerate() {
187
+ let Some(rgb) = common::load_frame(path, config.image_size as u32) else {
188
+ continue;
189
+ };
190
+ let patches = dinovision::preprocess::patches_from_rgb8(&rgb, &config);
191
+ encoder.set_input("patches", &patches);
192
+ encoder.step();
193
+ encoder.wait();
194
+ encoder.read_output_by_index(0, &mut scratch);
195
+
196
+ features.extend_from_slice(&decoder::patch_features_to_nchw(&scratch, &config));
197
+ // Target is plain CHW in [0, 1] — the decoder predicts pixels, not
198
+ // ImageNet-normalized values.
199
+ let size = config.image_size;
200
+ for c in 0..3 {
201
+ for p in 0..size * size {
202
+ targets.push(rgb[p * 3 + c]);
203
+ }
204
+ }
205
+ kept += 1;
206
+ if i % 500 == 0 {
207
+ log::info!(
208
+ " {i}/{} ({:.0}s)",
209
+ paths.len(),
210
+ start.elapsed().as_secs_f64()
211
+ );
212
+ }
213
+ }
214
+ drop(encoder);
215
+ assert!(kept > 0, "none of the selected images could be decoded");
216
+ let encoding_seconds = start.elapsed().as_secs_f64();
217
+ log::info!(
218
+ "encoded {kept} images in {:.0}s ({:.0} MB cached)",
219
+ encoding_seconds,
220
+ (features.len() * 4 + targets.len()) as f64 / 1e6
221
+ );
222
+
223
+ // ---- Phase 2: train the decoder ----
224
+ let mut g = Graph::new();
225
+ let feat_in = g.input("feat", &[BATCH * feat_len]);
226
+ let recon = decoder::build_decoder(&mut g, &config, feat_in, BATCH);
227
+ let target_in = g.input("target", &[BATCH * img_len]);
228
+ // L1 rather than MSE. Squared error optimises the conditional mean, so
229
+ // wherever a feature is ambiguous the decoder hedges by averaging every
230
+ // possibility, which is blur by construction. L1 optimises the median
231
+ // and commits to one answer, usually looking markedly sharper at the
232
+ // same PSNR.
233
+ let loss = g.l1_loss(recon, target_in);
234
+ g.set_outputs(vec![loss, recon]);
235
+
236
+ let (mut session, _) = meganeura::train::build(
237
+ &g,
238
+ SessionConfig {
239
+ mode: Mode::Training,
240
+ gpu: Some(gpu),
241
+ ..Default::default()
242
+ },
243
+ );
244
+ // `DINOVISION_INIT=decoder.bin` continues from existing weights instead
245
+ // of starting over. Necessary when adapting to a few hundred captured
246
+ // frames: 2M parameters trained from scratch on that much data would
247
+ // simply memorise it, where fine-tuning shifts an already-general
248
+ // decoder onto the new distribution.
249
+ let (initialization, initialization_sha256) = match std::env::var("DINOVISION_INIT") {
250
+ Ok(path) => {
251
+ let path = PathBuf::from(path);
252
+ decoder::load_parameters(&mut session, &g, &path)
253
+ .unwrap_or_else(|e| panic!("could not load {}: {e}", path.display()));
254
+ log::info!("fine-tuning from {}", path.display());
255
+ let digest = common::sha256(&path).expect("hash initial decoder");
256
+ (portable_path(&path), Some(digest))
257
+ }
258
+ Err(_) => {
259
+ init_parameters(&mut session, &g, seed);
260
+ ("random".to_string(), None)
261
+ }
262
+ };
263
+ session.set_adam(2e-3, 0.9, 0.999, 1e-8);
264
+ log::info!(
265
+ "training {} decoder parameters for {steps} steps, batch {BATCH}, encoder depth {layers}, \
266
+ resolution {size}, seed {seed}",
267
+ decoder::parameter_count(&config)
268
+ );
269
+
270
+ let mut feat_batch = vec![0.0f32; BATCH * feat_len];
271
+ let mut target_batch = vec![0.0f32; BATCH * img_len];
272
+ let mut order: Vec<usize> = (0..kept).collect();
273
+ let mut shuffle_state = seeded_state(0xD1B5_4A32_D192_ED03, seed);
274
+ shuffle(&mut order, &mut shuffle_state);
275
+ let mut cursor = 0usize;
276
+ let train_start = Instant::now();
277
+ let mut final_l1 = f32::NAN;
278
+ let mut final_learning_rate = 2e-3;
279
+
280
+ for step in 0..steps {
281
+ for b in 0..BATCH {
282
+ if cursor == kept {
283
+ shuffle(&mut order, &mut shuffle_state);
284
+ cursor = 0;
285
+ }
286
+ let idx = order[cursor];
287
+ cursor += 1;
288
+ feat_batch[b * feat_len..(b + 1) * feat_len]
289
+ .copy_from_slice(&features[idx * feat_len..(idx + 1) * feat_len]);
290
+ for (dst, &src) in target_batch[b * img_len..(b + 1) * img_len]
291
+ .iter_mut()
292
+ .zip(&targets[idx * img_len..(idx + 1) * img_len])
293
+ {
294
+ *dst = src as f32 / 255.0;
295
+ }
296
+ }
297
+ session.set_input("feat", &feat_batch);
298
+ session.set_input("target", &target_batch);
299
+ // Linear decay: the last stretch matters for sharpness, and a
300
+ // constant rate leaves the reconstruction visibly noisier.
301
+ let progress = step as f32 / steps as f32;
302
+ final_learning_rate = 2e-3 * (1.0 - progress).max(0.05);
303
+ session.set_adam(final_learning_rate, 0.9, 0.999, 1e-8);
304
+ session.step();
305
+ session.wait();
306
+
307
+ if step % 100 == 0 || step == steps - 1 {
308
+ final_l1 = session.read_loss();
309
+ log::info!(
310
+ "step {step:>5} L1 {final_l1:.5} ({:.0}s)",
311
+ train_start.elapsed().as_secs_f64()
312
+ );
313
+ }
314
+ }
315
+ let training_seconds = train_start.elapsed().as_secs_f64();
316
+
317
+ let decoder_path = output_dir.join("decoder.bin");
318
+ save_parameters(&session, &g, &decoder_path).expect("write decoder.bin");
319
+ log::info!("wrote {}", decoder_path.display());
320
+
321
+ // ---- Sample strip: original above, reconstruction below ----
322
+ let size = config.image_size;
323
+ for b in 0..BATCH {
324
+ let idx = b % kept;
325
+ feat_batch[b * feat_len..(b + 1) * feat_len]
326
+ .copy_from_slice(&features[idx * feat_len..(idx + 1) * feat_len]);
327
+ for (dst, &src) in target_batch[b * img_len..(b + 1) * img_len]
328
+ .iter_mut()
329
+ .zip(&targets[idx * img_len..(idx + 1) * img_len])
330
+ {
331
+ *dst = src as f32 / 255.0;
332
+ }
333
+ }
334
+ session.set_input("feat", &feat_batch);
335
+ session.step();
336
+ session.wait();
337
+ let mut recon_out = vec![0.0f32; BATCH * img_len];
338
+ session.read_output_by_index(1, &mut recon_out);
339
+
340
+ let cols = 6.min(BATCH);
341
+ let mut strip = image::RgbImage::new((cols * size) as u32, (2 * size) as u32);
342
+ let mut total_psnr = 0.0;
343
+ for b in 0..cols {
344
+ let t = &target_batch[b * img_len..(b + 1) * img_len];
345
+ let r = &recon_out[b * img_len..(b + 1) * img_len];
346
+ total_psnr += decoder::psnr(r, t);
347
+ for y in 0..size {
348
+ for x in 0..size {
349
+ let at = |src: &[f32], c: usize| {
350
+ (src[c * size * size + y * size + x].clamp(0.0, 1.0) * 255.0) as u8
351
+ };
352
+ strip.put_pixel(
353
+ (b * size + x) as u32,
354
+ y as u32,
355
+ image::Rgb([at(t, 0), at(t, 1), at(t, 2)]),
356
+ );
357
+ strip.put_pixel(
358
+ (b * size + x) as u32,
359
+ (size + y) as u32,
360
+ image::Rgb([at(r, 0), at(r, 1), at(r, 2)]),
361
+ );
362
+ }
363
+ }
364
+ }
365
+ let samples_path = output_dir.join("decoder_samples.png");
366
+ strip.save(&samples_path).expect("write samples");
367
+ let manifest_sha256 = (image_dir.extension().and_then(|e| e.to_str()) == Some("json"))
368
+ .then(|| common::sha256(&image_dir).expect("hash dataset manifest"));
369
+ let record = TrainingRecord {
370
+ schema_version: 1,
371
+ completed_unix_seconds: SystemTime::now()
372
+ .duration_since(UNIX_EPOCH)
373
+ .expect("system time before Unix epoch")
374
+ .as_secs(),
375
+ dataset: portable_path(&image_dir),
376
+ dataset_manifest_sha256: manifest_sha256,
377
+ requested_images: max_images,
378
+ training_images: kept,
379
+ model: portable_path(&weights),
380
+ model_sha256: common::sha256(&weights).expect("hash encoder model"),
381
+ initialization,
382
+ initialization_sha256,
383
+ seed,
384
+ image_size: config.image_size,
385
+ encoder_layers: config.num_hidden_layers,
386
+ batch_size: BATCH,
387
+ steps,
388
+ data_order: "seeded Fisher-Yates; reshuffled after every complete pass",
389
+ objective: "mean absolute error (L1)",
390
+ optimizer: "Adam(beta1=0.9,beta2=0.999,epsilon=1e-8), linear learning-rate decay",
391
+ initial_learning_rate: 2e-3,
392
+ final_learning_rate,
393
+ decoder_parameters: decoder::parameter_count(&config),
394
+ encoding_seconds,
395
+ training_seconds,
396
+ final_l1,
397
+ decoder: portable_path(&decoder_path),
398
+ decoder_sha256: common::sha256(&decoder_path).expect("hash trained decoder"),
399
+ diagnostic_samples: portable_path(&samples_path),
400
+ };
401
+ let record_path = output_dir.join("training.json");
402
+ std::fs::write(
403
+ &record_path,
404
+ serde_json::to_vec_pretty(&record).expect("serialize training record"),
405
+ )
406
+ .expect("write training record");
407
+ println!(
408
+ "wrote {} and {}\nin-sample diagnostic mean PSNR {:.2} dB",
409
+ samples_path.display(),
410
+ record_path.display(),
411
+ total_psnr / cols as f32
412
+ );
413
+ }
414
+
415
+ #[cfg(test)]
416
+ mod tests {
417
+ use super::*;
418
+
419
+ #[test]
420
+ fn shuffle_is_seeded_and_preserves_every_index() {
421
+ let run = |seed| {
422
+ let mut order: Vec<usize> = (0..100).collect();
423
+ let mut state = seeded_state(0xD1B5_4A32_D192_ED03, seed);
424
+ shuffle(&mut order, &mut state);
425
+ order
426
+ };
427
+ let a = run(7);
428
+ let b = run(7);
429
+ let c = run(8);
430
+ assert_eq!(a, b);
431
+ assert_ne!(a, c);
432
+ let mut sorted = a;
433
+ sorted.sort_unstable();
434
+ assert_eq!(sorted, (0..100).collect::<Vec<_>>());
435
+ }
436
+ }
environment/training-source/source-snapshot/examples/verify.rs ADDED
@@ -0,0 +1,638 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Compare our encoder against the HuggingFace reference, on desktop.
2
+ //!
3
+ //! ViT numerics are unforgiving: a transposed weight, a wrong RoPE pairing,
4
+ //! or a misordered patch flattening all produce output that looks
5
+ //! statistically reasonable and is completely wrong. This has to pass
6
+ //! before anything is deployed, because diagnosing it through logcat is
7
+ //! miserable.
8
+ //!
9
+ //! Inputs come from `tools/dump_reference.py`, which writes the *already
10
+ //! preprocessed* pixel tensor alongside the expected features. Taking the
11
+ //! pixel tensor verbatim keeps image resizing out of the comparison, so a
12
+ //! failure here is a failure in the graph.
13
+ //!
14
+ //! ```text
15
+ //! python tools/dump_reference.py --out ref/
16
+ //! cargo run --release --bin verify -- ref/ [model.safetensors]
17
+ //! ```
18
+
19
+ use std::path::{Path, PathBuf};
20
+
21
+ use dinovision::dinov3::Config;
22
+ use meganeura::Graph;
23
+ use meganeura::train::{Mode, SessionConfig};
24
+ use serde::{Deserialize, Serialize};
25
+
26
+ mod common;
27
+
28
+ #[derive(Serialize)]
29
+ struct Verification {
30
+ schema_version: u32,
31
+ model_sha256: String,
32
+ pixel_values_sha256: String,
33
+ reference_features_sha256: String,
34
+ meganeura_features_sha256: String,
35
+ elements: usize,
36
+ relative_l2: f64,
37
+ max_absolute_error: f32,
38
+ worst_absolute_token: usize,
39
+ cls_cosine: f32,
40
+ worst_patch_cosine: f32,
41
+ thresholds: Thresholds,
42
+ passed: bool,
43
+ }
44
+
45
+ #[derive(Deserialize)]
46
+ struct ReferenceMetadata {
47
+ image_size: usize,
48
+ encoder_layers: usize,
49
+ }
50
+
51
+ #[derive(Serialize)]
52
+ struct Thresholds {
53
+ max_relative_l2: f64,
54
+ min_cls_cosine: f32,
55
+ min_patch_cosine: f32,
56
+ }
57
+
58
+ fn read_f32(path: &Path) -> Vec<f32> {
59
+ let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
60
+ assert_eq!(
61
+ bytes.len() % 4,
62
+ 0,
63
+ "{} is not a whole number of f32 values",
64
+ path.display()
65
+ );
66
+ bytes
67
+ .chunks_exact(4)
68
+ .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
69
+ .collect()
70
+ }
71
+
72
+ fn embedding_forward(
73
+ gpu: std::sync::Arc<blade_graphics::Context>,
74
+ config: &Config,
75
+ model: &meganeura::data::safetensors::SafeTensorsModel,
76
+ patches: &[f32],
77
+ ) -> Vec<f32> {
78
+ let hidden = config.hidden_size;
79
+ let prefix = config.num_prefix_tokens();
80
+ let mut graph = Graph::new();
81
+ let input = graph.input("patches", &[config.num_patches(), config.patch_dim()]);
82
+ let weight = graph.parameter(
83
+ "embeddings.patch_embeddings.weight",
84
+ &[config.patch_dim(), hidden],
85
+ );
86
+ let bias = graph.parameter("embeddings.patch_embeddings.bias", &[hidden]);
87
+ let patch_embeddings = graph.matmul(input, weight);
88
+ let patch_embeddings = graph.bias_add(patch_embeddings, bias);
89
+ let prefix_tokens = graph.parameter("prefix_tokens", &[prefix, hidden]);
90
+ let output = graph.concat(
91
+ prefix_tokens,
92
+ patch_embeddings,
93
+ 1,
94
+ (prefix * hidden) as u32,
95
+ (config.num_patches() * hidden) as u32,
96
+ 1,
97
+ );
98
+ let output = graph.reshape(output, &[config.num_tokens(), hidden]);
99
+ graph.set_outputs(vec![output]);
100
+
101
+ let (mut session, _) = meganeura::train::build(
102
+ &graph,
103
+ SessionConfig {
104
+ mode: Mode::Inference,
105
+ gpu: Some(gpu),
106
+ ..Default::default()
107
+ },
108
+ );
109
+ let convolution = model
110
+ .tensor_f32_auto("embeddings.patch_embeddings.weight")
111
+ .expect("load patch embedding weight");
112
+ session.set_parameter(
113
+ "embeddings.patch_embeddings.weight",
114
+ &dinovision::preprocess::conv_weight_to_matmul(&convolution, hidden, config.patch_dim()),
115
+ );
116
+ session.set_parameter(
117
+ "embeddings.patch_embeddings.bias",
118
+ &model
119
+ .tensor_f32_auto("embeddings.patch_embeddings.bias")
120
+ .expect("load patch embedding bias"),
121
+ );
122
+ let mut prefix_data = model
123
+ .tensor_f32_auto("embeddings.cls_token")
124
+ .expect("load CLS token");
125
+ prefix_data.extend(
126
+ model
127
+ .tensor_f32_auto("embeddings.register_tokens")
128
+ .expect("load register tokens"),
129
+ );
130
+ session.set_parameter("prefix_tokens", &prefix_data);
131
+ session.set_input("patches", patches);
132
+ session.step();
133
+ session.wait();
134
+ session.read_output(config.num_tokens() * hidden)
135
+ }
136
+
137
+ fn first_projections(
138
+ gpu: std::sync::Arc<blade_graphics::Context>,
139
+ config: &Config,
140
+ model: &meganeura::data::safetensors::SafeTensorsModel,
141
+ embeddings: &[f32],
142
+ ) -> [Vec<f32>; 4] {
143
+ let hidden = config.hidden_size;
144
+ let mut graph = Graph::new();
145
+ let input = graph.input("embeddings", &[config.num_tokens(), hidden]);
146
+ let norm_weight = graph.parameter("layer.0.norm1.weight", &[hidden]);
147
+ let norm_bias = graph.parameter("layer.0.norm1.bias", &[hidden]);
148
+ let normalized = graph.layer_norm(input, norm_weight, norm_bias, config.layer_norm_eps);
149
+ let mut projections = Vec::new();
150
+ for (name, has_bias) in [("q_proj", true), ("k_proj", false), ("v_proj", true)] {
151
+ let weight = graph.parameter(
152
+ &format!("layer.0.attention.{name}.weight"),
153
+ &[hidden, hidden],
154
+ );
155
+ let projected = graph.matmul(normalized, weight);
156
+ let projected = if has_bias {
157
+ let bias = graph.parameter(&format!("layer.0.attention.{name}.bias"), &[hidden]);
158
+ graph.bias_add(projected, bias)
159
+ } else {
160
+ projected
161
+ };
162
+ projections.push(projected);
163
+ }
164
+ graph.set_outputs(vec![
165
+ normalized,
166
+ projections[0],
167
+ projections[1],
168
+ projections[2],
169
+ ]);
170
+ let (mut session, _) = meganeura::train::build(
171
+ &graph,
172
+ SessionConfig {
173
+ mode: Mode::Inference,
174
+ gpu: Some(gpu),
175
+ ..Default::default()
176
+ },
177
+ );
178
+ let prefix = if model
179
+ .tensor_info()
180
+ .contains_key("model.layer.0.norm1.weight")
181
+ {
182
+ "model."
183
+ } else {
184
+ ""
185
+ };
186
+ for part in ["weight", "bias"] {
187
+ session.set_parameter(
188
+ &format!("layer.0.norm1.{part}"),
189
+ &model
190
+ .tensor_f32_auto(&format!("{prefix}layer.0.norm1.{part}"))
191
+ .expect("load first norm"),
192
+ );
193
+ }
194
+ for (name, has_bias) in [("q_proj", true), ("k_proj", false), ("v_proj", true)] {
195
+ session.set_parameter(
196
+ &format!("layer.0.attention.{name}.weight"),
197
+ &model
198
+ .tensor_f32_auto_transposed(&format!("{prefix}layer.0.attention.{name}.weight"))
199
+ .expect("load first projection"),
200
+ );
201
+ if has_bias {
202
+ session.set_parameter(
203
+ &format!("layer.0.attention.{name}.bias"),
204
+ &model
205
+ .tensor_f32_auto(&format!("{prefix}layer.0.attention.{name}.bias"))
206
+ .expect("load first projection bias"),
207
+ );
208
+ }
209
+ }
210
+ session.set_input("embeddings", embeddings);
211
+ session.step();
212
+ session.wait();
213
+ std::array::from_fn(|index| {
214
+ let mut output = vec![0.0; config.num_tokens() * hidden];
215
+ session.read_output_by_index(index, &mut output);
216
+ output
217
+ })
218
+ }
219
+
220
+ fn first_attention(
221
+ gpu: std::sync::Arc<blade_graphics::Context>,
222
+ config: &Config,
223
+ q: &[f32],
224
+ k: &[f32],
225
+ v: &[f32],
226
+ ) -> [Vec<f32>; 3] {
227
+ let tokens = config.num_tokens();
228
+ let heads = config.num_attention_heads;
229
+ let head_dim = config.head_dim();
230
+ let hidden = config.hidden_size;
231
+ let mut graph = Graph::new();
232
+ let q_node = graph.input("q", &[tokens, hidden]);
233
+ let k_node = graph.input("k", &[tokens, hidden]);
234
+ let v_node = graph.input("v", &[tokens, hidden]);
235
+ let (cos_data, sin_data) = dinovision::dinov3::rope_tables(config);
236
+ let cos = graph.constant(cos_data, &[tokens, hidden]);
237
+ let sin = graph.constant(sin_data, &[tokens, hidden]);
238
+ let apply_rope = |graph: &mut Graph, input| {
239
+ let blocks = tokens as u32 * heads;
240
+ let half = head_dim / 2;
241
+ let first = graph.split_a(input, blocks, half, half, 1);
242
+ let second = graph.split_b(input, blocks, half, half, 1);
243
+ let negative_second = graph.neg(second);
244
+ let rotated = graph.concat(negative_second, first, blocks, half, half, 1);
245
+ let rotated = graph.reshape(rotated, &[tokens, hidden]);
246
+ let straight = graph.mul(input, cos);
247
+ let crossed = graph.mul(rotated, sin);
248
+ graph.add(straight, crossed)
249
+ };
250
+ let q_rope = apply_rope(&mut graph, q_node);
251
+ let k_rope = apply_rope(&mut graph, k_node);
252
+ let attention = graph.full_attention(q_rope, k_rope, v_node, heads, heads, head_dim);
253
+ graph.set_outputs(vec![q_rope, k_rope, attention]);
254
+ let (mut session, _) = meganeura::train::build(
255
+ &graph,
256
+ SessionConfig {
257
+ mode: Mode::Inference,
258
+ gpu: Some(gpu),
259
+ ..Default::default()
260
+ },
261
+ );
262
+ session.set_input("q", q);
263
+ session.set_input("k", k);
264
+ session.set_input("v", v);
265
+ session.step();
266
+ session.wait();
267
+ std::array::from_fn(|index| {
268
+ let mut output = vec![0.0; tokens * hidden];
269
+ session.read_output_by_index(index, &mut output);
270
+ output
271
+ })
272
+ }
273
+
274
+ fn first_remainder(
275
+ gpu: std::sync::Arc<blade_graphics::Context>,
276
+ config: &Config,
277
+ model: &meganeura::data::safetensors::SafeTensorsModel,
278
+ embeddings: &[f32],
279
+ attention: &[f32],
280
+ ) -> Vec<Vec<f32>> {
281
+ let tokens = config.num_tokens();
282
+ let hidden = config.hidden_size;
283
+ let intermediate = config.intermediate_size;
284
+ let mut graph = Graph::new();
285
+ let embeddings_node = graph.input("embeddings", &[tokens, hidden]);
286
+ let attention_node = graph.input("attention", &[tokens, hidden]);
287
+ let output_weight = graph.parameter("layer.0.attention.o_proj.weight", &[hidden, hidden]);
288
+ let output_bias = graph.parameter("layer.0.attention.o_proj.bias", &[hidden]);
289
+ let attention_projected = graph.matmul(attention_node, output_weight);
290
+ let attention_projected = graph.bias_add(attention_projected, output_bias);
291
+ let scale1 = graph.parameter("layer.0.layer_scale1.lambda1", &[hidden]);
292
+ let transposed = graph.transpose(attention_projected);
293
+ let flat = graph.reshape(transposed, &[hidden * tokens]);
294
+ let scaled = graph.mul_per_channel(flat, scale1, hidden as u32, tokens as u32);
295
+ let scaled = graph.reshape(scaled, &[hidden, tokens]);
296
+ let attention_scaled = graph.transpose(scaled);
297
+ let residual = graph.add(embeddings_node, attention_scaled);
298
+
299
+ let norm2_weight = graph.parameter("layer.0.norm2.weight", &[hidden]);
300
+ let norm2_bias = graph.parameter("layer.0.norm2.bias", &[hidden]);
301
+ let norm2 = graph.layer_norm(residual, norm2_weight, norm2_bias, config.layer_norm_eps);
302
+ let up_weight = graph.parameter("layer.0.mlp.up_proj.weight", &[hidden, intermediate]);
303
+ let up_bias = graph.parameter("layer.0.mlp.up_proj.bias", &[intermediate]);
304
+ let mlp_up = graph.matmul(norm2, up_weight);
305
+ let mlp_up = graph.bias_add(mlp_up, up_bias);
306
+ let mlp_activated = graph.gelu(mlp_up);
307
+ let down_weight = graph.parameter("layer.0.mlp.down_proj.weight", &[intermediate, hidden]);
308
+ let down_bias = graph.parameter("layer.0.mlp.down_proj.bias", &[hidden]);
309
+ let mlp_down = graph.matmul(mlp_activated, down_weight);
310
+ let mlp_down = graph.bias_add(mlp_down, down_bias);
311
+ let scale2 = graph.parameter("layer.0.layer_scale2.lambda1", &[hidden]);
312
+ let transposed = graph.transpose(mlp_down);
313
+ let flat = graph.reshape(transposed, &[hidden * tokens]);
314
+ let scaled = graph.mul_per_channel(flat, scale2, hidden as u32, tokens as u32);
315
+ let scaled = graph.reshape(scaled, &[hidden, tokens]);
316
+ let mlp_scaled = graph.transpose(scaled);
317
+ let layer_output = graph.add(residual, mlp_scaled);
318
+ let final_weight = graph.parameter("norm.weight", &[hidden]);
319
+ let final_bias = graph.parameter("norm.bias", &[hidden]);
320
+ let final_norm = graph.layer_norm(
321
+ layer_output,
322
+ final_weight,
323
+ final_bias,
324
+ config.layer_norm_eps,
325
+ );
326
+ graph.set_outputs(vec![
327
+ attention_projected,
328
+ attention_scaled,
329
+ residual,
330
+ norm2,
331
+ mlp_up,
332
+ mlp_activated,
333
+ mlp_down,
334
+ mlp_scaled,
335
+ layer_output,
336
+ final_norm,
337
+ ]);
338
+
339
+ let (mut session, _) = meganeura::train::build(
340
+ &graph,
341
+ SessionConfig {
342
+ mode: Mode::Inference,
343
+ gpu: Some(gpu),
344
+ ..Default::default()
345
+ },
346
+ );
347
+ let prefix = if model
348
+ .tensor_info()
349
+ .contains_key("model.layer.0.norm1.weight")
350
+ {
351
+ "model."
352
+ } else {
353
+ ""
354
+ };
355
+ for name in [
356
+ "attention.o_proj.weight",
357
+ "mlp.up_proj.weight",
358
+ "mlp.down_proj.weight",
359
+ ] {
360
+ session.set_parameter(
361
+ &format!("layer.0.{name}"),
362
+ &model
363
+ .tensor_f32_auto_transposed(&format!("{prefix}layer.0.{name}"))
364
+ .expect("load first-layer matrix"),
365
+ );
366
+ }
367
+ for name in [
368
+ "attention.o_proj.bias",
369
+ "layer_scale1.lambda1",
370
+ "norm2.weight",
371
+ "norm2.bias",
372
+ "mlp.up_proj.bias",
373
+ "mlp.down_proj.bias",
374
+ "layer_scale2.lambda1",
375
+ ] {
376
+ session.set_parameter(
377
+ &format!("layer.0.{name}"),
378
+ &model
379
+ .tensor_f32_auto(&format!("{prefix}layer.0.{name}"))
380
+ .expect("load first-layer vector"),
381
+ );
382
+ }
383
+ for part in ["weight", "bias"] {
384
+ session.set_parameter(
385
+ &format!("norm.{part}"),
386
+ &model
387
+ .tensor_f32_auto(&format!("norm.{part}"))
388
+ .expect("load final norm"),
389
+ );
390
+ }
391
+ session.set_input("embeddings", embeddings);
392
+ session.set_input("attention", attention);
393
+ session.step();
394
+ session.wait();
395
+ [
396
+ hidden,
397
+ hidden,
398
+ hidden,
399
+ hidden,
400
+ intermediate,
401
+ intermediate,
402
+ hidden,
403
+ hidden,
404
+ hidden,
405
+ hidden,
406
+ ]
407
+ .into_iter()
408
+ .enumerate()
409
+ .map(|(index, width)| {
410
+ let mut output = vec![0.0; tokens * width];
411
+ session.read_output_by_index(index, &mut output);
412
+ output
413
+ })
414
+ .collect()
415
+ }
416
+
417
+ fn relative_l2(actual: &[f32], expected: &[f32]) -> f64 {
418
+ let squared_error: f64 = actual
419
+ .iter()
420
+ .zip(expected)
421
+ .map(|(a, b)| (*a as f64 - *b as f64).powi(2))
422
+ .sum();
423
+ let squared_reference: f64 = expected.iter().map(|x| (*x as f64).powi(2)).sum();
424
+ squared_error.sqrt() / squared_reference.sqrt().max(f64::MIN_POSITIVE)
425
+ }
426
+
427
+ fn main() {
428
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
429
+
430
+ let mut args = std::env::args().skip(1);
431
+ let ref_dir = PathBuf::from(
432
+ args.next()
433
+ .expect("usage: verify <reference-dir> [model.safetensors]"),
434
+ );
435
+ let weights_path = args
436
+ .next()
437
+ .map(PathBuf::from)
438
+ .unwrap_or_else(|| ref_dir.join("model.safetensors"));
439
+
440
+ let pixels = read_f32(&ref_dir.join("pixel_values.bin"));
441
+ let expected = read_f32(&ref_dir.join("features.bin"));
442
+ let reference: ReferenceMetadata = serde_json::from_slice(
443
+ &std::fs::read(ref_dir.join("reference.json")).expect("read reference.json"),
444
+ )
445
+ .expect("parse reference.json");
446
+
447
+ // Infer the resolution from the pixel tensor rather than assuming it,
448
+ // so the reference script can dump any supported size.
449
+ let side = ((pixels.len() / 3) as f64).sqrt() as usize;
450
+ assert_eq!(
451
+ 3 * side * side,
452
+ pixels.len(),
453
+ "pixel tensor is not 3 x N x N"
454
+ );
455
+ assert_eq!(
456
+ side, reference.image_size,
457
+ "pixel tensor resolution disagrees with reference.json"
458
+ );
459
+ let config = Config::vits16()
460
+ .at_resolution(side)
461
+ .with_layers(reference.encoder_layers);
462
+ log::info!(
463
+ "reference: {side}x{side} -> {}x{} grid, {} tokens",
464
+ config.grid(),
465
+ config.grid(),
466
+ config.num_tokens()
467
+ );
468
+ assert_eq!(
469
+ expected.len(),
470
+ config.num_tokens() * config.hidden_size,
471
+ "expected features should be [{}, {}]",
472
+ config.num_tokens(),
473
+ config.hidden_size
474
+ );
475
+
476
+ let gpu = dinovision::init_context(None).expect("failed to initialize GPU context");
477
+
478
+ let model = meganeura::data::safetensors::SafeTensorsModel::load(weights_path.clone())
479
+ .unwrap_or_else(|e| panic!("{}: {e}", weights_path.display()));
480
+ let patches = dinovision::preprocess::patches_from_pixels_chw(&pixels, &config);
481
+ let expected_embeddings = read_f32(&ref_dir.join("embeddings.bin"));
482
+ let actual_embeddings = embedding_forward(gpu.clone(), &config, &model, &patches);
483
+ println!(
484
+ "embedding relative L2 : {:.6}",
485
+ relative_l2(&actual_embeddings, &expected_embeddings)
486
+ );
487
+ let actual_first = first_projections(gpu.clone(), &config, &model, &actual_embeddings);
488
+ for (label, actual, file) in [
489
+ ("first norm1", &actual_first[0], "first-norm1.bin"),
490
+ ("first Q", &actual_first[1], "first-q.bin"),
491
+ ("first K", &actual_first[2], "first-k.bin"),
492
+ ("first V", &actual_first[3], "first-v.bin"),
493
+ ] {
494
+ println!(
495
+ "{label:<18}: {:.6}",
496
+ relative_l2(actual, &read_f32(&ref_dir.join(file)))
497
+ );
498
+ }
499
+ let actual_attention = first_attention(
500
+ gpu.clone(),
501
+ &config,
502
+ &actual_first[1],
503
+ &actual_first[2],
504
+ &actual_first[3],
505
+ );
506
+ for (label, actual, file) in [
507
+ ("first Q RoPE", &actual_attention[0], "first-q-rope.bin"),
508
+ ("first K RoPE", &actual_attention[1], "first-k-rope.bin"),
509
+ (
510
+ "first attention",
511
+ &actual_attention[2],
512
+ "first-attention.bin",
513
+ ),
514
+ ] {
515
+ println!(
516
+ "{label:<18}: {:.6}",
517
+ relative_l2(actual, &read_f32(&ref_dir.join(file)))
518
+ );
519
+ }
520
+ let actual_remainder = first_remainder(
521
+ gpu.clone(),
522
+ &config,
523
+ &model,
524
+ &actual_embeddings,
525
+ &actual_attention[2],
526
+ );
527
+ for (index, (label, file)) in [
528
+ ("attention projected", "first-attention-projected.bin"),
529
+ ("attention scaled", "first-attention-scaled.bin"),
530
+ ("attention residual", "first-residual.bin"),
531
+ ("first norm2", "first-norm2.bin"),
532
+ ("first MLP up", "first-mlp-up.bin"),
533
+ ("first MLP GELU", "first-mlp-activated.bin"),
534
+ ("first MLP down", "first-mlp-down.bin"),
535
+ ("first MLP scaled", "first-mlp-scaled.bin"),
536
+ ("first output", "first-output.bin"),
537
+ ("first final norm", "first-final-norm.bin"),
538
+ ]
539
+ .into_iter()
540
+ .enumerate()
541
+ {
542
+ println!(
543
+ "{label:<20}: {:.6}",
544
+ relative_l2(&actual_remainder[index], &read_f32(&ref_dir.join(file)))
545
+ );
546
+ }
547
+
548
+ let (mut session, _) = dinovision::bench::build_encoder_session(gpu, &config, None);
549
+ dinovision::weights::load_encoder(&mut session, &model, &config).expect("load weights");
550
+
551
+ session.set_input("patches", &patches);
552
+ session.step();
553
+ session.wait();
554
+
555
+ let got = session.read_output(config.num_tokens() * config.hidden_size);
556
+
557
+ // Report both absolute error and cosine similarity per token group.
558
+ // Cosine matters more for what we do downstream: PCA colouring and a
559
+ // decoder both care about feature *direction*, and a uniform scale
560
+ // error would still look right while indicating a real bug.
561
+ let mut worst_abs = 0.0f32;
562
+ let mut worst_token = 0usize;
563
+ let mut squared_error = 0.0f64;
564
+ let mut squared_reference = 0.0f64;
565
+ for t in 0..config.num_tokens() {
566
+ for d in 0..config.hidden_size {
567
+ let i = t * config.hidden_size + d;
568
+ let difference = got[i] - expected[i];
569
+ let e = difference.abs();
570
+ squared_error += (difference as f64).powi(2);
571
+ squared_reference += (expected[i] as f64).powi(2);
572
+ if e > worst_abs {
573
+ worst_abs = e;
574
+ worst_token = t;
575
+ }
576
+ }
577
+ }
578
+ let relative_l2 = squared_error.sqrt() / squared_reference.sqrt().max(f64::MIN_POSITIVE);
579
+
580
+ let cosine = |a: &[f32], b: &[f32]| -> f32 {
581
+ let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
582
+ let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
583
+ let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
584
+ dot / (na * nb).max(f32::MIN_POSITIVE)
585
+ };
586
+
587
+ let h = config.hidden_size;
588
+ let cls_cos = cosine(&got[0..h], &expected[0..h]);
589
+ let mut worst_patch_cos = 1.0f32;
590
+ for t in config.num_prefix_tokens()..config.num_tokens() {
591
+ let c = cosine(&got[t * h..(t + 1) * h], &expected[t * h..(t + 1) * h]);
592
+ worst_patch_cos = worst_patch_cos.min(c);
593
+ }
594
+
595
+ println!("relative L2 : {relative_l2:.6}");
596
+ println!("max |ours - reference| : {worst_abs:.5} (worst at token {worst_token})");
597
+ println!("CLS cosine : {cls_cos:.6}");
598
+ println!("worst patch cosine : {worst_patch_cos:.6}");
599
+
600
+ // f32 GPU accumulation in a different order than PyTorch's will not
601
+ // reproduce bit-for-bit; 0.999 cosine across every patch is the real
602
+ // signal that the architecture is right.
603
+ let thresholds = Thresholds {
604
+ max_relative_l2: 0.01,
605
+ min_cls_cosine: 0.999,
606
+ min_patch_cosine: 0.999,
607
+ };
608
+ let ok = relative_l2 <= thresholds.max_relative_l2
609
+ && worst_patch_cos > thresholds.min_patch_cosine
610
+ && cls_cos > thresholds.min_cls_cosine;
611
+ let got_path = ref_dir.join("meganeura_features.bin");
612
+ std::fs::write(&got_path, bytemuck::cast_slice(&got)).expect("write Meganeura features");
613
+ let verification = Verification {
614
+ schema_version: 1,
615
+ model_sha256: common::sha256(&weights_path).expect("hash model"),
616
+ pixel_values_sha256: common::sha256(&ref_dir.join("pixel_values.bin")).expect("hash input"),
617
+ reference_features_sha256: common::sha256(&ref_dir.join("features.bin"))
618
+ .expect("hash reference"),
619
+ meganeura_features_sha256: common::sha256(&got_path).expect("hash Meganeura output"),
620
+ elements: got.len(),
621
+ relative_l2,
622
+ max_absolute_error: worst_abs,
623
+ worst_absolute_token: worst_token,
624
+ cls_cosine: cls_cos,
625
+ worst_patch_cosine: worst_patch_cos,
626
+ thresholds,
627
+ passed: ok,
628
+ };
629
+ std::fs::write(
630
+ ref_dir.join("verification.json"),
631
+ serde_json::to_vec_pretty(&verification).expect("serialize verification"),
632
+ )
633
+ .expect("write verification record");
634
+ println!("\n{}", if ok { "PASS" } else { "FAIL" });
635
+ if !ok {
636
+ std::process::exit(1);
637
+ }
638
+ }
environment/training-source/source-snapshot/examples/viewer.rs ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Live DINO feature view in a desktop window.
2
+ //!
3
+ //! The same pipeline the headset runs — capture, encode, PCA colour,
4
+ //! display — pointed at a monitor instead of a passthrough camera. Two
5
+ //! reasons it exists:
6
+ //!
7
+ //! * It is the demo you can actually show someone. Point it at a video, a
8
+ //! photo, a game, and watch objects resolve into stable colour regions.
9
+ //! * It de-risks the Quest camera. The capture loop, the downscale, and the
10
+ //! "new frame arrives asynchronously while the window redraws" structure
11
+ //! are all the same; the passthrough camera becomes another
12
+ //! `FrameSource` and nothing around it changes.
13
+ //!
14
+ //! ```text
15
+ //! cargo run --release --features capture --example viewer -- [model.safetensors] [monitor]
16
+ //! ```
17
+ //!
18
+ //! Without a checkpoint it runs on synthetic weights, which still shows the
19
+ //! scene's structure but colours it arbitrarily.
20
+
21
+ use std::sync::Arc;
22
+ use std::time::{Duration, Instant};
23
+
24
+ use blade_graphics as gpu;
25
+ use dinovision::dinov3::Config;
26
+ use dinovision::inference::{self, Weights, Worker};
27
+ use dinovision::render::GridView;
28
+ use dinovision::source::FrameSource;
29
+
30
+ fn surface_config(size: winit::dpi::PhysicalSize<u32>) -> gpu::SurfaceConfig {
31
+ gpu::SurfaceConfig {
32
+ size: gpu::Extent {
33
+ width: size.width.max(1),
34
+ height: size.height.max(1),
35
+ depth: 1,
36
+ },
37
+ usage: gpu::TextureUsage::TARGET,
38
+ display_sync: gpu::DisplaySync::Recent,
39
+ ..Default::default()
40
+ }
41
+ }
42
+
43
+ struct Viewer {
44
+ context: Arc<gpu::Context>,
45
+ surface: gpu::Surface,
46
+ encoder: gpu::CommandEncoder,
47
+ view: GridView,
48
+ worker: Worker,
49
+ source: Box<dyn FrameSource>,
50
+ config: Config,
51
+ last_shown: u64,
52
+ frames: u64,
53
+ encodes: u64,
54
+ last_report: Instant,
55
+ window: winit::window::Window,
56
+ }
57
+
58
+ impl Viewer {
59
+ fn redraw(&mut self) {
60
+ // Feed the encoder whenever it is free. Unlike the headset there is
61
+ // no compositor to starve here, so it runs flat out.
62
+ if self.worker.is_ready()
63
+ && let Some(rgb) = self.source.next_frame()
64
+ {
65
+ let patches = dinovision::preprocess::patches_from_rgb8(rgb, &self.config);
66
+ self.worker.submit(patches);
67
+ }
68
+
69
+ let generation = self.worker.generation();
70
+ if generation != self.last_shown
71
+ && let Some(grid) = self.worker.latest()
72
+ {
73
+ self.view.upload(&self.context, grid.patch_rgb());
74
+ self.last_shown = generation;
75
+ self.encodes += 1;
76
+ }
77
+
78
+ let frame = self.surface.acquire_frame();
79
+ self.encoder.start();
80
+ self.encoder.init_texture(frame.texture());
81
+ {
82
+ let mut pass = self.encoder.render("view", gpu::RenderTargetSet {
83
+ colors: &[gpu::RenderTarget {
84
+ view: frame.texture_view(),
85
+ init_op: gpu::InitOp::DontCare,
86
+ finish_op: gpu::FinishOp::Store,
87
+ }],
88
+ depth_stencil: None,
89
+ });
90
+ self.view.draw(&mut pass, 0);
91
+ }
92
+ self.encoder.present(frame);
93
+ let _sp = self.context.submit(&mut self.encoder);
94
+
95
+ self.frames += 1;
96
+ if self.last_report.elapsed() >= Duration::from_secs(3) {
97
+ let secs = self.last_report.elapsed().as_secs_f64();
98
+ let latency = self
99
+ .worker
100
+ .latest()
101
+ .map(|g| g.latency_ms)
102
+ .unwrap_or(f64::NAN);
103
+ self.window.set_title(&format!(
104
+ "dinovision — {:.0} fps display, {:.1} Hz inference ({:.0} ms)",
105
+ self.frames as f64 / secs,
106
+ self.encodes as f64 / secs,
107
+ latency
108
+ ));
109
+ self.frames = 0;
110
+ self.encodes = 0;
111
+ self.last_report = Instant::now();
112
+ }
113
+ }
114
+ }
115
+
116
+ #[derive(Default)]
117
+ struct App {
118
+ viewer: Option<Viewer>,
119
+ weights: Option<std::path::PathBuf>,
120
+ decoder: Option<std::path::PathBuf>,
121
+ monitor: usize,
122
+ }
123
+
124
+ impl winit::application::ApplicationHandler for App {
125
+ fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {
126
+ if self.viewer.is_some() {
127
+ return;
128
+ }
129
+ let window = event_loop
130
+ .create_window(
131
+ winit::window::Window::default_attributes()
132
+ .with_title("dinovision — starting…")
133
+ .with_inner_size(winit::dpi::LogicalSize::new(720, 720)),
134
+ )
135
+ .expect("failed to create window");
136
+
137
+ let context = Arc::new(unsafe {
138
+ gpu::Context::init(gpu::ContextDesc {
139
+ presentation: true,
140
+ validation: false,
141
+ ..Default::default()
142
+ })
143
+ .expect("failed to initialize GPU context")
144
+ });
145
+
146
+ let surface = context
147
+ .create_surface_configured(&window, surface_config(window.inner_size()))
148
+ .expect("failed to create surface");
149
+
150
+ let config = Config::vits16().at_resolution(224);
151
+ // A reconstruction fills the whole image, so the display grid is the
152
+ // image size; PCA colouring only has one value per patch.
153
+ let display = match self.decoder.clone() {
154
+ Some(path) => {
155
+ log::info!("decoder: {}", path.display());
156
+ inference::Display::Reconstruction(path)
157
+ }
158
+ None => inference::Display::PcaColour,
159
+ };
160
+ let view_grid = match &display {
161
+ inference::Display::Reconstruction(_) => config.image_size,
162
+ inference::Display::PcaColour => config.grid(),
163
+ };
164
+ let view = GridView::new(&context, surface.info().format, view_grid);
165
+
166
+ let weights = match self.weights.clone() {
167
+ Some(p) => {
168
+ log::info!("weights: {}", p.display());
169
+ Weights::SafeTensors(p)
170
+ }
171
+ None => {
172
+ log::warn!("no checkpoint given — synthetic weights, colours are arbitrary");
173
+ Weights::Synthetic
174
+ }
175
+ };
176
+ // One submission: nothing else is competing for this GPU, so the
177
+ // chunking that matters on a headset would only cost overhead.
178
+ let worker = inference::spawn(Arc::clone(&context), config.clone(), weights, None, 1, display);
179
+
180
+ let source: Box<dyn FrameSource> = match dinovision::source::ScreenCapture::new(
181
+ config.image_size,
182
+ self.monitor,
183
+ ) {
184
+ Ok(s) => Box::new(s),
185
+ Err(e) => {
186
+ log::warn!("screen capture unavailable ({e}); falling back to the test pattern");
187
+ Box::new(dinovision::source::TestPattern::new(config.image_size))
188
+ }
189
+ };
190
+
191
+ let encoder = context.create_command_encoder(gpu::CommandEncoderDesc {
192
+ name: "viewer",
193
+ buffer_count: 2,
194
+ manual_barriers: false,
195
+ });
196
+
197
+ self.viewer = Some(Viewer {
198
+ context,
199
+ surface,
200
+ encoder,
201
+ view,
202
+ worker,
203
+ source,
204
+ config,
205
+ last_shown: 0,
206
+ frames: 0,
207
+ encodes: 0,
208
+ last_report: Instant::now(),
209
+ window,
210
+ });
211
+ }
212
+
213
+ fn about_to_wait(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) {
214
+ if let Some(v) = &self.viewer {
215
+ v.window.request_redraw();
216
+ }
217
+ }
218
+
219
+ fn window_event(
220
+ &mut self,
221
+ event_loop: &winit::event_loop::ActiveEventLoop,
222
+ _id: winit::window::WindowId,
223
+ event: winit::event::WindowEvent,
224
+ ) {
225
+ let Some(viewer) = self.viewer.as_mut() else {
226
+ return;
227
+ };
228
+ match event {
229
+ winit::event::WindowEvent::CloseRequested => event_loop.exit(),
230
+ winit::event::WindowEvent::KeyboardInput {
231
+ event:
232
+ winit::event::KeyEvent {
233
+ physical_key: winit::keyboard::PhysicalKey::Code(code),
234
+ state: winit::event::ElementState::Pressed,
235
+ ..
236
+ },
237
+ ..
238
+ } => match code {
239
+ winit::keyboard::KeyCode::Escape => event_loop.exit(),
240
+ // Refit the colour basis on demand: point the capture at
241
+ // something new and the old principal components will be a
242
+ // poor fit for it.
243
+ winit::keyboard::KeyCode::KeyR => {
244
+ log::info!("refitting colour basis");
245
+ viewer.worker.request_refit();
246
+ }
247
+ _ => {}
248
+ },
249
+ winit::event::WindowEvent::Resized(size) => {
250
+ viewer
251
+ .context
252
+ .reconfigure_surface(&mut viewer.surface, surface_config(size));
253
+ }
254
+ winit::event::WindowEvent::RedrawRequested => viewer.redraw(),
255
+ _ => {}
256
+ }
257
+ }
258
+ }
259
+
260
+ fn main() {
261
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
262
+
263
+ let mut args = std::env::args().skip(1);
264
+ let weights = args.next().map(std::path::PathBuf::from).filter(|p| {
265
+ let ok = p.exists();
266
+ if !ok {
267
+ log::warn!("{} does not exist; using synthetic weights", p.display());
268
+ }
269
+ ok
270
+ });
271
+ // A decoder file switches the view from PCA colour to a real RGB
272
+ // reconstruction.
273
+ let decoder = args.next().map(std::path::PathBuf::from).filter(|p| p.exists());
274
+ let monitor = args.next().and_then(|s| s.parse().ok()).unwrap_or(0);
275
+
276
+ println!("R refits the colour basis · Esc quits");
277
+
278
+ let event_loop = winit::event_loop::EventLoop::new().expect("failed to create event loop");
279
+ event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
280
+ event_loop
281
+ .run_app(&mut App {
282
+ viewer: None,
283
+ weights,
284
+ decoder,
285
+ monitor,
286
+ })
287
+ .expect("event loop failed");
288
+ }
environment/training-source/source-snapshot/experiments/AUDIT.md ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Audit record for the Meganeura case study
2
+
3
+ Date: 2026-08-01 (America/Los_Angeles)
4
+
5
+ ## Decision
6
+
7
+ The original `REPORT.md` is not admissible as paper evidence. It is a useful
8
+ engineering narrative, but several headline results were either evaluated on
9
+ training examples, measured with a biased timing protocol, or produced by an
10
+ incorrect DINOv3 graph. The case study can become paper material after the
11
+ corrected decoder training and Quest measurements in `experiments/README.md`
12
+ are complete.
13
+
14
+ The defensible case-study claim is deliberately narrow:
15
+
16
+ > A decoder graph is trained through Meganeura autodiff and Adam on the host,
17
+ > then its forward path is joined to a frozen DINOv3 encoder and compiled by
18
+ > the same Meganeura graph compiler/runtime for Vulkan inference in an Android
19
+ > XR application sharing Blade's graphics context and queue.
20
+
21
+ Training caches frozen encoder features and executes a batched decoder-only
22
+ graph. Deployment executes a batch-one joined encoder/decoder graph. They
23
+ share decoder construction, parameters, IR operations, compiler, and runtime;
24
+ they are not literally the same complete graph.
25
+
26
+ ## Findings that invalidate historical numbers
27
+
28
+ | Historical evidence | Audit finding | Disposition |
29
+ |---|---|---|
30
+ | Decoder PSNR in `REPORT.md` | The trainer reconstructed the first six cached training images and averaged their PSNR. There was no held-out split, per-image record, or seed replication. | Replaced by an upstream-defined Imagenette validation split, all 3,925 images, per-image PSNR/SSIM/MAE, exact hashes, and three initializations. |
31
+ | Isolated device timings | The benchmark used one untimed warmup and reported the minimum retained sample as throughput. Clock ramp and process-to-process state were not controlled. | Rerun with at least five warmups, 20 retained samples, median/IQR/raw samples, and three fresh processes in a declared headset state. |
32
+ | DINOv3 encoder correctness | The app passed `[tokens, hidden]` data to Meganeura's NCHW-flat `mul_per_channel` as if it broadcast over the trailing dimension. Only the first token received the intended LayerScale update; later token residual branches were effectively suppressed. | Fixed by transposing to `[hidden, tokens]`, flattening for the NCHW operator, applying the hidden gain with `spatial=tokens`, reshaping, and transposing back. All old decoders and device timings are invalid. |
33
+ | Live XR “inference Hz” | Two asynchronous eye completions were combined into one count, and the raw-camera toggle still submitted hidden inference. | App records each eye independently and true raw-camera mode bypasses the model. |
34
+ | 240-pixel, f16, and operation-share conclusions | These were microbenchmark observations or extrapolations, not matched end-to-end experiments or a deployed-graph profile. | Exclude causal or “closed” claims unless the matched experiments are run. |
35
+
36
+ The invalid pre-fix decoder runs are preserved locally under
37
+ `C:\tmp\dinovision-runs`; they must not be uploaded or cited. The corrected
38
+ matrix uses a separate root so no artifact can be mistaken for a valid run.
39
+
40
+ ## Independent encoder validation after the fix
41
+
42
+ The reference is `facebook/dinov3-vits16-pretrain-lvd1689m`, executed in
43
+ Torch 2.13.0+cpu and Transformers 5.14.1 from the same deterministic normalized
44
+ pixel tensor and exact checkpoint. Meganeura uses f32 scalar matmul on the RTX
45
+ 3050 for this comparison. Thresholds were declared before the corrected run:
46
+ relative L2 at most 0.01, CLS cosine above 0.999, and every patch-token cosine
47
+ above 0.999.
48
+
49
+ | Depth | Relative L2 | CLS cosine | Worst patch cosine | Result |
50
+ |---:|---:|---:|---:|---|
51
+ | 1 layer | 0.000781 | 1.000000 | 0.999990 | pass |
52
+ | 3 layers (deployed) | 0.001403 | 1.000000 | 0.999995 | pass |
53
+ | 12 layers (upstream depth) | 0.002253 | 0.999997 | 0.999996 | pass |
54
+
55
+ At the first layer, embeddings, LayerNorm, Q/K/V projections, and RoPE agree
56
+ to the displayed six decimal places. Attention differs by 0.005852 relative
57
+ L2; after learned LayerScale the residual differs by 0.000566. Exact-shape
58
+ tests independently compare Meganeura attention and the corrected trailing
59
+ LayerScale broadcast against CPU implementations.
60
+
61
+ ## Evidence still required
62
+
63
+ - Three corrected decoder initializations and full held-out photo metrics.
64
+ - A public decoder artifact whose metadata binds it to the corrected source,
65
+ DINO checkpoint hash, dataset-manifest hash, depth, resolution, and seed.
66
+ - Host/Quest equality on preprocessed patches, encoder features, spatial
67
+ decoder input, and reconstruction for one fixed public frame.
68
+ - Fresh isolated Quest timings and live-worn XR chunk sweep from the corrected
69
+ graph, with raw samples and state snapshots.
70
+ - Clean, immutable DinoVision and Meganeura revisions. The current worktree is
71
+ a development freeze candidate, not yet a citable revision.
72
+
73
+ Until those items are complete, the correct paper status is **promising but
74
+ not merge-ready**.
environment/training-source/source-snapshot/experiments/README.md ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DinoVision paper artifact protocol
2
+
3
+ This experiment is a deployment case study for Meganeura, not an additional
4
+ cell in the paper's matched Meganeura/PyTorch performance-portability matrix.
5
+ Its claim is narrower and complementary:
6
+
7
+ > One Meganeura graph definition trains an RGB decoder through autodiff on an
8
+ > RTX GPU and deploys its forward path, joined to a DINOv3 encoder, through the
9
+ > same compiler and Vulkan runtime on an Adreno-based Android XR device. The
10
+ > application shares Blade's graphics context and queue with inference.
11
+
12
+ The artifact must distinguish that demonstrated claim from hypotheses about
13
+ decoder quality, f16 arithmetic, zero-copy integration, or camera perception.
14
+
15
+ ## Frozen revisions and assets
16
+
17
+ Every run directory contains:
18
+
19
+ - clean full Git revisions for DinoVision, Meganeura, and the Blade revision
20
+ selected by Cargo;
21
+ - `Cargo.lock`, `cargo tree`, Rust/NDK/ADB versions, GPU/driver, Horizon build,
22
+ refresh configuration, thermal state, and all runtime toggle values;
23
+ - SHA-256 hashes for the upstream DINO checkpoint and each decoder;
24
+ - the immutable dataset manifest and the exact command line;
25
+ - raw timing or per-image records, followed by separately generated summary
26
+ tables and figures.
27
+
28
+ The canonical DINO checkpoint remains an upstream dependency rather than being
29
+ duplicated. Meta's DINOv3 License permits redistribution only under that
30
+ agreement and requires a copy of it, a prominent “Built with DINOv3” notice,
31
+ and acknowledgment in resulting research. The artifact links to the gated
32
+ canonical model and preserves its hash. Decoder weights are treated
33
+ conservatively as DINOv3-derived: their model repository carries the DINOv3
34
+ agreement and notice rather than presenting the weights as unconditionally
35
+ MIT-licensed. The DinoVision source remains MIT. Generated decoder weights are
36
+ published with their architecture, depth, resolution, dataset-manifest hash,
37
+ and training metadata.
38
+
39
+ ## Dataset validity
40
+
41
+ Generate `dataset.json` with `tools/make_dataset_manifest.py`. A `group` is a
42
+ leakage boundary: one headset capture session, room/lighting traversal, or
43
+ photo collection. A group may occur in exactly one of `train`, `validation`,
44
+ and `test`. Identical file hashes may not cross splits.
45
+
46
+ The paper-quality dataset should contain at least:
47
+
48
+ - an Imagenette/photo training source;
49
+ - multiple independent headset capture sessions for training;
50
+ - held-out capture sessions from different trajectories and lighting for
51
+ validation and test;
52
+ - a separately reported held-out photo split, so domain adaptation is not
53
+ hidden inside one aggregate.
54
+
55
+ Sequential camera frames must never be randomly divided across splits.
56
+ Create each capture group under its own immutable session name by writing that
57
+ name into the device's `capture` flag before launching the app, for example
58
+ `echo room-a-01 > /data/local/tmp/dinovision/capture`. The app refuses to
59
+ overwrite a non-empty session directory.
60
+
61
+ ## Reconstruction evaluation
62
+
63
+ Use `examples/evaluate_decoder.rs`. It verifies all input hashes and emits
64
+ per-image MSE, MAE, PSNR, and RGB SSIM, plus global PSNR and distributions.
65
+ The sample PNGs place the target on the left and reconstruction on the right;
66
+ the same directory retains raw preprocessed patches, the full encoder output,
67
+ the spatial decoder input, and decoder output for numerical cross-device
68
+ comparison.
69
+
70
+ The deployment artifact repeats its selected `3 layers @ 224` cell with three
71
+ decoder initializations. If the report retains comparative depth or resolution
72
+ claims, extend that into this matched matrix, again with three seeds per cell:
73
+
74
+ | Encoder | Resolution | Decoder | Purpose |
75
+ |---|---:|---|---|
76
+ | 12 layers | 224 | early two-stage blend | official final representation |
77
+ | 3 layers | 224 | early two-stage blend | deployed latency choice |
78
+ | 3 layers | 240 | retargeted and retrained | test the tail-waste hypothesis end to end |
79
+
80
+ If the report retains claims about L1, constant learning rate, or the number of
81
+ blend stages, those become matched ablations under the same manifests and
82
+ seeds. Otherwise they are described as implementation choices, not findings.
83
+
84
+ Example:
85
+
86
+ ```powershell
87
+ python tools/make_dataset_manifest.py `
88
+ --root C:\Data\dinovision `
89
+ --output experiments\dataset.json `
90
+ --entry train photo imagenette-train imagenette\train `
91
+ --entry train capture room-a captures\room-a `
92
+ --entry test capture room-b captures\room-b
93
+
94
+ cargo run --release --example evaluate_decoder -- `
95
+ --manifest experiments\dataset.json `
96
+ --model model.safetensors --decoder decoder.bin `
97
+ --split test --layers 3 --output artifacts\quality-3l-224.json
98
+ ```
99
+
100
+ Training uses a deterministic, seed-controlled shuffle and writes its decoder,
101
+ diagnostic strip, and `training.json` into the requested output directory:
102
+
103
+ ```powershell
104
+ cargo run --release --example train_decoder -- `
105
+ experiments\dataset.json model.safetensors `
106
+ 12000 2500 3 224 0 artifacts\train-3l-224-seed0
107
+ ```
108
+
109
+ ## Timing validity
110
+
111
+ Isolated native benchmarks and live-XR co-tenancy are separate experiments.
112
+
113
+ For isolated kernel/encoder timings:
114
+
115
+ - use five or more untimed warmups and at least 20 retained synchronized
116
+ samples;
117
+ - report median, IQR, minimum, maximum, and every raw sample;
118
+ - stabilize the headset in one power/display state before the matrix;
119
+ - randomize or bracket variants rather than relying on monotonic clock ramp;
120
+ - repeat the matrix in at least three fresh processes.
121
+
122
+ For the XR application, retain every `DINOVISION_APP_JSON` record and report:
123
+
124
+ - application render-submission rate;
125
+ - each eye's independent update rate and the lower of the two rates;
126
+ - per-worker wall latency, explicitly not capture-to-photon latency;
127
+ - mono/stereo, chunk count, inference interval, display refresh, and duration.
128
+
129
+ Sweep `submission_chunks = 1, 2, 4, 8, 12, 16` at a fixed interval in the
130
+ predeclared order `4, 16, 1, 12, 2, 8`, rather than coupling chunk count to
131
+ startup or thermal drift. Report both isolated throughput cost and live
132
+ render/update tradeoff. Run long enough to expose sustained thermal behavior.
133
+ App submission rate must not be called compositor or display rate.
134
+
135
+ ## Correctness
136
+
137
+ Before performance or quality results are admitted:
138
+
139
+ 1. run the Hugging Face reference comparison and retain its tensors/output;
140
+ 2. run the same fixed raw image and weights through `evaluate_decoder` on RTX
141
+ and Adreno, retaining `*-patches.f32`, `*-encoder.f32`, `*-features.f32`,
142
+ and `*-reconstruction.f32`;
143
+ 3. report relative output L2, maximum absolute error, and per-token cosine;
144
+ 4. confirm decoder output agreement across the two devices;
145
+ 5. run `cargo test --workspace --all-targets`, with the real-weight semantic
146
+ test enabled rather than silently skipped.
147
+
148
+ Use `tools/compare_f32.py` for each host/device tensor pair. It records both
149
+ input hashes, relative L2, cosine similarity, mean/max absolute error, and the
150
+ explicit pass thresholds rather than relying on a console-only comparison.
151
+
152
+ ## Interpretation rules
153
+
154
+ - A microbenchmark may explain a hypothesis, but only a profile of the
155
+ deployed graph may assign wall time to an operation class.
156
+ - f16 weight storage says nothing conclusive about unimplemented f16
157
+ arithmetic.
158
+ - The 240-pixel encoder result is not an end-to-end result until its decoder,
159
+ preprocessing, readback, and rendering path are included.
160
+ - Direct GPU output is future work until output lifetime, EMA, layout, and
161
+ synchronization are implemented and measured.
162
+ - Stereo fusion, comfort, camera rate, and capture-to-photon claims require
163
+ timestamps/calibration or are labeled single-user observations.
environment/training-source/source-snapshot/huggingface/DINOv3-LICENSE.md ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DINOv3 License
2
+
3
+ *Last Updated: August 19, 2025*
4
+
5
+ **“Agreement”** means the terms and conditions for use, reproduction, distribution and modification of the DINO Materials set forth herein.
6
+
7
+ **“DINO Materials”** means, collectively, Documentation and the models, software and algorithms, including machine-learning model code, trained model weights, inference-enabling code, training-enabling code, fine-tuning enabling code, and other elements of the foregoing distributed by Meta and made available under this Agreement.
8
+
9
+ **“Documentation”** means the specifications, manuals and documentation accompanying
10
+ DINO Materials distributed by Meta.
11
+
12
+ **“Licensee”** or **“you”** means you, or your employer or any other person or entity (if you are entering into this Agreement on such person or entity’s behalf), of the age required under applicable laws, rules or regulations to provide legal consent and that has legal authority to bind your employer or such other person or entity if you are entering in this Agreement on their behalf.
13
+
14
+ **“Meta”** or **“we”** means Meta Platforms Ireland Limited (if you are located in or, if you are an entity, your principal place of business is in the EEA or Switzerland) or Meta Platforms, Inc. (if you are located outside of the EEA or Switzerland).
15
+
16
+ **“Sanctions”** means any economic or trade sanctions or restrictions administered or enforced by the United States (including the Office of Foreign Assets Control of the U.S. Department of the Treasury (“OFAC”), the U.S. Department of State and the U.S. Department of Commerce), the United Nations, the European Union, or the United Kingdom.
17
+
18
+ **“Trade Controls”** means any of the following: Sanctions and applicable export and import controls.
19
+
20
+ By clicking “I Accept” below or by using or distributing any portion or element of the DINO Materials, you agree to be bound by this Agreement.
21
+
22
+ ## 1. License Rights and Redistribution.
23
+
24
+ a. <ins>Grant of Rights</ins>. You are granted a non-exclusive, worldwide, non-transferable and royalty-free limited license under Meta’s intellectual property or other rights owned by Meta embodied in the DINO Materials to use, reproduce, distribute, copy, create derivative works of, and make modifications to the DINO Materials.
25
+
26
+ b. <ins>Redistribution and Use</ins>.
27
+
28
+ i. Distribution of DINO Materials, and any derivative works thereof, are subject to the terms of this Agreement. If you distribute or make the DINO Materials, or any derivative works thereof, available to a third party, you may only do so under the terms of this Agreement and you shall provide a copy of this Agreement with any such DINO Materials.
29
+
30
+ ii. If you submit for publication the results of research you perform on, using, or otherwise in connection with DINO Materials, you must acknowledge the use of DINO Materials in your publication.
31
+
32
+ iii. Your use of the DINO Materials must comply with applicable laws and regulations, including Trade Control Laws and applicable privacy and data protection laws.
33
+
34
+ iv. Your use of the DINO Materials will not involve or encourage others to reverse engineer, decompile or discover the underlying components of the DINO Materials.
35
+
36
+ v. You are not the target of Trade Controls and your use of DINO Materials must comply with Trade Controls. You agree not to use, or permit others to use, DINO Materials for any activities subject to the International Traffic in Arms Regulations (ITAR) or end uses prohibited by Trade Controls, including those related to military or warfare purposes, nuclear industries or applications, espionage, or the development or use of guns or illegal weapons.
37
+
38
+ ## 2. User Support.
39
+
40
+ Your use of the DINO Materials is done at your own discretion; Meta does not process any information nor provide any service in relation to such use. Meta is under no obligation to provide any support services for the DINO Materials. Any support provided is “as is”, “with all faults”, and without warranty of any kind.
41
+
42
+ ## 3. Disclaimer of Warranty.
43
+
44
+ UNLESS REQUIRED BY APPLICABLE LAW, THE DINO MATERIALS AND ANY OUTPUT AND RESULTS THEREFROM ARE PROVIDED ON AN “AS IS” BASIS, WITHOUT WARRANTIES OF ANY KIND, AND META DISCLAIMS ALL WARRANTIES OF ANY KIND, BOTH EXPRESS AND IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. YOU ARE SOLELY RESPONSIBLE FOR DETERMINING THE APPROPRIATENESS OF USING OR REDISTRIBUTING THE DINO MATERIALS AND ASSUME ANY RISKS ASSOCIATED WITH YOUR USE OF THE DINO MATERIALS AND ANY OUTPUT AND RESULTS.
45
+
46
+ ## 4. Limitation of Liability.
47
+
48
+ IN NO EVENT WILL META OR ITS AFFILIATES BE LIABLE UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, TORT, NEGLIGENCE, PRODUCTS LIABILITY, OR OTHERWISE, ARISING OUT OF THIS AGREEMENT, FOR ANY LOST PROFITS OR ANY DIRECT OR INDIRECT, SPECIAL, CONSEQUENTIAL, INCIDENTAL, EXEMPLARY OR PUNITIVE DAMAGES, EVEN IF META OR ITS AFFILIATES HAVE BEEN ADVISED OF THE POSSIBILITY OF ANY OF THE FOREGOING.
49
+
50
+ ## 5. Intellectual Property.
51
+
52
+ a. Subject to Meta’s ownership of DINO Materials and derivatives made by or for Meta, with respect to any derivative works and modifications of the DINO Materials that are made by you, as between you and Meta, you are and will be the owner of such derivative works and modifications.
53
+
54
+ b. If you institute litigation or other proceedings against Meta or any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the DINO Materials, outputs or results, or any portion of any of the foregoing, constitutes infringement of intellectual property or other rights owned or licensable by you, then any licenses granted to you under this Agreement shall terminate as of the date such litigation or claim is filed or instituted. You will indemnify and hold harmless Meta from and against any claim by any third party arising out of or related to your use or distribution of the DINO Materials.
55
+
56
+ ## 6. Term and Termination.
57
+
58
+ The term of this Agreement will commence upon your acceptance of this Agreement or access to the DINO Materials and will continue in full force and effect until terminated in accordance with the terms and conditions herein. Meta may terminate this Agreement if you are in breach of any term or condition of this Agreement. Upon termination of this Agreement, you shall delete and cease use of the DINO Materials. Sections 3, 4 and 7 shall survive the termination of this Agreement.
59
+
60
+ ## 7. Governing Law and Jurisdiction.
61
+
62
+ This Agreement will be governed and construed under the laws of the State of California without regard to choice of law principles, and the UN Convention on Contracts for the International Sale of Goods does not apply to this Agreement. The courts of California shall have exclusive jurisdiction of any dispute arising out of this Agreement.
63
+
64
+ ## 8. Modifications and Amendments.
65
+
66
+ Meta may modify this Agreement from time to time; provided that they are similar in spirit to the current version of the Agreement, but may differ in detail to address new problems or concerns. All such changes will be effective immediately. Your continued use of the DINO Materials after any modification to this Agreement constitutes your agreement to such modification. Except as provided in this Agreement, no modification or addition to any provision of this Agreement will be binding unless it is in writing and signed by an authorized representative of both you and Meta.
environment/training-source/source-snapshot/huggingface/NOTICE.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Built with DINOv3
2
+
3
+ The DinoVision decoder weights were trained from features produced by Meta's
4
+ DINOv3 ViT-S/16 checkpoint. The encoder checkpoint is not redistributed in
5
+ this artifact. Obtain it from the canonical gated repository and comply with
6
+ the DINOv3 License included as `DINOv3-LICENSE.md`.
7
+
8
+ DinoVision source code is MIT-licensed. The learned decoder weights are
9
+ treated conservatively as DINOv3-derived and distributed under the DINOv3
10
+ agreement.
11
+
12
+ `DINOv3-LICENSE.md` is the upstream DINOv3 repository's agreement (SHA-256
13
+ `25d122eb8f5b880fd23c736fb6ea8018ee45c12237e00b8a86d14c653904999e`).
14
+ Meta may amend the agreement; consult the
15
+ [current official terms](https://ai.meta.com/resources/models-and-libraries/dinov3-license/)
16
+ as well. The live terms require the prominent notice above in addition to a
17
+ copy of the agreement and acknowledgment in research publications.
environment/training-source/source-snapshot/huggingface/README.md ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ license_name: DINOv3 License
4
+ license_link: https://ai.meta.com/resources/models-and-libraries/dinov3-license/
5
+ library_name: meganeura
6
+ pipeline_tag: image-to-image
7
+ base_model: facebook/dinov3-vits16-pretrain-lvd1689m
8
+ tags:
9
+ - dinov3
10
+ - meganeura
11
+ - vulkan
12
+ - android
13
+ - image-reconstruction
14
+ ---
15
+
16
+ # DinoVision decoder
17
+
18
+ > **Built with DINOv3**
19
+
20
+ This repository contains the RGB decoder weights and audited records for the
21
+ DinoVision Meganeura case study. The decoder is trained on the host with
22
+ Meganeura autodiff and Adam, then joined to a frozen three-layer DINOv3
23
+ ViT-S/16 encoder for Vulkan inference on Android XR.
24
+
25
+ The artifact is not a standalone Hugging Face image-to-image pipeline. Use it
26
+ with [DinoVision](https://github.com/kvark/dinovision) and
27
+ [Meganeura](https://github.com/kvark/meganeura).
28
+
29
+ ## Required base model
30
+
31
+ The Meta encoder checkpoint is deliberately not duplicated here. Obtain the
32
+ gated
33
+ [`facebook/dinov3-vits16-pretrain-lvd1689m`](https://huggingface.co/facebook/dinov3-vits16-pretrain-lvd1689m)
34
+ checkpoint after accepting its terms.
35
+
36
+ | Item | Value |
37
+ |---|---|
38
+ | Encoder checkpoint SHA-256 | `4610ad75edef83e75afdebf162d148dc628045ea6cbb83d67d4708c709c4f91d` |
39
+ | Encoder depth | first 3 of 12 layers |
40
+ | Input | normalized RGB, 224 by 224 |
41
+ | Encoder patch grid | 14 by 14, 384 channels |
42
+ | Decoder parameters | 2,012,547 |
43
+ | Decoder output | RGB, 224 by 224, sigmoid range |
44
+
45
+ ## Selected artifact
46
+
47
+ Seed 0 is selected by a rule fixed before validation; it is not selected as
48
+ the best of the three runs.
49
+
50
+ <!-- CORRECTED_RESULTS_START -->
51
+
52
+ Corrected three-seed quality results will be inserted here after the frozen
53
+ matrix completes. No pre-audit decoder is published.
54
+
55
+ <!-- CORRECTED_RESULTS_END -->
56
+
57
+ `decoder.bin` is the runtime format: little-endian f32 parameters in
58
+ Meganeura graph-declaration order. `decoder.safetensors` contains the same
59
+ values as 26 named tensors and records the encoder depth, resolution, seed,
60
+ base-model hash, and dataset-manifest hash. The SafeTensors conversion checks
61
+ every tensor for exact round-trip equality.
62
+
63
+ ## Training and evaluation
64
+
65
+ Each replicate uses 2,500 class-interleaved Imagenette training images (250
66
+ from each of ten classes),
67
+ 12,000 batch-8 updates, mean absolute error, and Adam with a linearly decayed
68
+ learning rate from 0.002 to 0.0001 over the first 95% of updates and a 0.0001
69
+ floor thereafter. Seeds independently control parameter initialization and
70
+ data order.
71
+
72
+ Evaluation processes all 3,925 images in the upstream Imagenette validation
73
+ split. Every file is verified against an immutable manifest. The artifact
74
+ retains per-image MSE, MAE, PSNR, and RGB SSIM (11 by 11 Gaussian window,
75
+ sigma 1.5), plus aggregate distributions. Training wall time is excluded as a
76
+ performance result because the interactive host was not controlled.
77
+
78
+ The previous DinoVision graph misapplied DINOv3 LayerScale and is invalid.
79
+ The corrected three-layer Meganeura encoder was compared with an independent
80
+ Torch 2.13.0+cpu / Transformers 5.14.1 execution of the canonical checkpoint:
81
+
82
+ | Depth | Relative L2 | CLS cosine | Worst patch cosine |
83
+ |---:|---:|---:|---:|
84
+ | 3 layers | 0.001403 | 1.000000 | 0.999995 |
85
+ | 12 layers | 0.002253 | 0.999997 | 0.999996 |
86
+
87
+ ## File layout
88
+
89
+ - `decoder.bin`, `decoder.safetensors`, `training.json`: selected seed 0.
90
+ - `replicates/`: all seeded decoder weights and immutable training records.
91
+ - `quality/`: per-image held-out records and generated aggregate summaries.
92
+ - `manifests/`: public dataset and correctness manifests.
93
+ - `correctness/`: independent-implementation and host/device numerical checks.
94
+ - `benchmarks/`: raw and summarized corrected Quest measurements, when present.
95
+ - `environment/`: source/revision, dependency, toolchain, and device metadata.
96
+
97
+ Target/reconstruction PNGs from Imagenette are not redistributed. Private
98
+ headset captures are also excluded. The fixed synthetic correctness frame is
99
+ public and is included with its hash.
100
+
101
+ ## Intended use and limitations
102
+
103
+ These weights support research reproduction of a systems case study. They are
104
+ not intended for photographic restoration, surveillance, identity inference,
105
+ medical use, or safety-critical perception. Reconstruction quality is
106
+ evaluated on held-out photographs, not independent headset capture sessions.
107
+ Worker latency is not capture-to-photon latency, and the artifact does not
108
+ claim a PyTorch-on-Android speedup.
109
+
110
+ ## License and acknowledgment
111
+
112
+ The DinoVision source code is MIT-licensed. These learned weights are treated
113
+ conservatively as DINOv3-derived and distributed under the included
114
+ `DINOv3-LICENSE.md`. Use of the required base model and these artifacts is
115
+ subject to that agreement. Research using this artifact must acknowledge
116
+ DINOv3; see the canonical model card for the official citation. The included
117
+ agreement is preserved byte-for-byte from the upstream DINOv3 repository; the
118
+ [live Meta terms](https://ai.meta.com/resources/models-and-libraries/dinov3-license/)
119
+ should also be consulted because they may be amended.
120
+
121
+ ```bibtex
122
+ @misc{simeoni2025dinov3,
123
+ title = {{DINOv3}},
124
+ author = {Sim{\'e}oni, Oriane and Vo, Huy V. and Seitzer, Maximilian and others},
125
+ year = {2025},
126
+ eprint = {2508.10104},
127
+ archivePrefix = {arXiv},
128
+ primaryClass = {cs.CV},
129
+ url = {https://arxiv.org/abs/2508.10104}
130
+ }
131
+ ```
environment/training-source/source-snapshot/src/bench.rs ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Throughput measurement, shared by the desktop and on-device entry
2
+ //! points.
3
+ //!
4
+ //! The question this exists to answer: the Adreno in a Quest 3 is not
5
+ //! expected to expose `VK_KHR_cooperative_matrix`, so meganeura will fall
6
+ //! back to its register-tiled scalar matmul. Whether the encoder then
7
+ //! lands at 20 ms or 200 ms per frame decides the input resolution, the
8
+ //! inference rate, and whether the render loop has to be decoupled from
9
+ //! inference (it almost certainly does).
10
+ //!
11
+ //! Everything here reports achieved GFLOP/s next to the theoretical MAC
12
+ //! count, so a number that looks fast can be checked against what the
13
+ //! device could possibly do.
14
+
15
+ use std::sync::Arc;
16
+ use std::time::Instant;
17
+
18
+ use meganeura::graph::Op;
19
+ use meganeura::train::{Mode, SessionConfig};
20
+ use meganeura::{Graph, Session};
21
+
22
+ use crate::dinov3::Config;
23
+
24
+ /// One timing result.
25
+ #[derive(Debug, Clone)]
26
+ pub struct Timing {
27
+ pub label: String,
28
+ /// Minimum retained sample. Useful diagnostically, but not the primary
29
+ /// result: selecting the minimum biases comparisons on a noisy device.
30
+ pub best_ms: f64,
31
+ pub mean_ms: f64,
32
+ pub median_ms: f64,
33
+ pub p25_ms: f64,
34
+ pub p75_ms: f64,
35
+ pub max_ms: f64,
36
+ /// Every retained synchronized wall-time observation.
37
+ pub samples_ms: Vec<f64>,
38
+ /// Multiply-accumulates per iteration, for the GFLOP/s figure.
39
+ pub macs: u64,
40
+ }
41
+
42
+ impl Timing {
43
+ /// Median achieved GFLOP/s, counting a multiply-accumulate as two
44
+ /// operations.
45
+ pub fn gflops(&self) -> f64 {
46
+ (self.macs as f64 * 2.0) / (self.median_ms / 1000.0) / 1e9
47
+ }
48
+
49
+ /// One self-contained JSON record suitable for retaining from logcat.
50
+ pub fn json_line(&self) -> String {
51
+ let label = self.label.replace('\\', "\\\\").replace('"', "\\\"");
52
+ let samples = self
53
+ .samples_ms
54
+ .iter()
55
+ .map(|v| format!("{v:.6}"))
56
+ .collect::<Vec<_>>()
57
+ .join(",");
58
+ format!(
59
+ "{{\"schema_version\":1,\"kind\":\"dinovision_benchmark\",\
60
+ \"label\":\"{label}\",\"macs\":{},\"median_ms\":{:.6},\
61
+ \"p25_ms\":{:.6},\"p75_ms\":{:.6},\"min_ms\":{:.6},\
62
+ \"max_ms\":{:.6},\"mean_ms\":{:.6},\"samples_ms\":[{samples}]}}",
63
+ self.macs,
64
+ self.median_ms,
65
+ self.p25_ms,
66
+ self.p75_ms,
67
+ self.best_ms,
68
+ self.max_ms,
69
+ self.mean_ms,
70
+ )
71
+ }
72
+ }
73
+
74
+ impl std::fmt::Display for Timing {
75
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76
+ write!(
77
+ f,
78
+ "{:<28} median {:>8.2} ms IQR [{:>7.2}, {:>7.2}] min/max [{:>7.2}, {:>7.2}] {:>7.1} GFLOP/s",
79
+ self.label,
80
+ self.median_ms,
81
+ self.p25_ms,
82
+ self.p75_ms,
83
+ self.best_ms,
84
+ self.max_ms,
85
+ self.gflops()
86
+ )
87
+ }
88
+ }
89
+
90
+ /// Log the device identity and whether cooperative matrix is usable.
91
+ ///
92
+ /// This is the single most important line of the whole benchmark: it
93
+ /// determines which matmul kernel every subsequent number came from.
94
+ pub fn describe_device(gpu: &blade_graphics::Context) {
95
+ let info = gpu.device_information();
96
+ log::info!("device : {}", info.device_name);
97
+ log::info!("driver : {} {}", info.driver_name, info.driver_info);
98
+
99
+ let caps = meganeura::runtime::auto_tune(gpu, 0).coop_caps;
100
+ if caps.f16_tile == 0 && caps.f32_tile == 0 {
101
+ log::warn!("coop matrix : NOT AVAILABLE — expect the register-tiled scalar matmul");
102
+ } else {
103
+ log::info!(
104
+ "coop matrix : available (f32 tile {}, f16 tile {})",
105
+ caps.f32_tile,
106
+ caps.f16_tile
107
+ );
108
+ }
109
+ }
110
+
111
+ /// Fill every parameter with small deterministic pseudo-random values.
112
+ ///
113
+ /// Timing is not sensitive to weight *values*, but leaving parameters at
114
+ /// zero would make every activation zero, and a benchmark whose data is
115
+ /// entirely one value invites doubt about data-dependent fast paths.
116
+ /// Cheap insurance.
117
+ pub fn fill_parameters(session: &mut Session, graph: &Graph) {
118
+ let mut state = 0x2545_F491_4F6C_DD1Du64;
119
+ let mut scratch = Vec::new();
120
+ for node in graph.nodes() {
121
+ let Op::Parameter { name } = &node.op else {
122
+ continue;
123
+ };
124
+ scratch.clear();
125
+ scratch.reserve(node.ty.num_elements());
126
+ for _ in 0..node.ty.num_elements() {
127
+ // xorshift64*, inlined to avoid a dependency.
128
+ state ^= state >> 12;
129
+ state ^= state << 25;
130
+ state ^= state >> 27;
131
+ let bits = state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40;
132
+ scratch.push((bits as f32 / (1u32 << 24) as f32 - 0.5) * 0.05);
133
+ }
134
+ session.set_parameter(name, &scratch);
135
+ }
136
+ }
137
+
138
+ pub(crate) fn time_session(session: &mut Session, label: &str, macs: u64, iters: usize) -> Timing {
139
+ // Five is the paper protocol's minimum. The environment override exists
140
+ // for diagnostic sweeps, and is recorded alongside the raw samples by
141
+ // the calling artifact script.
142
+ let warmups = std::env::var("DINOVISION_WARMUPS")
143
+ .ok()
144
+ .and_then(|s| s.parse::<usize>().ok())
145
+ .unwrap_or(5);
146
+ for _ in 0..warmups {
147
+ session.step();
148
+ session.wait();
149
+ }
150
+
151
+ let mut samples_ms = Vec::with_capacity(iters.max(1));
152
+ for _ in 0..iters.max(1) {
153
+ let start = Instant::now();
154
+ session.step();
155
+ session.wait();
156
+ samples_ms.push(start.elapsed().as_secs_f64() * 1000.0);
157
+ }
158
+
159
+ let mut sorted = samples_ms.clone();
160
+ sorted.sort_by(f64::total_cmp);
161
+ let quantile = |q: f64| {
162
+ let at = q * (sorted.len() - 1) as f64;
163
+ let lo = at.floor() as usize;
164
+ let hi = at.ceil() as usize;
165
+ sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo as f64)
166
+ };
167
+ Timing {
168
+ label: label.to_string(),
169
+ best_ms: sorted[0],
170
+ mean_ms: samples_ms.iter().sum::<f64>() / samples_ms.len() as f64,
171
+ median_ms: quantile(0.5),
172
+ p25_ms: quantile(0.25),
173
+ p75_ms: quantile(0.75),
174
+ max_ms: sorted[sorted.len() - 1],
175
+ samples_ms,
176
+ macs,
177
+ }
178
+ }
179
+
180
+ /// Time a bare `[m, k] @ [k, n]` matmul.
181
+ ///
182
+ /// Run at the shapes the encoder actually uses, so the result is an upper
183
+ /// bound on what the encoder could reach rather than a peak-throughput
184
+ /// number from an unrepresentatively large matrix.
185
+ pub fn matmul_throughput(
186
+ gpu: Arc<blade_graphics::Context>,
187
+ m: usize,
188
+ k: usize,
189
+ n: usize,
190
+ iters: usize,
191
+ ) -> Timing {
192
+ let mut g = Graph::new();
193
+ let a = g.input("a", &[m, k]);
194
+ let b = g.parameter("b", &[k, n]);
195
+ let y = g.matmul(a, b);
196
+ g.set_outputs(vec![y]);
197
+
198
+ let (mut session, _) = meganeura::train::build(
199
+ &g,
200
+ SessionConfig {
201
+ mode: Mode::Inference,
202
+ gpu: Some(gpu),
203
+ ..Default::default()
204
+ },
205
+ );
206
+ fill_parameters(&mut session, &g);
207
+ session.set_input("a", &vec![0.01f32; m * k]);
208
+
209
+ time_session(
210
+ &mut session,
211
+ &format!("matmul {m}x{k}x{n}"),
212
+ (m * k * n) as u64,
213
+ iters,
214
+ )
215
+ }
216
+
217
+ /// Sweep matmul shapes to find what the kernel is actually short of.
218
+ ///
219
+ /// The encoder runs at ~67 GFLOP/s on device and an isolated matmul at its
220
+ /// own shapes reaches ~120, but the same register-tiled kernel only reaches
221
+ /// ~12% of peak on a desktop GPU too. An inefficiency that follows the
222
+ /// kernel across two unrelated architectures is not an ALU-rate problem,
223
+ /// which is what would have made f16 arithmetic worth writing. So measure
224
+ /// the three things that could be costing the rest, each isolated:
225
+ ///
226
+ /// * **Tail waste.** `m` is 201 — 196 patches, CLS, 4 registers — against
227
+ /// `BM = 64`, so the last tile row is 9/64 useful and 21% of every
228
+ /// dispatch is padding. Padding `m` to 256 does 27% more arithmetic; if
229
+ /// the wall time does not move, we were paying for it already.
230
+ /// * **Occupancy.** Tiles are 64×64 with `KTILE = 32`, so a workgroup holds
231
+ /// 16.4 KB of shared memory and few stay resident. Widening `n` at fixed
232
+ /// cost per workgroup raises the workgroup count; if throughput climbs
233
+ /// with it, the device is starved rather than saturated. This is also the
234
+ /// one thing f16 would genuinely fix, by halving the LDS footprint.
235
+ /// * **Prologue cost.** `k = 384` is 12 k-tiles, so the per-tile load and
236
+ /// double barrier amortize over very few iterations. Deepening `k` at the
237
+ /// same output size isolates that.
238
+ ///
239
+ /// The large square shapes at the end give the kernel's ceiling with every
240
+ /// one of those effects removed, which is the number f16 arithmetic would
241
+ /// have to improve on to be worth the work.
242
+ pub fn matmul_shapes(gpu: Arc<blade_graphics::Context>, iters: usize) -> Vec<Timing> {
243
+ let mut results = Vec::new();
244
+ for (m, k, n, note) in [
245
+ (201, 384, 1536, "encoder MLP up, as it runs"),
246
+ (256, 384, 1536, "m padded to a whole tile: +27% MACs"),
247
+ (201, 384, 384, "24 workgroups"),
248
+ (201, 384, 1536, "96 workgroups"),
249
+ (201, 384, 6144, "384 workgroups"),
250
+ (201, 1536, 1536, "4x the k-tiles per workgroup"),
251
+ (512, 512, 512, "square, no tail"),
252
+ (1024, 1024, 1024, "square, no tail"),
253
+ (2048, 2048, 2048, "square, kernel ceiling"),
254
+ ] {
255
+ let t = matmul_throughput(gpu.clone(), m, k, n, iters);
256
+ log::info!("{t} ({note})");
257
+ results.push(t);
258
+ }
259
+
260
+ // Cash in the tail waste. Every token-major matmul has `m = tokens`, and
261
+ // 201 tokens occupy four 64-row tiles — 256 rows, of which 55 are
262
+ // padding we already pay for. A 240² input gives a 15×15 grid, so 225
263
+ // patches plus 5 prefix tokens is 230 rows: still four tiles, still the
264
+ // same dispatch, but 15% more spatial resolution reaching the decoder.
265
+ //
266
+ // Not entirely free — attention scores are O(tokens²) and they are ~8%
267
+ // of the layer — so measure it rather than assert it.
268
+ // 224 twice, bracketing 240. The Adreno's first measurement in a session
269
+ // runs at less than half the rate of its fifth — clocks ramp — so a
270
+ // straight A-then-B comparison credits B for the warm-up. If both 224s
271
+ // agree, the ramp is done and the middle number means something.
272
+ for size in [224, 240, 224] {
273
+ let c = Config::vits16().at_resolution(size).with_layers(3);
274
+ let mut t = encoder_forward(gpu.clone(), &c, iters);
275
+ t.label = format!("dinov3 @{size} 3L");
276
+ log::info!(
277
+ "{t} (grid {}x{}, {} tokens)",
278
+ c.grid(),
279
+ c.grid(),
280
+ c.num_tokens()
281
+ );
282
+ results.push(t);
283
+ }
284
+ results
285
+ }
286
+
287
+ /// Build an encoder session on the shared context.
288
+ ///
289
+ /// Weights are left to the caller: [`crate::weights::load_encoder`] for
290
+ /// real features, [`fill_parameters`] for pure timing.
291
+ pub fn build_encoder_session(
292
+ gpu: Arc<blade_graphics::Context>,
293
+ config: &Config,
294
+ cache: Option<&std::path::Path>,
295
+ ) -> (Session, Graph) {
296
+ let mut g = Graph::new();
297
+ let out = crate::dinov3::build_encoder(&mut g, config);
298
+ g.set_outputs(vec![out]);
299
+
300
+ let build_start = Instant::now();
301
+ let (session, _) = meganeura::train::build(
302
+ &g,
303
+ SessionConfig {
304
+ mode: Mode::Inference,
305
+ gpu: Some(gpu),
306
+ cache,
307
+ ..Default::default()
308
+ },
309
+ );
310
+ // Worth reporting separately: on a mobile CPU, graph optimization plus
311
+ // WGSL-to-SPIR-V for every kernel is slow enough to matter at startup,
312
+ // and it is exactly what the plan cache is meant to remove.
313
+ log::info!(
314
+ "session build: {:.1} s (cache {})",
315
+ build_start.elapsed().as_secs_f64(),
316
+ if cache.is_some() { "on" } else { "off" }
317
+ );
318
+ (session, g)
319
+ }
320
+
321
+ /// Time a full DINOv3 forward pass with synthetic weights.
322
+ pub fn encoder_forward(gpu: Arc<blade_graphics::Context>, config: &Config, iters: usize) -> Timing {
323
+ let (mut session, g) = build_encoder_session(gpu, config, None);
324
+ fill_parameters(&mut session, &g);
325
+ session.set_input(
326
+ "patches",
327
+ &vec![0.1f32; config.num_patches() * config.patch_dim()],
328
+ );
329
+
330
+ time_session(
331
+ &mut session,
332
+ &format!("dinov3 vits16 @{}", config.image_size),
333
+ config.forward_macs(),
334
+ iters,
335
+ )
336
+ }
337
+
338
+ /// Time the deployed joined encoder-to-RGB graph, including decoder and all
339
+ /// graph-internal layout transformations. Camera conversion, CPU patchifying,
340
+ /// output readback, and rendering are outside this isolated measurement.
341
+ pub fn roundtrip_forward(
342
+ gpu: Arc<blade_graphics::Context>,
343
+ config: &Config,
344
+ submission_chunks: usize,
345
+ iters: usize,
346
+ ) -> Timing {
347
+ let mut graph = Graph::new();
348
+ let encoder_output = crate::dinov3::build_encoder(&mut graph, config);
349
+ let reconstruction = crate::decoder::attach_to_encoder(&mut graph, config, encoder_output);
350
+ graph.set_outputs(vec![reconstruction]);
351
+ let (mut session, _) = meganeura::train::build(
352
+ &graph,
353
+ SessionConfig {
354
+ mode: Mode::Inference,
355
+ gpu: Some(gpu),
356
+ ..Default::default()
357
+ },
358
+ );
359
+ session.set_submission_chunks(submission_chunks);
360
+ fill_parameters(&mut session, &graph);
361
+ session.set_input(
362
+ "patches",
363
+ &vec![0.1f32; config.num_patches() * config.patch_dim()],
364
+ );
365
+ time_session(
366
+ &mut session,
367
+ &format!(
368
+ "roundtrip @{} {}L x{}",
369
+ config.image_size, config.num_hidden_layers, submission_chunks
370
+ ),
371
+ config.forward_macs() + crate::decoder::forward_macs(config),
372
+ iters,
373
+ )
374
+ }
375
+
376
+ /// The full benchmark: device identity, matmul throughput at the shapes
377
+ /// the encoder uses, then the encoder itself across candidate input
378
+ /// resolutions.
379
+ pub fn run_all(gpu: Arc<blade_graphics::Context>, iters: usize) -> Vec<Timing> {
380
+ describe_device(&gpu);
381
+
382
+ let base = Config::vits16();
383
+ let tokens = base.num_tokens();
384
+ let hidden = base.hidden_size;
385
+ let mut results = Vec::new();
386
+
387
+ // The three matmul shapes that dominate a ViT-S forward pass.
388
+ for (m, k, n) in [
389
+ (tokens, hidden, hidden), // Q/K/V/output projection
390
+ (tokens, hidden, base.intermediate_size), // MLP up
391
+ (tokens, base.intermediate_size, hidden), // MLP down
392
+ ] {
393
+ let t = matmul_throughput(gpu.clone(), m, k, n, iters);
394
+ log::info!("{t}");
395
+ results.push(t);
396
+ }
397
+
398
+ // 224 is the reference resolution (14×14 features); 256 buys a 16×16
399
+ // grid for ~30% more work. Anything larger is unlikely to fit a
400
+ // real-time budget, which is what these numbers will confirm or deny.
401
+ for size in [224, 256] {
402
+ let config = base.clone().at_resolution(size);
403
+ let t = encoder_forward(gpu.clone(), &config, iters);
404
+ log::info!(
405
+ "{t} (grid {}x{}, {} tokens)",
406
+ config.grid(),
407
+ config.grid(),
408
+ config.num_tokens()
409
+ );
410
+ results.push(t);
411
+ }
412
+
413
+ // ViT-S/16 is the smallest DINOv3 there is, so cheaper means fewer
414
+ // layers or fewer tokens rather than a smaller model. Measure both
415
+ // knobs, since which one to spend is a quality judgement that wants
416
+ // real numbers under it.
417
+ for layers in [6, 3] {
418
+ let c = base.clone().at_resolution(224).with_layers(layers);
419
+ let mut t = encoder_forward(gpu.clone(), &c, iters);
420
+ t.label = format!("dinov3 @224 {layers}L");
421
+ log::info!("{t} ({layers} of 12 layers)");
422
+ results.push(t);
423
+ }
424
+ for size in [160, 128] {
425
+ let c = base.clone().at_resolution(size);
426
+ let mut t = encoder_forward(gpu.clone(), &c, iters);
427
+ t.label = format!("dinov3 vits16 @{size}");
428
+ log::info!("{t} (grid {}x{})", c.grid(), c.grid());
429
+ results.push(t);
430
+ }
431
+
432
+ // The actual reconstruction workload is a joined three-layer encoder and
433
+ // decoder. Keep isolated chunk costs beside the live-XR sweep so queue
434
+ // fairness is not presented without its throughput price.
435
+ let reconstruction = base.clone().at_resolution(224).with_layers(3);
436
+ for chunks in [1, 4, 12] {
437
+ let t = roundtrip_forward(gpu.clone(), &reconstruction, chunks, iters);
438
+ log::info!("{t} (joined encoder + decoder, {chunks} submission chunks)");
439
+ results.push(t);
440
+ }
441
+
442
+ // f16 weight storage halves weight traffic. Whether that shows up
443
+ // depends on whether the device is short of bandwidth or of ALU, which
444
+ // is exactly the thing worth measuring rather than reasoning about.
445
+ let f16 = base.clone().at_resolution(224).with_f16_weights(true);
446
+ let mut t = encoder_forward(gpu.clone(), &f16, iters);
447
+ t.label = "dinov3 vits16 @224 f16w".to_string();
448
+ log::info!("{t} (f16 weight storage, f32 arithmetic)");
449
+ results.push(t);
450
+
451
+ // Chunked submission should cost a little throughput — several submits
452
+ // instead of one — in exchange for letting a co-tenant interleave. This
453
+ // measures the price when nothing else is on the queue.
454
+ let chunked = base.clone().at_resolution(224);
455
+ let (mut session, g) = build_encoder_session(gpu, &chunked, None);
456
+ session.set_submission_chunks(12);
457
+ fill_parameters(&mut session, &g);
458
+ session.set_input(
459
+ "patches",
460
+ &vec![0.1f32; chunked.num_patches() * chunked.patch_dim()],
461
+ );
462
+ let t = time_session(
463
+ &mut session,
464
+ "dinov3 vits16 @224 x12",
465
+ chunked.forward_macs(),
466
+ iters,
467
+ );
468
+ log::info!("{t} (12 submissions instead of 1)");
469
+ results.push(t);
470
+
471
+ results
472
+ }
environment/training-source/source-snapshot/src/camera.rs ADDED
@@ -0,0 +1,731 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Quest passthrough camera, through the Camera2 NDK.
2
+ //!
3
+ //! Meta exposes the forward-facing cameras on Quest 3 and 3S as ordinary
4
+ //! Camera2 devices, distinguished from the avatar cameras by vendor tags.
5
+ //! Their own documentation covers the Kotlin path; this is the native one,
6
+ //! because everything else here is Rust and bouncing frames through JNI to
7
+ //! get them back into a Vulkan pipeline would be absurd.
8
+ //!
9
+ //! Requirements, all of which fail silently if missed:
10
+ //!
11
+ //! * `horizonos.permission.HEADSET_CAMERA` **granted at runtime**. Declaring
12
+ //! it is not enough. For a POC:
13
+ //! `adb shell pm grant rust.dinovision_xr horizonos.permission.HEADSET_CAMERA`
14
+ //! * minSdk 34 and NDK r27 for the API-34 sysroot. Note that Horizon OS
15
+ //! v205 does *not* export `ACameraManager_getTagFromName`, so Meta's
16
+ //! vendor tags cannot be resolved by name; cameras are identified by
17
+ //! lens facing and supported formats instead.
18
+ //! * Passthrough enabled on the device.
19
+ //!
20
+ //! The camera delivers 1280×960 YUV420 at 60 Hz. The encoder wants a
21
+ //! 224×224 RGB square, so [`FrameSource::next_frame`] centre-crops,
22
+ //! box-downscales, and converts in one pass over the destination.
23
+
24
+ #![cfg(target_os = "android")]
25
+
26
+ use std::ffi::{c_char, c_int, c_void};
27
+ use std::sync::atomic::{AtomicBool, Ordering};
28
+ use std::time::{Duration, Instant};
29
+
30
+ use crate::source::FrameSource;
31
+
32
+ /// How long to wait between reopen attempts. The camera stays disabled for
33
+ /// as long as the headset is off, so retrying hard would just spin.
34
+ const REOPEN_INTERVAL: Duration = Duration::from_secs(1);
35
+
36
+ // --- Opaque handles -------------------------------------------------------
37
+
38
+ #[repr(C)]
39
+ struct ACameraManager(#[allow(dead_code)] [u8; 0]);
40
+ #[repr(C)]
41
+ struct ACameraDevice(#[allow(dead_code)] [u8; 0]);
42
+ #[repr(C)]
43
+ struct ACameraMetadata(#[allow(dead_code)] [u8; 0]);
44
+ #[repr(C)]
45
+ struct ACameraCaptureSession(#[allow(dead_code)] [u8; 0]);
46
+ #[repr(C)]
47
+ struct ACaptureRequest(#[allow(dead_code)] [u8; 0]);
48
+ #[repr(C)]
49
+ struct ACameraOutputTarget(#[allow(dead_code)] [u8; 0]);
50
+ #[repr(C)]
51
+ struct ACaptureSessionOutput(#[allow(dead_code)] [u8; 0]);
52
+ #[repr(C)]
53
+ struct ACaptureSessionOutputContainer(#[allow(dead_code)] [u8; 0]);
54
+ #[repr(C)]
55
+ struct AImageReader(#[allow(dead_code)] [u8; 0]);
56
+ #[repr(C)]
57
+ struct AImage(#[allow(dead_code)] [u8; 0]);
58
+ #[repr(C)]
59
+ struct ANativeWindow(#[allow(dead_code)] [u8; 0]);
60
+
61
+ #[repr(C)]
62
+ struct ACameraIdList {
63
+ num_cameras: c_int,
64
+ camera_ids: *mut *const c_char,
65
+ }
66
+
67
+ #[repr(C)]
68
+ struct ACameraMetadataConstEntry {
69
+ tag: u32,
70
+ kind: u8,
71
+ count: u32,
72
+ data: *const u8,
73
+ }
74
+
75
+ #[repr(C)]
76
+ struct ACameraDeviceStateCallbacks {
77
+ context: *mut c_void,
78
+ on_disconnected: extern "C" fn(*mut c_void, *mut ACameraDevice),
79
+ on_error: extern "C" fn(*mut c_void, *mut ACameraDevice, c_int),
80
+ }
81
+
82
+ #[repr(C)]
83
+ struct ACameraCaptureSessionStateCallbacks {
84
+ context: *mut c_void,
85
+ on_closed: extern "C" fn(*mut c_void, *mut ACameraCaptureSession),
86
+ on_ready: extern "C" fn(*mut c_void, *mut ACameraCaptureSession),
87
+ on_active: extern "C" fn(*mut c_void, *mut ACameraCaptureSession),
88
+ }
89
+
90
+ const AIMAGE_FORMAT_YUV_420_888: c_int = 0x23;
91
+ const TEMPLATE_PREVIEW: c_int = 1;
92
+ const ACAMERA_OK: c_int = 0;
93
+ const AMEDIA_OK: c_int = 0;
94
+
95
+ /// Standard metadata tags, section ordinal `<< 16` plus index.
96
+ ///
97
+ /// Meta's vendor tags would be nicer, but resolving a vendor tag by name
98
+ /// needs `ACameraManager_getTagFromName`, and Horizon OS v205 does not
99
+ /// export it — linking against it stops the whole library from loading
100
+ /// with `UnsatisfiedLinkError`. Standard tags are all that is portable
101
+ /// here, so the passthrough cameras get identified by what they can do
102
+ /// rather than by what they are called.
103
+ const ACAMERA_LENS_FACING: u32 = (8 << 16) + 5;
104
+ const ACAMERA_SCALER_AVAILABLE_STREAM_CONFIGURATIONS: u32 = (13 << 16) + 10;
105
+
106
+ /// The passthrough cameras look outward at the world, so they report
107
+ /// `LENS_FACING_BACK`. The Quest also exposes a front-facing camera —
108
+ /// the avatar one — which advertises the same resolutions but is
109
+ /// separately restricted and denies `openCamera` outright.
110
+ const LENS_FACING_BACK: u8 = 1;
111
+
112
+ /// Camera2 error codes, so a failure says something rather than showing a
113
+ /// bare negative number.
114
+ fn camera_error(code: c_int) -> &'static str {
115
+ match code {
116
+ -10001 => "invalid parameter",
117
+ -10002 => "camera disconnected",
118
+ -10003 => "not enough memory",
119
+ -10004 => "metadata not found",
120
+ -10005 => "camera device error",
121
+ -10006 => "camera service error",
122
+ -10007 => "session closed",
123
+ -10008 => "invalid operation",
124
+ -10009 => "stream configure failed",
125
+ -10010 => "camera in use",
126
+ -10011 => "max cameras in use",
127
+ -10012 => "camera disabled",
128
+ -10013 => "permission denied",
129
+ -10014 => "unsupported operation",
130
+ _ => "unknown",
131
+ }
132
+ }
133
+
134
+ #[link(name = "camera2ndk")]
135
+ unsafe extern "C" {
136
+ fn ACameraManager_create() -> *mut ACameraManager;
137
+ fn ACameraManager_delete(m: *mut ACameraManager);
138
+ fn ACameraManager_getCameraIdList(m: *mut ACameraManager, out: *mut *mut ACameraIdList) -> c_int;
139
+ fn ACameraManager_deleteCameraIdList(l: *mut ACameraIdList);
140
+ fn ACameraManager_getCameraCharacteristics(
141
+ m: *mut ACameraManager,
142
+ id: *const c_char,
143
+ out: *mut *mut ACameraMetadata,
144
+ ) -> c_int;
145
+ fn ACameraManager_openCamera(
146
+ m: *mut ACameraManager,
147
+ id: *const c_char,
148
+ cb: *mut ACameraDeviceStateCallbacks,
149
+ out: *mut *mut ACameraDevice,
150
+ ) -> c_int;
151
+ fn ACameraMetadata_getConstEntry(
152
+ md: *const ACameraMetadata,
153
+ tag: u32,
154
+ entry: *mut ACameraMetadataConstEntry,
155
+ ) -> c_int;
156
+ fn ACameraMetadata_free(md: *mut ACameraMetadata);
157
+ fn ACameraDevice_close(d: *mut ACameraDevice) -> c_int;
158
+ fn ACameraDevice_createCaptureRequest(
159
+ d: *mut ACameraDevice,
160
+ template: c_int,
161
+ out: *mut *mut ACaptureRequest,
162
+ ) -> c_int;
163
+ fn ACameraDevice_createCaptureSession(
164
+ d: *mut ACameraDevice,
165
+ outputs: *const ACaptureSessionOutputContainer,
166
+ cb: *const ACameraCaptureSessionStateCallbacks,
167
+ session: *mut *mut ACameraCaptureSession,
168
+ ) -> c_int;
169
+ fn ACaptureSessionOutputContainer_create(
170
+ out: *mut *mut ACaptureSessionOutputContainer,
171
+ ) -> c_int;
172
+ fn ACaptureSessionOutputContainer_add(
173
+ c: *mut ACaptureSessionOutputContainer,
174
+ o: *const ACaptureSessionOutput,
175
+ ) -> c_int;
176
+ fn ACaptureSessionOutputContainer_free(c: *mut ACaptureSessionOutputContainer);
177
+ fn ACaptureSessionOutput_create(
178
+ w: *mut ANativeWindow,
179
+ out: *mut *mut ACaptureSessionOutput,
180
+ ) -> c_int;
181
+ fn ACaptureSessionOutput_free(o: *mut ACaptureSessionOutput);
182
+ fn ACameraOutputTarget_create(w: *mut ANativeWindow, out: *mut *mut ACameraOutputTarget)
183
+ -> c_int;
184
+ fn ACameraOutputTarget_free(t: *mut ACameraOutputTarget);
185
+ fn ACaptureRequest_addTarget(r: *mut ACaptureRequest, t: *const ACameraOutputTarget) -> c_int;
186
+ fn ACaptureRequest_free(r: *mut ACaptureRequest);
187
+ fn ACameraCaptureSession_setRepeatingRequest(
188
+ s: *mut ACameraCaptureSession,
189
+ cb: *mut c_void,
190
+ num: c_int,
191
+ requests: *mut *mut ACaptureRequest,
192
+ seq: *mut c_int,
193
+ ) -> c_int;
194
+ fn ACameraCaptureSession_close(s: *mut ACameraCaptureSession);
195
+ }
196
+
197
+ #[link(name = "mediandk")]
198
+ unsafe extern "C" {
199
+ fn AImageReader_new(
200
+ width: c_int,
201
+ height: c_int,
202
+ format: c_int,
203
+ max_images: c_int,
204
+ out: *mut *mut AImageReader,
205
+ ) -> c_int;
206
+ fn AImageReader_delete(r: *mut AImageReader);
207
+ fn AImageReader_getWindow(r: *mut AImageReader, out: *mut *mut ANativeWindow) -> c_int;
208
+ fn AImageReader_acquireLatestImage(r: *mut AImageReader, out: *mut *mut AImage) -> c_int;
209
+ fn AImage_delete(i: *mut AImage);
210
+ fn AImage_getWidth(i: *const AImage, out: *mut i32) -> c_int;
211
+ fn AImage_getHeight(i: *const AImage, out: *mut i32) -> c_int;
212
+ fn AImage_getPlaneRowStride(i: *const AImage, plane: c_int, out: *mut i32) -> c_int;
213
+ fn AImage_getPlanePixelStride(i: *const AImage, plane: c_int, out: *mut i32) -> c_int;
214
+ fn AImage_getPlaneData(
215
+ i: *const AImage,
216
+ plane: c_int,
217
+ data: *mut *mut u8,
218
+ len: *mut c_int,
219
+ ) -> c_int;
220
+ }
221
+
222
+ /// Set from the device callbacks, cleared when the camera is reopened.
223
+ ///
224
+ /// A process-global rather than a per-camera flag because the callbacks
225
+ /// take a raw context pointer and this app only ever opens one camera;
226
+ /// threading an `Arc` through the FFI to support a second would be
227
+ /// ceremony for a case that does not exist.
228
+ static DEVICE_ERROR: AtomicBool = AtomicBool::new(false);
229
+
230
+ extern "C" fn on_disconnected(_ctx: *mut c_void, _d: *mut ACameraDevice) {
231
+ log::warn!("camera disconnected");
232
+ DEVICE_ERROR.store(true, Ordering::Release);
233
+ }
234
+
235
+ extern "C" fn on_error(_ctx: *mut c_void, _d: *mut ACameraDevice, err: c_int) {
236
+ // Code 3 is ERROR_CAMERA_DISABLED, which Horizon OS raises whenever the
237
+ // headset comes off. It is routine rather than exceptional here, so the
238
+ // camera reopens instead of the view freezing on its last frame.
239
+ log::warn!(
240
+ "camera device error {err}{}",
241
+ if err == 3 { " (disabled — headset removed?)" } else { "" }
242
+ );
243
+ DEVICE_ERROR.store(true, Ordering::Release);
244
+ }
245
+ extern "C" fn on_session(_ctx: *mut c_void, _s: *mut ACameraCaptureSession) {}
246
+
247
+ /// Which of the two forward-facing cameras to read.
248
+ #[derive(Clone, Copy, Debug)]
249
+ pub enum Eye {
250
+ Left,
251
+ Right,
252
+ }
253
+
254
+ impl Eye {
255
+ fn position(self) -> u8 {
256
+ match self {
257
+ Eye::Left => 0,
258
+ Eye::Right => 1,
259
+ }
260
+ }
261
+ }
262
+
263
+ /// A live passthrough camera delivering square RGB frames.
264
+ pub struct PassthroughCamera {
265
+ manager: *mut ACameraManager,
266
+ device: *mut ACameraDevice,
267
+ session: *mut ACameraCaptureSession,
268
+ request: *mut ACaptureRequest,
269
+ target: *mut ACameraOutputTarget,
270
+ output: *mut ACaptureSessionOutput,
271
+ container: *mut ACaptureSessionOutputContainer,
272
+ reader: *mut AImageReader,
273
+ size: usize,
274
+ buf: Vec<u8>,
275
+ have_frame: bool,
276
+ /// Kept so the camera can be reopened after the device errors.
277
+ eye: Eye,
278
+ capture: (i32, i32),
279
+ last_reopen: Instant,
280
+ }
281
+
282
+ // The handles are only touched from the thread that owns the struct; the
283
+ // NDK does not pin them to a thread.
284
+ unsafe impl Send for PassthroughCamera {}
285
+
286
+ impl PassthroughCamera {
287
+ /// Open the passthrough camera for one eye at `size × size` output.
288
+ ///
289
+ /// `capture` is the sensor resolution to request — 1280×960 is what the
290
+ /// Quest 3/3S offer.
291
+ pub fn new(size: usize, eye: Eye, capture: (i32, i32)) -> Result<Self, String> {
292
+ unsafe {
293
+ let manager = ACameraManager_create();
294
+ if manager.is_null() {
295
+ return Err("ACameraManager_create returned null".into());
296
+ }
297
+
298
+ let mut list: *mut ACameraIdList = std::ptr::null_mut();
299
+ if ACameraManager_getCameraIdList(manager, &mut list) != ACAMERA_OK || list.is_null() {
300
+ ACameraManager_delete(manager);
301
+ return Err("could not enumerate cameras — is HEADSET_CAMERA granted?".into());
302
+ }
303
+ let ids = std::slice::from_raw_parts((*list).camera_ids, (*list).num_cameras as usize);
304
+ log::info!("{} cameras visible", ids.len());
305
+
306
+ let candidates = Self::candidates(manager, ids, eye, capture);
307
+ if candidates.is_empty() {
308
+ ACameraManager_deleteCameraIdList(list);
309
+ ACameraManager_delete(manager);
310
+ return Err("no outward-facing camera offers the requested format".into());
311
+ }
312
+
313
+ // --- Reader and its surface ---
314
+ let mut reader: *mut AImageReader = std::ptr::null_mut();
315
+ if AImageReader_new(
316
+ capture.0,
317
+ capture.1,
318
+ AIMAGE_FORMAT_YUV_420_888,
319
+ // A small queue: we always take the newest frame and drop
320
+ // the rest, so depth only adds latency.
321
+ 4,
322
+ &mut reader,
323
+ ) != AMEDIA_OK
324
+ {
325
+ ACameraManager_deleteCameraIdList(list);
326
+ ACameraManager_delete(manager);
327
+ return Err("AImageReader_new failed".into());
328
+ }
329
+ let mut window: *mut ANativeWindow = std::ptr::null_mut();
330
+ AImageReader_getWindow(reader, &mut window);
331
+
332
+ // --- Open the device ---
333
+ let mut callbacks = ACameraDeviceStateCallbacks {
334
+ context: std::ptr::null_mut(),
335
+ on_disconnected,
336
+ on_error,
337
+ };
338
+ // Try each candidate in preference order. Which camera id maps
339
+ // to which physical sensor is not documented, and some are
340
+ // restricted in ways that only surface at open time, so trying
341
+ // beats predicting.
342
+ let mut device: *mut ACameraDevice = std::ptr::null_mut();
343
+ let mut last = ACAMERA_OK;
344
+ for &id in &candidates {
345
+ let status = ACameraManager_openCamera(manager, id, &mut callbacks, &mut device);
346
+ if status == ACAMERA_OK && !device.is_null() {
347
+ log::info!("opened camera {:?}", std::ffi::CStr::from_ptr(id));
348
+ break;
349
+ }
350
+ log::warn!(
351
+ "camera {:?} would not open: {} ({status})",
352
+ std::ffi::CStr::from_ptr(id),
353
+ camera_error(status)
354
+ );
355
+ last = status;
356
+ device = std::ptr::null_mut();
357
+ }
358
+ ACameraManager_deleteCameraIdList(list);
359
+ if device.is_null() {
360
+ AImageReader_delete(reader);
361
+ ACameraManager_delete(manager);
362
+ return Err(format!(
363
+ "no camera would open; last error {} ({last}){}",
364
+ camera_error(last),
365
+ if last == -10013 {
366
+ " — HEADSET_CAMERA is a runtime permission and must be \
367
+ granted, and passthrough must be enabled"
368
+ } else {
369
+ ""
370
+ }
371
+ ));
372
+ }
373
+
374
+ // --- Session and repeating request ---
375
+ let mut container: *mut ACaptureSessionOutputContainer = std::ptr::null_mut();
376
+ ACaptureSessionOutputContainer_create(&mut container);
377
+ let mut output: *mut ACaptureSessionOutput = std::ptr::null_mut();
378
+ ACaptureSessionOutput_create(window, &mut output);
379
+ ACaptureSessionOutputContainer_add(container, output);
380
+
381
+ let session_cb = ACameraCaptureSessionStateCallbacks {
382
+ context: std::ptr::null_mut(),
383
+ on_closed: on_session,
384
+ on_ready: on_session,
385
+ on_active: on_session,
386
+ };
387
+ let mut session: *mut ACameraCaptureSession = std::ptr::null_mut();
388
+ if ACameraDevice_createCaptureSession(device, container, &session_cb, &mut session)
389
+ != ACAMERA_OK
390
+ {
391
+ return Err("createCaptureSession failed".into());
392
+ }
393
+
394
+ let mut request: *mut ACaptureRequest = std::ptr::null_mut();
395
+ ACameraDevice_createCaptureRequest(device, TEMPLATE_PREVIEW, &mut request);
396
+ let mut target: *mut ACameraOutputTarget = std::ptr::null_mut();
397
+ ACameraOutputTarget_create(window, &mut target);
398
+ ACaptureRequest_addTarget(request, target);
399
+
400
+ let mut requests = [request];
401
+ if ACameraCaptureSession_setRepeatingRequest(
402
+ session,
403
+ std::ptr::null_mut(),
404
+ 1,
405
+ requests.as_mut_ptr(),
406
+ std::ptr::null_mut(),
407
+ ) != ACAMERA_OK
408
+ {
409
+ return Err("setRepeatingRequest failed".into());
410
+ }
411
+
412
+ log::info!(
413
+ "passthrough camera streaming {}x{} -> {size}x{size}",
414
+ capture.0,
415
+ capture.1
416
+ );
417
+ Ok(Self {
418
+ manager,
419
+ device,
420
+ session,
421
+ request,
422
+ target,
423
+ output,
424
+ container,
425
+ reader,
426
+ size,
427
+ // Mid-grey until the first frame lands, so a stalled camera
428
+ // is visibly "no data" rather than black.
429
+ buf: vec![128; size * size * 3],
430
+ have_frame: false,
431
+ eye,
432
+ capture,
433
+ last_reopen: Instant::now(),
434
+ })
435
+ }
436
+ }
437
+
438
+ /// Find the passthrough camera for the requested eye.
439
+ ///
440
+ /// Meta identifies these with vendor tags, but resolving a vendor tag
441
+ /// needs `ACameraManager_getTagFromName`, which Horizon OS v205 does not
442
+ /// export — and merely *linking* it prevents the library from loading at
443
+ /// all. So the cameras are identified by capability instead: the
444
+ /// passthrough pair are the ones offering the requested YUV420 size.
445
+ /// Among those, the list order is left then right, matching how Meta
446
+ /// numbers them.
447
+ unsafe fn candidates(
448
+ manager: *mut ACameraManager,
449
+ ids: &[*const c_char],
450
+ eye: Eye,
451
+ capture: (i32, i32),
452
+ ) -> Vec<*const c_char> {
453
+ unsafe {
454
+ let mut matching = Vec::new();
455
+ for &id in ids {
456
+ let mut md: *mut ACameraMetadata = std::ptr::null_mut();
457
+ if ACameraManager_getCameraCharacteristics(manager, id, &mut md) != ACAMERA_OK {
458
+ continue;
459
+ }
460
+
461
+ let mut entry = ACameraMetadataConstEntry {
462
+ tag: 0,
463
+ kind: 0,
464
+ count: 0,
465
+ data: std::ptr::null(),
466
+ };
467
+ let facing = (ACameraMetadata_getConstEntry(md, ACAMERA_LENS_FACING, &mut entry)
468
+ == ACAMERA_OK
469
+ && entry.count > 0)
470
+ .then(|| *entry.data);
471
+
472
+ let mut supported = false;
473
+ let mut entry = ACameraMetadataConstEntry {
474
+ tag: 0,
475
+ kind: 0,
476
+ count: 0,
477
+ data: std::ptr::null(),
478
+ };
479
+ if ACameraMetadata_getConstEntry(
480
+ md,
481
+ ACAMERA_SCALER_AVAILABLE_STREAM_CONFIGURATIONS,
482
+ &mut entry,
483
+ ) == ACAMERA_OK
484
+ && !entry.data.is_null()
485
+ {
486
+ // int32[n * 4]: format, width, height, is-input.
487
+ let values =
488
+ std::slice::from_raw_parts(entry.data as *const i32, entry.count as usize);
489
+ supported = values.chunks_exact(4).any(|c| {
490
+ c[0] == AIMAGE_FORMAT_YUV_420_888
491
+ && c[1] == capture.0
492
+ && c[2] == capture.1
493
+ && c[3] == 0
494
+ });
495
+ }
496
+ ACameraMetadata_free(md);
497
+
498
+ log::info!(
499
+ "camera {:?}: facing={facing:?} offers {}x{} YUV420: {supported}",
500
+ std::ffi::CStr::from_ptr(id),
501
+ capture.0,
502
+ capture.1
503
+ );
504
+ if supported {
505
+ matching.push((id, facing));
506
+ }
507
+ }
508
+
509
+ // Outward-facing first, and within that the requested eye
510
+ // first — but keep the rest as fallbacks, since the mapping
511
+ // from camera id to physical sensor is undocumented.
512
+ let (mut outward, inward): (Vec<_>, Vec<_>) = matching
513
+ .into_iter()
514
+ .partition(|&(_, facing)| facing == Some(LENS_FACING_BACK));
515
+
516
+ let wanted = eye.position() as usize;
517
+ if wanted < outward.len() {
518
+ outward.swap(0, wanted);
519
+ }
520
+ let ordered: Vec<*const c_char> = outward
521
+ .into_iter()
522
+ .chain(inward)
523
+ .map(|(id, _)| id)
524
+ .collect();
525
+
526
+ if ordered.is_empty() {
527
+ log::warn!(
528
+ "no camera advertises {}x{} YUV420; trying all of them",
529
+ capture.0,
530
+ capture.1
531
+ );
532
+ return ids.to_vec();
533
+ }
534
+ ordered
535
+ }
536
+ }
537
+
538
+ /// Pull the newest frame, converting YUV420 to a square RGB crop.
539
+ fn pump(&mut self) -> bool {
540
+ unsafe {
541
+ let mut image: *mut AImage = std::ptr::null_mut();
542
+ if AImageReader_acquireLatestImage(self.reader, &mut image) != AMEDIA_OK
543
+ || image.is_null()
544
+ {
545
+ return false;
546
+ }
547
+
548
+ let (mut w, mut h) = (0i32, 0i32);
549
+ AImage_getWidth(image, &mut w);
550
+ AImage_getHeight(image, &mut h);
551
+
552
+ let plane = |index: c_int| -> Option<(*mut u8, usize, usize)> {
553
+ let (mut data, mut len) = (std::ptr::null_mut(), 0);
554
+ if AImage_getPlaneData(image, index, &mut data, &mut len) != AMEDIA_OK {
555
+ return None;
556
+ }
557
+ let (mut row, mut pixel) = (0i32, 0i32);
558
+ AImage_getPlaneRowStride(image, index, &mut row);
559
+ AImage_getPlanePixelStride(image, index, &mut pixel);
560
+ Some((data, row as usize, pixel.max(1) as usize))
561
+ };
562
+
563
+ let (Some((y_data, y_row, _)), Some((u_data, u_row, u_pix)), Some((v_data, v_row, v_pix))) =
564
+ (plane(0), plane(1), plane(2))
565
+ else {
566
+ AImage_delete(image);
567
+ return false;
568
+ };
569
+
570
+ yuv420_to_square_rgb(
571
+ YuvPlanes {
572
+ y: y_data,
573
+ y_row,
574
+ u: u_data,
575
+ u_row,
576
+ u_pix,
577
+ v: v_data,
578
+ v_row,
579
+ v_pix,
580
+ },
581
+ w as usize,
582
+ h as usize,
583
+ self.size,
584
+ &mut self.buf,
585
+ );
586
+ AImage_delete(image);
587
+ self.have_frame = true;
588
+ true
589
+ }
590
+ }
591
+ }
592
+
593
+ impl Drop for PassthroughCamera {
594
+ fn drop(&mut self) {
595
+ unsafe {
596
+ if !self.session.is_null() {
597
+ ACameraCaptureSession_close(self.session);
598
+ }
599
+ if !self.request.is_null() {
600
+ ACaptureRequest_free(self.request);
601
+ }
602
+ if !self.target.is_null() {
603
+ ACameraOutputTarget_free(self.target);
604
+ }
605
+ if !self.container.is_null() {
606
+ ACaptureSessionOutputContainer_free(self.container);
607
+ }
608
+ if !self.output.is_null() {
609
+ ACaptureSessionOutput_free(self.output);
610
+ }
611
+ if !self.device.is_null() {
612
+ ACameraDevice_close(self.device);
613
+ }
614
+ if !self.reader.is_null() {
615
+ AImageReader_delete(self.reader);
616
+ }
617
+ if !self.manager.is_null() {
618
+ ACameraManager_delete(self.manager);
619
+ }
620
+ }
621
+ }
622
+ }
623
+
624
+ impl FrameSource for PassthroughCamera {
625
+ fn size(&self) -> usize {
626
+ self.size
627
+ }
628
+
629
+ fn next_frame(&mut self) -> Option<&[u8]> {
630
+ // Taking the headset off disables the camera, which is a normal
631
+ // thing to do rather than a fatal one. Reopen instead of freezing
632
+ // on the last frame forever.
633
+ if DEVICE_ERROR.load(Ordering::Acquire)
634
+ && self.last_reopen.elapsed() >= REOPEN_INTERVAL
635
+ {
636
+ self.last_reopen = Instant::now();
637
+ match Self::new(self.size, self.eye, self.capture) {
638
+ Ok(mut fresh) => {
639
+ // Carry the last good frame across so the view does not
640
+ // flash grey on every reopen.
641
+ std::mem::swap(&mut fresh.buf, &mut self.buf);
642
+ fresh.have_frame = self.have_frame;
643
+ DEVICE_ERROR.store(false, Ordering::Release);
644
+ // Dropping the old value closes the previous handles.
645
+ *self = fresh;
646
+ log::info!("camera reopened");
647
+ }
648
+ Err(e) => log::warn!("camera reopen failed: {e}"),
649
+ }
650
+ }
651
+
652
+ // A frame may not have arrived since the last call; showing the
653
+ // previous one beats stalling the pipeline.
654
+ self.pump();
655
+ self.have_frame.then_some(&self.buf[..])
656
+ }
657
+ }
658
+
659
+ struct YuvPlanes {
660
+ y: *mut u8,
661
+ y_row: usize,
662
+ u: *mut u8,
663
+ u_row: usize,
664
+ u_pix: usize,
665
+ v: *mut u8,
666
+ v_row: usize,
667
+ v_pix: usize,
668
+ }
669
+
670
+ /// Resample the *whole* YUV420 frame into a square RGB buffer.
671
+ ///
672
+ /// Deliberately not a centre crop. The encoder needs a square, but cropping
673
+ /// 1280×960 to 960×960 throws away a quarter of the horizontal field of
674
+ /// view, and the point of this app is to show what the camera saw. Squashing
675
+ /// the full frame instead keeps all of it; the aspect is restored at display
676
+ /// time by drawing the quad at the camera's real 4:3 shape, so nothing ends
677
+ /// up stretched on screen. DINO sees a horizontally compressed image, which
678
+ /// costs a little feature quality and is a far better trade than losing the
679
+ /// periphery.
680
+ ///
681
+ /// Box-averages luma over each destination pixel's source footprint —
682
+ /// point-sampling 960 down to 224 aliases badly enough to change what the
683
+ /// patch embedding sees. Chroma is sampled at the centre, which is
684
+ /// imperceptible after a 4× reduction and halves the work.
685
+ ///
686
+ /// # Safety
687
+ ///
688
+ /// The plane pointers must be valid for the given strides and dimensions.
689
+ unsafe fn yuv420_to_square_rgb(
690
+ p: YuvPlanes,
691
+ width: usize,
692
+ height: usize,
693
+ size: usize,
694
+ out: &mut [u8],
695
+ ) {
696
+ for oy in 0..size {
697
+ let sy0 = oy * height / size;
698
+ let sy1 = ((oy + 1) * height / size).max(sy0 + 1).min(height);
699
+ for ox in 0..size {
700
+ let sx0 = ox * width / size;
701
+ let sx1 = ((ox + 1) * width / size).max(sx0 + 1).min(width);
702
+
703
+ let mut acc = 0u32;
704
+ let mut n = 0u32;
705
+ for sy in sy0..sy1 {
706
+ let row = unsafe { p.y.add(sy * p.y_row) };
707
+ for sx in sx0..sx1 {
708
+ acc += unsafe { *row.add(sx) } as u32;
709
+ n += 1;
710
+ }
711
+ }
712
+ let luma = (acc / n.max(1)) as f32;
713
+
714
+ // Chroma planes are half resolution in both axes.
715
+ let cx = ((sx0 + sx1) / 2) / 2;
716
+ let cy = ((sy0 + sy1) / 2) / 2;
717
+ let u = unsafe { *p.u.add(cy * p.u_row + cx * p.u_pix) } as f32 - 128.0;
718
+ let v = unsafe { *p.v.add(cy * p.v_row + cx * p.v_pix) } as f32 - 128.0;
719
+
720
+ // BT.601, which is what Camera2 delivers.
721
+ let r = luma + 1.402 * v;
722
+ let g = luma - 0.344_136 * u - 0.714_136 * v;
723
+ let b = luma + 1.772 * u;
724
+
725
+ let o = (oy * size + ox) * 3;
726
+ out[o] = r.clamp(0.0, 255.0) as u8;
727
+ out[o + 1] = g.clamp(0.0, 255.0) as u8;
728
+ out[o + 2] = b.clamp(0.0, 255.0) as u8;
729
+ }
730
+ }
731
+ }
environment/training-source/source-snapshot/src/decoder.rs ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Reconstructing RGB from DINOv3 patch features.
2
+ //!
3
+ //! This is the roundtrip the project is named for: encode an image to
4
+ //! features, decode those features back to pixels, and look at what
5
+ //! survived.
6
+ //!
7
+ //! # What to expect
8
+ //!
9
+ //! At 224² the encoder produces a 14×14 grid of 384-dimensional features —
10
+ //! about 75k numbers standing in for a 150k-value image. That alone would
11
+ //! permit a decent reconstruction, but DINOv3 features are trained for
12
+ //! *semantic invariance*: two views of the same object should land in the
13
+ //! same place regardless of colour, lighting, or fine texture, which means
14
+ //! precisely that information is discarded on purpose. So the reconstruction
15
+ //! recovers layout and dominant colour well and fine detail poorly. That is
16
+ //! the interesting result, not a defect in the decoder.
17
+ //!
18
+ //! # Shape
19
+ //!
20
+ //! A plain convolutional upsampler, four stages of ×2 from 14 to 224:
21
+ //!
22
+ //! ```text
23
+ //! [384, 14, 14] → 256@28 → 128@56 → 64@112 → 32@224 → 3@224
24
+ //! ```
25
+ //!
26
+ //! 2.01M parameters and about 1.14 GMAC per image. The early two stages use
27
+ //! a second convolution to blend patch seams; the later stages do not.
28
+ //!
29
+ //! No skip connections and nothing fancy: the point is to measure what the
30
+ //! features carry, and a decoder with its own access to the input would
31
+ //! confuse that question entirely.
32
+
33
+ use meganeura::{Graph, NodeId};
34
+
35
+ use crate::dinov3::Config;
36
+
37
+ /// Channel widths after each upsampling stage, from the feature grid up.
38
+ pub const STAGES: [usize; 4] = [256, 128, 64, 32];
39
+ /// How many of the early stages get a second convolution. Seams originate
40
+ /// at the patch grid, so blending earns its cost at low resolution and
41
+ /// mostly smooths detail away at high resolution.
42
+ pub const BLEND_STAGES: usize = 2;
43
+ /// Channels per group in the group norms.
44
+ const GROUP_SIZE: usize = 16;
45
+ const EPS: f32 = 1e-5;
46
+
47
+ /// Total parameter count, for reporting and for sizing a weights file.
48
+ pub fn parameter_count(config: &Config) -> usize {
49
+ let mut total = 0;
50
+ let mut in_c = config.hidden_size;
51
+ for (i, &out_c) in STAGES.iter().enumerate() {
52
+ total += out_c * in_c * 9 + out_c + out_c * 2;
53
+ if i < BLEND_STAGES {
54
+ total += out_c * out_c * 9 + out_c + out_c * 2;
55
+ }
56
+ in_c = out_c;
57
+ }
58
+ total += 3 * in_c * 9 + 3; // final projection to RGB
59
+ total
60
+ }
61
+
62
+ /// Multiply-accumulates in the decoder's convolution kernels at batch one.
63
+ /// Bias, normalization, activations, and upsampling are intentionally not
64
+ /// folded into this number; timing still includes every operation.
65
+ pub fn forward_macs(config: &Config) -> u64 {
66
+ let mut total = 0u64;
67
+ let mut in_c = config.hidden_size;
68
+ let mut hw = config.grid();
69
+ for (i, &out_c) in STAGES.iter().enumerate() {
70
+ total += (out_c * in_c * 9 * hw * hw) as u64;
71
+ if i < BLEND_STAGES {
72
+ total += (out_c * out_c * 9 * hw * hw) as u64;
73
+ }
74
+ hw *= 2;
75
+ in_c = out_c;
76
+ }
77
+ total + (3 * in_c * 9 * hw * hw) as u64
78
+ }
79
+
80
+ /// One `conv3x3 → bias → group-norm → SiLU` block at a fixed resolution.
81
+ fn block(
82
+ g: &mut Graph,
83
+ x: NodeId,
84
+ name: &str,
85
+ batch: usize,
86
+ in_c: usize,
87
+ out_c: usize,
88
+ hw: usize,
89
+ ) -> NodeId {
90
+ let kernel = g.parameter(&format!("{name}.weight"), &[out_c, in_c, 3, 3]);
91
+ let x = g.conv2d(
92
+ x,
93
+ kernel,
94
+ batch as u32,
95
+ in_c as u32,
96
+ hw as u32,
97
+ hw as u32,
98
+ out_c as u32,
99
+ 3,
100
+ 3,
101
+ 1,
102
+ 1,
103
+ );
104
+ let bias = g.parameter(&format!("{name}.bias"), &[out_c]);
105
+ let x = g.add_per_channel(x, bias, out_c as u32, (hw * hw) as u32);
106
+
107
+ let gn_w = g.parameter(&format!("{name}.norm.weight"), &[out_c]);
108
+ let gn_b = g.parameter(&format!("{name}.norm.bias"), &[out_c]);
109
+ let x = g.group_norm(
110
+ x,
111
+ gn_w,
112
+ gn_b,
113
+ batch as u32,
114
+ out_c as u32,
115
+ (hw * hw) as u32,
116
+ (out_c / GROUP_SIZE) as u32,
117
+ EPS,
118
+ );
119
+ g.silu(x)
120
+ }
121
+
122
+ /// Build the decoder over an existing feature node.
123
+ ///
124
+ /// `features` must be `[batch, hidden, grid, grid]` flattened NCHW — patch
125
+ /// tokens only, transposed out of the encoder's token-major layout. See
126
+ /// [`patch_features_to_nchw`].
127
+ ///
128
+ /// Returns `[batch, 3, image_size, image_size]` in `[0, 1]`.
129
+ pub fn build_decoder(g: &mut Graph, config: &Config, features: NodeId, batch: usize) -> NodeId {
130
+ let mut x = features;
131
+ let mut in_c = config.hidden_size;
132
+ let mut hw = config.grid();
133
+
134
+ for (i, &out_c) in STAGES.iter().enumerate() {
135
+ x = block(g, x, &format!("dec.{i}"), batch, in_c, out_c, hw);
136
+ // A second convolution, but only where it pays for itself.
137
+ //
138
+ // The seams come from the input being piecewise constant — one
139
+ // feature vector per 16×16 patch — so hiding them needs a receptive
140
+ // field wide enough to mix across cells, and one 3×3 per scale
141
+ // reaches only a single neighbour. Doing that at *every* scale
142
+ // removed the tiling but doubled the decoder, and the decoder is
143
+ // ~40% of the frame on device.
144
+ //
145
+ // Each stage halves the channels and doubles the resolution, so all
146
+ // four second convolutions cost the same. The early ones are where
147
+ // patch boundaries actually live; by 56² and beyond the grid has
148
+ // already been blended and the extra pass mostly smooths detail
149
+ // away. Keeping the first two buys the seam repair for half the
150
+ // price.
151
+ if i < BLEND_STAGES {
152
+ x = block(g, x, &format!("dec.{i}b"), batch, out_c, out_c, hw);
153
+ }
154
+ x = g.upsample_2x(x, batch as u32, out_c as u32, hw as u32, hw as u32);
155
+ hw *= 2;
156
+ in_c = out_c;
157
+ }
158
+ assert_eq!(
159
+ hw, config.image_size,
160
+ "stage count does not reach the image resolution"
161
+ );
162
+
163
+ let kernel = g.parameter("dec.out.weight", &[3, in_c, 3, 3]);
164
+ let x = g.conv2d(
165
+ x,
166
+ kernel,
167
+ batch as u32,
168
+ in_c as u32,
169
+ hw as u32,
170
+ hw as u32,
171
+ 3,
172
+ 3,
173
+ 3,
174
+ 1,
175
+ 1,
176
+ );
177
+ let bias = g.parameter("dec.out.bias", &[3]);
178
+ let x = g.add_per_channel(x, bias, 3, (hw * hw) as u32);
179
+ // Sigmoid rather than a clamp: pixels live in [0, 1] and a hard clamp
180
+ // has zero gradient outside the range, which strands any channel that
181
+ // starts saturated.
182
+ g.sigmoid(x)
183
+ }
184
+
185
+ /// Attach the decoder directly to a live encoder output.
186
+ ///
187
+ /// Does in the graph what [`patch_features_to_nchw`] does on the CPU: drop
188
+ /// the prefix tokens and transpose from the encoder's token-major layout to
189
+ /// the channel-major one convolution needs. Batch 1 only — that is what
190
+ /// inference runs.
191
+ pub fn attach_to_encoder(g: &mut Graph, config: &Config, encoder_out: NodeId) -> NodeId {
192
+ let hidden = config.hidden_size;
193
+ let patches = config.num_patches();
194
+ let prefix = config.num_prefix_tokens();
195
+
196
+ // Slice off CLS and the register tokens. They carry global rather than
197
+ // spatial information and have no place on a feature map.
198
+ let patch_tokens = g.split_b(
199
+ encoder_out,
200
+ 1,
201
+ (prefix * hidden) as u32,
202
+ (patches * hidden) as u32,
203
+ 1,
204
+ );
205
+ let patch_tokens = g.reshape(patch_tokens, &[patches, hidden]);
206
+ let planes = g.transpose(patch_tokens);
207
+ let planes = g.reshape(planes, &[hidden * patches]);
208
+ build_decoder(g, config, planes, 1)
209
+ }
210
+
211
+ /// Load parameters written by `examples/train_decoder.rs`.
212
+ ///
213
+ /// The file is raw f32 in graph declaration order, so it is only valid for
214
+ /// the graph shape that produced it; a length mismatch means the decoder
215
+ /// architecture changed and the weights are stale.
216
+ pub fn load_parameters(
217
+ session: &mut meganeura::Session,
218
+ graph: &Graph,
219
+ path: &std::path::Path,
220
+ ) -> Result<(), Box<dyn std::error::Error>> {
221
+ let bytes = std::fs::read(path)?;
222
+ let values: Vec<f32> = bytes
223
+ .chunks_exact(4)
224
+ .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
225
+ .collect();
226
+
227
+ let mut offset = 0;
228
+ let mut loaded = 0;
229
+ for node in graph.nodes() {
230
+ let meganeura::graph::Op::Parameter { name } = &node.op else {
231
+ continue;
232
+ };
233
+ // Only decoder parameters live in this file; the encoder's come
234
+ // from the checkpoint.
235
+ if !name.starts_with("dec.") {
236
+ continue;
237
+ }
238
+ let n = node.ty.num_elements();
239
+ if offset + n > values.len() {
240
+ return Err(format!(
241
+ "{} is too short: needed {} values by parameter '{name}', have {}",
242
+ path.display(),
243
+ offset + n,
244
+ values.len()
245
+ )
246
+ .into());
247
+ }
248
+ session.set_parameter(name, &values[offset..offset + n]);
249
+ offset += n;
250
+ loaded += 1;
251
+ }
252
+ if offset != values.len() {
253
+ return Err(format!(
254
+ "{} has {} values but the graph consumed {offset}; the decoder \
255
+ architecture and the weights disagree",
256
+ path.display(),
257
+ values.len()
258
+ )
259
+ .into());
260
+ }
261
+ log::info!("loaded {loaded} decoder parameters from {}", path.display());
262
+ Ok(())
263
+ }
264
+
265
+ /// Rearrange the encoder's `[tokens, hidden]` output into the `[hidden,
266
+ /// grid, grid]` NCHW block the decoder consumes, dropping the CLS and
267
+ /// register tokens.
268
+ ///
269
+ /// The encoder is token-major (each row one token); convolution wants
270
+ /// channel-major. Done on the CPU here because it happens once per image
271
+ /// during dataset preparation; the live path folds it into the graph.
272
+ pub fn patch_features_to_nchw(features: &[f32], config: &Config) -> Vec<f32> {
273
+ let hidden = config.hidden_size;
274
+ let patches = config.num_patches();
275
+ let skip = config.num_prefix_tokens();
276
+ assert_eq!(features.len(), config.num_tokens() * hidden);
277
+
278
+ let mut out = vec![0.0f32; hidden * patches];
279
+ for p in 0..patches {
280
+ let src = (skip + p) * hidden;
281
+ for c in 0..hidden {
282
+ out[c * patches + p] = features[src + c];
283
+ }
284
+ }
285
+ out
286
+ }
287
+
288
+ /// Peak signal-to-noise ratio in dB between two `[0, 1]` images.
289
+ ///
290
+ /// The number to quote for reconstruction quality. Roughly: below 15 dB is
291
+ /// unrecognizable, 20 dB is a recognizable blur, 30 dB is visually close.
292
+ pub fn psnr(a: &[f32], b: &[f32]) -> f32 {
293
+ assert_eq!(a.len(), b.len());
294
+ let mse: f64 = a
295
+ .iter()
296
+ .zip(b)
297
+ .map(|(x, y)| {
298
+ let d = (x - y) as f64;
299
+ d * d
300
+ })
301
+ .sum::<f64>()
302
+ / a.len() as f64;
303
+ if mse <= f64::EPSILON {
304
+ return f32::INFINITY;
305
+ }
306
+ (10.0 * (1.0 / mse).log10()) as f32
307
+ }
308
+
309
+ #[cfg(test)]
310
+ mod tests {
311
+ use super::*;
312
+
313
+ #[test]
314
+ fn stages_reach_the_image_resolution() {
315
+ let c = Config::vits16();
316
+ assert_eq!(c.grid() * 2usize.pow(STAGES.len() as u32), c.image_size);
317
+ }
318
+
319
+ #[test]
320
+ fn every_stage_divides_into_groups() {
321
+ for &c in &STAGES {
322
+ assert_eq!(c % GROUP_SIZE, 0, "{c} channels do not group evenly");
323
+ }
324
+ }
325
+
326
+ #[test]
327
+ fn parameter_count_is_modest() {
328
+ let n = parameter_count(&Config::vits16());
329
+ assert!(
330
+ (1_000_000..4_000_000).contains(&n),
331
+ "unexpected decoder size: {n}"
332
+ );
333
+ }
334
+
335
+ #[test]
336
+ fn forward_macs_match_the_deployed_decoder() {
337
+ let c = Config::vits16().at_resolution(224).with_layers(3);
338
+ assert_eq!(forward_macs(&c), 1_141_604_352);
339
+ }
340
+
341
+ #[test]
342
+ fn graph_builds_with_the_right_output_shape() {
343
+ let c = Config::vits16();
344
+ let mut g = Graph::new();
345
+ let feat = g.input("feat", &[c.hidden_size * c.num_patches()]);
346
+ let out = build_decoder(&mut g, &c, feat, 1);
347
+ assert_eq!(
348
+ g.node(out).ty.num_elements(),
349
+ 3 * c.image_size * c.image_size
350
+ );
351
+ }
352
+
353
+ /// The NCHW rearrangement must move each token's channel `c` to plane
354
+ /// `c` at the token's grid position, and must skip the prefix tokens.
355
+ #[test]
356
+ fn nchw_rearrangement_is_a_transpose_past_the_prefix() {
357
+ let c = Config::vits16();
358
+ let h = c.hidden_size;
359
+ // Encode each value as token*1000 + channel so the mapping is
360
+ // checkable by arithmetic.
361
+ let features: Vec<f32> = (0..c.num_tokens() * h)
362
+ .map(|i| ((i / h) * 1000 + (i % h)) as f32)
363
+ .collect();
364
+ let nchw = patch_features_to_nchw(&features, &c);
365
+ assert_eq!(nchw.len(), h * c.num_patches());
366
+
367
+ for &(p, ch) in &[(0usize, 0usize), (37, 5), (195, 383)] {
368
+ let token = c.num_prefix_tokens() + p;
369
+ assert_eq!(
370
+ nchw[ch * c.num_patches() + p],
371
+ (token * 1000 + ch) as f32,
372
+ "patch {p} channel {ch} came from the wrong token"
373
+ );
374
+ }
375
+ }
376
+
377
+ #[test]
378
+ fn psnr_behaves() {
379
+ let a = vec![0.5f32; 100];
380
+ assert!(psnr(&a, &a).is_infinite(), "identical images are lossless");
381
+ // A uniform 0.1 error is MSE 0.01, i.e. 20 dB.
382
+ let b: Vec<f32> = a.iter().map(|v| v + 0.1).collect();
383
+ assert!((psnr(&a, &b) - 20.0).abs() < 0.1);
384
+ }
385
+ }
environment/training-source/source-snapshot/src/dinov3.rs ADDED
@@ -0,0 +1,532 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! DINOv3 ViT encoder, expressed directly in meganeura's graph IR.
2
+ //!
3
+ //! Built by hand rather than imported: `meganeura::load_onnx` currently
4
+ //! recognizes only `Add`, `Gemm`, `MatMul`, and `Relu`, which is nowhere
5
+ //! near enough for a ViT. Every op this needs already exists in the IR,
6
+ //! so the graph is a faithful transcription of
7
+ //! `transformers/models/dinov3_vit/modeling_dinov3_vit.py`.
8
+ //!
9
+ //! Three things distinguish DINOv3 from a garden-variety ViT, and each is
10
+ //! handled below:
11
+ //!
12
+ //! 1. **No learned position embedding.** Position enters only as 2D axial
13
+ //! RoPE applied to Q and K inside every layer.
14
+ //! 2. **Prefix tokens.** A CLS token and `num_register_tokens` register
15
+ //! tokens are prepended to the patch tokens, and RoPE deliberately
16
+ //! skips them.
17
+ //! 3. **LayerScale.** Each residual branch is scaled by a learned
18
+ //! per-channel vector before being added back.
19
+
20
+ use meganeura::{Graph, NodeId};
21
+
22
+ /// Architecture hyperparameters, mirroring HuggingFace's `DINOv3ViTConfig`.
23
+ #[derive(Debug, Clone)]
24
+ pub struct Config {
25
+ pub hidden_size: usize,
26
+ pub num_hidden_layers: usize,
27
+ pub num_attention_heads: u32,
28
+ pub intermediate_size: usize,
29
+ pub num_register_tokens: usize,
30
+ pub patch_size: usize,
31
+ /// Side length in pixels of the (square) input the graph is compiled
32
+ /// for. RoPE lets the real model take any size, but a meganeura graph
33
+ /// has static shapes, so one session serves one resolution.
34
+ pub image_size: usize,
35
+ pub layer_norm_eps: f32,
36
+ pub rope_theta: f32,
37
+ /// `false` selects the plain GELU MLP; DINOv3 only gates on ViT-L and
38
+ /// larger. Gated MLPs are not implemented here yet.
39
+ pub use_gated_mlp: bool,
40
+ /// Store the projection weights as f16, halving weight traffic from
41
+ /// 86 MB to 43 MB per forward pass.
42
+ ///
43
+ /// Storage only — meganeura's generated matmul reads `array<f16>` and
44
+ /// converts to f32 for the arithmetic, so this buys bandwidth, not ALU
45
+ /// rate. Whether that helps depends on which of the two the device is
46
+ /// short of; measure with `examples/bench` rather than assuming.
47
+ ///
48
+ /// Norms, biases, and LayerScale stay f32: they are a rounding error in
49
+ /// size and the most precision-sensitive parts of the network.
50
+ pub f16_weights: bool,
51
+ }
52
+
53
+ impl Config {
54
+ /// `facebook/dinov3-vits16-pretrain-lvd1689m` — 21M parameters.
55
+ pub fn vits16() -> Self {
56
+ Self {
57
+ hidden_size: 384,
58
+ num_hidden_layers: 12,
59
+ num_attention_heads: 6,
60
+ intermediate_size: 1536,
61
+ num_register_tokens: 4,
62
+ patch_size: 16,
63
+ image_size: 224,
64
+ layer_norm_eps: 1e-5,
65
+ rope_theta: 100.0,
66
+ use_gated_mlp: false,
67
+ f16_weights: false,
68
+ }
69
+ }
70
+
71
+ /// Enable f16 weight storage. See [`Config::f16_weights`].
72
+ pub fn with_f16_weights(mut self, enabled: bool) -> Self {
73
+ self.f16_weights = enabled;
74
+ self
75
+ }
76
+
77
+ /// Keep only the first `n` transformer layers.
78
+ ///
79
+ /// ViT-S/16 is already the smallest DINOv3 — the family only goes up
80
+ /// from here — so truncation is the way to get a cheaper encoder. Layers
81
+ /// are uniform in cost and dominate the frame, so this is very close to
82
+ /// a linear speedup: 6 of 12 layers runs about twice as fast.
83
+ ///
84
+ /// For RGB reconstruction it is not a trade at all. Measured: the same
85
+ /// decoder trained against 3 layers reaches 20.94 dB where 12 layers
86
+ /// gives 19.82 dB — better *and* 3.69× faster. The later layers are
87
+ /// where DINO's semantic invariance is built, which is exactly where
88
+ /// colour and texture get discarded, so for putting pixels back the deep
89
+ /// layers were destroying information the decoder needed.
90
+ ///
91
+ /// It does cut the other way for PCA colouring, which wants the
92
+ /// semantics the deep layers add. Depth is a per-display-mode choice.
93
+ ///
94
+ /// The decoder must be retrained for whichever depth is chosen; features
95
+ /// from layer 6 are not features from layer 12.
96
+ pub fn with_layers(mut self, n: usize) -> Self {
97
+ assert!(n <= 12, "DINOv3 ViT-S/16 has 12 layers, not {n}");
98
+ self.num_hidden_layers = n;
99
+ self
100
+ }
101
+
102
+ /// Same weights, different input resolution. The patch grid scales
103
+ /// with it, so cost grows quadratically — 224 gives a 14×14 grid at
104
+ /// ~4.6 GMAC, 448 gives 28×28 at ~4× that.
105
+ pub fn at_resolution(mut self, image_size: usize) -> Self {
106
+ assert_eq!(
107
+ image_size % self.patch_size,
108
+ 0,
109
+ "image_size {image_size} is not a multiple of patch_size {}",
110
+ self.patch_size
111
+ );
112
+ self.image_size = image_size;
113
+ self
114
+ }
115
+
116
+ pub fn head_dim(&self) -> u32 {
117
+ self.hidden_size as u32 / self.num_attention_heads
118
+ }
119
+
120
+ /// Patches along one side of the image — the feature grid resolution.
121
+ pub fn grid(&self) -> usize {
122
+ self.image_size / self.patch_size
123
+ }
124
+
125
+ pub fn num_patches(&self) -> usize {
126
+ self.grid() * self.grid()
127
+ }
128
+
129
+ /// CLS + register tokens, which sit ahead of the patch tokens.
130
+ pub fn num_prefix_tokens(&self) -> usize {
131
+ 1 + self.num_register_tokens
132
+ }
133
+
134
+ pub fn num_tokens(&self) -> usize {
135
+ self.num_prefix_tokens() + self.num_patches()
136
+ }
137
+
138
+ /// Length of one flattened patch: `3 * patch_size²`.
139
+ pub fn patch_dim(&self) -> usize {
140
+ 3 * self.patch_size * self.patch_size
141
+ }
142
+
143
+ /// Multiply-accumulate count for one forward pass, for sanity-checking
144
+ /// measured throughput against achievable device throughput.
145
+ pub fn forward_macs(&self) -> u64 {
146
+ let t = self.num_tokens() as u64;
147
+ let d = self.hidden_size as u64;
148
+ let ff = self.intermediate_size as u64;
149
+ let per_layer =
150
+ // Q, K, V, and output projections
151
+ 4 * t * d * d
152
+ // scores and the value-weighted sum
153
+ + 2 * t * t * d
154
+ // MLP up and down
155
+ + 2 * t * d * ff;
156
+ self.num_hidden_layers as u64 * per_layer + t * self.patch_dim() as u64 * d
157
+ }
158
+ }
159
+
160
+ /// Precomputed 2D axial RoPE tables, each `[num_tokens, hidden_size]`.
161
+ ///
162
+ /// Two deviations from a literal transcription, both deliberate:
163
+ ///
164
+ /// * Prefix rows are `(cos, sin) = (1, 0)`, making the rotation an
165
+ /// identity there. DINOv3 skips RoPE on CLS and register tokens by
166
+ /// slicing the token dimension; encoding it as data instead keeps the
167
+ /// graph free of token-dimension splits.
168
+ /// * The per-head table is replicated across all heads, so the tables
169
+ /// multiply Q and K elementwise with no per-head indexing.
170
+ ///
171
+ /// The coordinate jitter/shift/rescale in the reference is training-only
172
+ /// (`if self.training`), so `pos_embed_rescale` is correctly ignored here.
173
+ pub fn rope_tables(config: &Config) -> (Vec<f32>, Vec<f32>) {
174
+ let head_dim = config.head_dim() as usize;
175
+ // inv_freq has head_dim/4 entries: half the rotated pairs go to the
176
+ // y axis and half to x.
177
+ let quarter = head_dim / 4;
178
+ let half = head_dim / 2;
179
+ let heads = config.num_attention_heads as usize;
180
+ let hidden = config.hidden_size;
181
+ let grid = config.grid();
182
+
183
+ // inv_freq = 1 / theta^linspace(0, 1, head_dim/4)
184
+ let inv_freq: Vec<f32> = (0..quarter)
185
+ .map(|i| 1.0 / config.rope_theta.powf(i as f32 * 4.0 / head_dim as f32))
186
+ .collect();
187
+
188
+ let mut cos = vec![0.0f32; config.num_tokens() * hidden];
189
+ let mut sin = vec![0.0f32; config.num_tokens() * hidden];
190
+
191
+ // Identity rotation over the prefix tokens.
192
+ for t in 0..config.num_prefix_tokens() {
193
+ for c in 0..hidden {
194
+ cos[t * hidden + c] = 1.0;
195
+ sin[t * hidden + c] = 0.0;
196
+ }
197
+ }
198
+
199
+ let mut angles = vec![0.0f32; half];
200
+ for py in 0..grid {
201
+ for px in 0..grid {
202
+ // Patch-center coordinates normalized to [-1, +1]. The
203
+ // reference stacks them (y, x), and that order decides which
204
+ // half of the rotated pairs encodes which axis.
205
+ let y = 2.0 * ((py as f32 + 0.5) / grid as f32) - 1.0;
206
+ let x = 2.0 * ((px as f32 + 0.5) / grid as f32) - 1.0;
207
+ for i in 0..quarter {
208
+ angles[i] = std::f32::consts::TAU * y * inv_freq[i];
209
+ angles[quarter + i] = std::f32::consts::TAU * x * inv_freq[i];
210
+ }
211
+
212
+ let t = config.num_prefix_tokens() + py * grid + px;
213
+ let row = t * hidden;
214
+ for h in 0..heads {
215
+ for d in 0..head_dim {
216
+ // `angles.tile(2)`: the upper half repeats the lower,
217
+ // which is what pairs dimension d with d + head_dim/2
218
+ // under `rotate_half`.
219
+ let a = angles[d % half];
220
+ cos[row + h * head_dim + d] = a.cos();
221
+ sin[row + h * head_dim + d] = a.sin();
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ (cos, sin)
228
+ }
229
+
230
+ /// `x * cos + rotate_half(x) * sin`, where
231
+ /// `rotate_half([x1, x2]) = [-x2, x1]` splits each head's `head_dim` in
232
+ /// two.
233
+ ///
234
+ /// Q and K arrive from `matmul` as `[tokens, heads * head_dim]`, and
235
+ /// meganeura's attention reads head `h` of token `t` at
236
+ /// `t * heads * head_dim + h * head_dim` — head-major, so each head's
237
+ /// slice is contiguous. That lets the split treat the tensor as
238
+ /// `tokens * heads` independent blocks of `head_dim`, with no transpose.
239
+ fn apply_rope(
240
+ g: &mut Graph,
241
+ x: NodeId,
242
+ cos: NodeId,
243
+ sin: NodeId,
244
+ tokens: usize,
245
+ heads: u32,
246
+ head_dim: u32,
247
+ ) -> NodeId {
248
+ let hidden = (heads * head_dim) as usize;
249
+ let blocks = tokens as u32 * heads;
250
+ let half = head_dim / 2;
251
+
252
+ let x1 = g.split_a(x, blocks, half, half, 1);
253
+ let x2 = g.split_b(x, blocks, half, half, 1);
254
+ let neg_x2 = g.neg(x2);
255
+ let rotated = g.concat(neg_x2, x1, blocks, half, half, 1);
256
+ let rotated = g.reshape(rotated, &[tokens, hidden]);
257
+
258
+ let straight = g.mul(x, cos);
259
+ let crossed = g.mul(rotated, sin);
260
+ g.add(straight, crossed)
261
+ }
262
+
263
+ /// Apply a learned gain to the trailing hidden dimension of a
264
+ /// `[tokens, hidden]` matrix.
265
+ ///
266
+ /// Meganeura's `mul_per_channel` follows NCHW layout and indexes its gate as
267
+ /// `linear_index / spatial`; using it directly with `spatial = 1` would read
268
+ /// beyond the hidden-sized gate after the first token. Transposing to
269
+ /// `[hidden, tokens]` makes each hidden coordinate an NCHW channel, with all
270
+ /// tokens as its spatial extent, and therefore gives the LayerScale broadcast
271
+ /// used by the reference implementation.
272
+ fn layer_scale(g: &mut Graph, x: NodeId, gain: NodeId, tokens: usize, hidden: usize) -> NodeId {
273
+ let hidden_by_tokens = g.transpose(x);
274
+ // `mul_per_channel` is an NCHW-flat operator. Keeping the two-dimensional
275
+ // transpose shape would make its compiler interpret only the first axis
276
+ // as the element count.
277
+ let flat = g.reshape(hidden_by_tokens, &[hidden * tokens]);
278
+ let scaled = g.mul_per_channel(flat, gain, hidden as u32, tokens as u32);
279
+ let scaled = g.reshape(scaled, &[hidden, tokens]);
280
+ g.transpose(scaled)
281
+ }
282
+
283
+ /// Build the encoder and return the final `[num_tokens, hidden_size]`
284
+ /// feature node.
285
+ ///
286
+ /// Declares one input, `"patches"`, of shape `[num_patches, patch_dim]` —
287
+ /// see [`crate::preprocess`] for the layout it expects. Parameter names
288
+ /// match the HuggingFace checkpoint so [`crate::weights`] can bind them
289
+ /// directly.
290
+ ///
291
+ /// Row 0 of the output is the CLS token, rows `1..=num_register_tokens`
292
+ /// are the registers, and the patch features follow in row-major grid
293
+ /// order.
294
+ pub fn build_encoder(g: &mut Graph, config: &Config) -> NodeId {
295
+ assert!(
296
+ !config.use_gated_mlp,
297
+ "gated MLP (ViT-L and larger) is not implemented"
298
+ );
299
+
300
+ let hidden = config.hidden_size;
301
+ let eps = config.layer_norm_eps;
302
+ let heads = config.num_attention_heads;
303
+ let head_dim = config.head_dim();
304
+ let tokens = config.num_tokens();
305
+ let prefix = config.num_prefix_tokens();
306
+
307
+ // Projection weights, in whichever storage the config asks for. Only
308
+ // the large 2D matrices participate; everything else stays f32.
309
+ let weight = |g: &mut Graph, name: &str, shape: &[usize]| {
310
+ if config.f16_weights {
311
+ g.parameter_f16(name, shape)
312
+ } else {
313
+ g.parameter(name, shape)
314
+ }
315
+ };
316
+
317
+ // --- Patch embedding ---
318
+ //
319
+ // The reference applies a Conv2d with kernel == stride == patch_size,
320
+ // which is exactly a per-patch linear map. Feeding pre-flattened
321
+ // patches turns it into a single matmul and keeps the (cheap,
322
+ // memory-bound) patch extraction outside the graph, where a render
323
+ // pass can do it straight from the camera texture later.
324
+ let patches = g.input("patches", &[config.num_patches(), config.patch_dim()]);
325
+ let patch_w = g.parameter(
326
+ "embeddings.patch_embeddings.weight",
327
+ &[config.patch_dim(), hidden],
328
+ );
329
+ let patch_b = g.parameter("embeddings.patch_embeddings.bias", &[hidden]);
330
+ let patch_embeds = g.matmul(patches, patch_w);
331
+ let patch_embeds = g.bias_add(patch_embeds, patch_b);
332
+
333
+ // --- Prepend CLS + register tokens ---
334
+ //
335
+ // One `[prefix, hidden]` parameter rather than separate cls_token and
336
+ // register_tokens nodes: the loader concatenates them, which costs one
337
+ // memcpy offline and saves a concat per forward pass.
338
+ let prefix_tokens = g.parameter("prefix_tokens", &[prefix, hidden]);
339
+ let mut x = g.concat(
340
+ prefix_tokens,
341
+ patch_embeds,
342
+ 1,
343
+ (prefix * hidden) as u32,
344
+ (config.num_patches() * hidden) as u32,
345
+ 1,
346
+ );
347
+ x = g.reshape(x, &[tokens, hidden]);
348
+
349
+ // RoPE tables are the same for every layer and for both Q and K, so
350
+ // they become two constant buffers shared by all 24 uses.
351
+ let (cos_data, sin_data) = rope_tables(config);
352
+ let cos = g.constant(cos_data, &[tokens, hidden]);
353
+ let sin = g.constant(sin_data, &[tokens, hidden]);
354
+
355
+ for i in 0..config.num_hidden_layers {
356
+ let p = format!("layer.{i}");
357
+
358
+ // --- Attention block ---
359
+ let n1_w = g.parameter(&format!("{p}.norm1.weight"), &[hidden]);
360
+ let n1_b = g.parameter(&format!("{p}.norm1.bias"), &[hidden]);
361
+ let h = g.layer_norm(x, n1_w, n1_b, eps);
362
+
363
+ let wq = weight(
364
+ g,
365
+ &format!("{p}.attention.q_proj.weight"),
366
+ &[hidden, hidden],
367
+ );
368
+ let bq = g.parameter(&format!("{p}.attention.q_proj.bias"), &[hidden]);
369
+ let wk = weight(
370
+ g,
371
+ &format!("{p}.attention.k_proj.weight"),
372
+ &[hidden, hidden],
373
+ );
374
+ let wv = weight(
375
+ g,
376
+ &format!("{p}.attention.v_proj.weight"),
377
+ &[hidden, hidden],
378
+ );
379
+ let bv = g.parameter(&format!("{p}.attention.v_proj.bias"), &[hidden]);
380
+
381
+ let q = g.matmul(h, wq);
382
+ let q = g.bias_add(q, bq);
383
+ // `key_bias: false` in the config — K genuinely has no bias.
384
+ let k = g.matmul(h, wk);
385
+ let v = g.matmul(h, wv);
386
+ let v = g.bias_add(v, bv);
387
+
388
+ let q = apply_rope(g, q, cos, sin, tokens, heads, head_dim);
389
+ let k = apply_rope(g, k, cos, sin, tokens, heads, head_dim);
390
+
391
+ // Non-causal: every token attends to every other. meganeura
392
+ // scales by head_dim^-0.5 internally, matching the reference.
393
+ let attn = g.full_attention(q, k, v, heads, heads, head_dim);
394
+
395
+ let wo = weight(
396
+ g,
397
+ &format!("{p}.attention.o_proj.weight"),
398
+ &[hidden, hidden],
399
+ );
400
+ let bo = g.parameter(&format!("{p}.attention.o_proj.bias"), &[hidden]);
401
+ let attn = g.matmul(attn, wo);
402
+ let attn = g.bias_add(attn, bo);
403
+
404
+ // LayerScale: learned gain over the trailing hidden dimension.
405
+ let ls1 = g.parameter(&format!("{p}.layer_scale1.lambda1"), &[hidden]);
406
+ let attn = layer_scale(g, attn, ls1, tokens, hidden);
407
+ x = g.add(x, attn);
408
+
409
+ // --- MLP block ---
410
+ let n2_w = g.parameter(&format!("{p}.norm2.weight"), &[hidden]);
411
+ let n2_b = g.parameter(&format!("{p}.norm2.bias"), &[hidden]);
412
+ let h = g.layer_norm(x, n2_w, n2_b, eps);
413
+
414
+ let up_w = weight(
415
+ g,
416
+ &format!("{p}.mlp.up_proj.weight"),
417
+ &[hidden, config.intermediate_size],
418
+ );
419
+ let up_b = g.parameter(
420
+ &format!("{p}.mlp.up_proj.bias"),
421
+ &[config.intermediate_size],
422
+ );
423
+ let down_w = weight(
424
+ g,
425
+ &format!("{p}.mlp.down_proj.weight"),
426
+ &[config.intermediate_size, hidden],
427
+ );
428
+ let down_b = g.parameter(&format!("{p}.mlp.down_proj.bias"), &[hidden]);
429
+
430
+ let m = g.matmul(h, up_w);
431
+ let m = g.bias_add(m, up_b);
432
+ let m = g.gelu(m);
433
+ let m = g.matmul(m, down_w);
434
+ let m = g.bias_add(m, down_b);
435
+
436
+ let ls2 = g.parameter(&format!("{p}.layer_scale2.lambda1"), &[hidden]);
437
+ let m = layer_scale(g, m, ls2, tokens, hidden);
438
+ x = g.add(x, m);
439
+ }
440
+
441
+ let n_w = g.parameter("norm.weight", &[hidden]);
442
+ let n_b = g.parameter("norm.bias", &[hidden]);
443
+ g.layer_norm(x, n_w, n_b, eps)
444
+ }
445
+
446
+ #[cfg(test)]
447
+ mod tests {
448
+ use super::*;
449
+
450
+ #[test]
451
+ fn vits16_shapes() {
452
+ let c = Config::vits16();
453
+ assert_eq!(c.grid(), 14);
454
+ assert_eq!(c.num_patches(), 196);
455
+ assert_eq!(c.num_prefix_tokens(), 5);
456
+ assert_eq!(c.num_tokens(), 201);
457
+ assert_eq!(c.head_dim(), 64);
458
+ assert_eq!(c.patch_dim(), 768);
459
+ }
460
+
461
+ #[test]
462
+ fn forward_macs_in_expected_ballpark() {
463
+ // ViT-S/16 at 224² is quoted at roughly 4.6 GMAC.
464
+ let macs = Config::vits16().forward_macs();
465
+ assert!(
466
+ (4.0e9..5.5e9).contains(&(macs as f64)),
467
+ "unexpected MAC count {macs}"
468
+ );
469
+ }
470
+
471
+ #[test]
472
+ fn rope_prefix_rows_are_identity() {
473
+ let c = Config::vits16();
474
+ let (cos, sin) = rope_tables(&c);
475
+ assert_eq!(cos.len(), c.num_tokens() * c.hidden_size);
476
+ for t in 0..c.num_prefix_tokens() {
477
+ for d in 0..c.hidden_size {
478
+ assert_eq!(cos[t * c.hidden_size + d], 1.0);
479
+ assert_eq!(sin[t * c.hidden_size + d], 0.0);
480
+ }
481
+ }
482
+ }
483
+
484
+ #[test]
485
+ fn rope_replicates_across_heads_and_tiles_halves() {
486
+ let c = Config::vits16();
487
+ let (cos, sin) = rope_tables(&c);
488
+ let hd = c.head_dim() as usize;
489
+ let half = hd / 2;
490
+ // Pick a patch token well away from the grid origin.
491
+ let row = (c.num_prefix_tokens() + 100) * c.hidden_size;
492
+ for d in 0..hd {
493
+ // Every head sees the same angle...
494
+ for h in 1..c.num_attention_heads as usize {
495
+ assert_eq!(cos[row + d], cos[row + h * hd + d]);
496
+ assert_eq!(sin[row + d], sin[row + h * hd + d]);
497
+ }
498
+ // ...and the upper half of each head repeats the lower half,
499
+ // which is what makes rotate_half pair d with d + half.
500
+ if d < half {
501
+ assert_eq!(cos[row + d], cos[row + d + half]);
502
+ assert_eq!(sin[row + d], sin[row + d + half]);
503
+ }
504
+ }
505
+ }
506
+
507
+ #[test]
508
+ fn rope_center_of_grid_is_near_zero_angle() {
509
+ // A 14×14 grid has no patch exactly at the center, but the two
510
+ // straddling it must have opposite-signed angles.
511
+ let c = Config::vits16();
512
+ let (_, sin) = rope_tables(&c);
513
+ let grid = c.grid();
514
+ let hidden = c.hidden_size;
515
+ let lo = (c.num_prefix_tokens() + (grid / 2 - 1) * grid + grid / 2 - 1) * hidden;
516
+ let hi = (c.num_prefix_tokens() + (grid / 2) * grid + grid / 2) * hidden;
517
+ assert!(
518
+ sin[lo] < 0.0 && sin[hi] > 0.0,
519
+ "expected opposite signs straddling the grid center, got {} and {}",
520
+ sin[lo],
521
+ sin[hi]
522
+ );
523
+ }
524
+
525
+ #[test]
526
+ fn graph_builds_and_declares_expected_slots() {
527
+ let c = Config::vits16();
528
+ let mut g = Graph::new();
529
+ let out = build_encoder(&mut g, &c);
530
+ assert_eq!(g.node(out).ty.shape, vec![c.num_tokens(), c.hidden_size]);
531
+ }
532
+ }
environment/training-source/source-snapshot/src/inference.rs ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! The inference worker: a thread that owns the meganeura session and
2
+ //! publishes coloured feature grids for the renderer to display.
3
+ //!
4
+ //! # Why a thread at all
5
+ //!
6
+ //! The encoder is expected to take 40–60 ms on a Quest 3 while the
7
+ //! compositor wants a frame every 13.9 ms. Running inference inline would
8
+ //! put the render loop on the encoder's cadence, which is uncomfortable at
9
+ //! best. Instead the renderer runs free, always drawing the most recent
10
+ //! completed result, and inference lands whenever it lands.
11
+ //!
12
+ //! # Why the worker builds its own session
13
+ //!
14
+ //! `meganeura::Session` is not `Send` — it owns a `CommandEncoder` holding
15
+ //! raw Vulkan handles. It cannot be constructed here and moved. What *is*
16
+ //! shareable is `Arc<blade_graphics::Context>`, so the worker receives the
17
+ //! context and builds the session in place. See `tests/threading.rs`.
18
+ //!
19
+ //! # The caveat this design cannot fix
20
+ //!
21
+ //! Both threads submit to the same Vulkan queue, which blade guards with a
22
+ //! mutex. A long compute submission can still delay the render submission
23
+ //! queued behind it — threading decouples *CPU* orchestration, not GPU
24
+ //! occupancy. Whether that shows up as dropped frames is exactly what the
25
+ //! on-device numbers will reveal; if it does, the fix is splitting the plan
26
+ //! across frames, which needs meganeura to grow partial execution
27
+ //! (`step()` currently submits every dispatch in one go).
28
+
29
+ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
30
+ use std::sync::{Arc, Mutex, mpsc};
31
+ use std::time::Instant;
32
+
33
+ use crate::dinov3::Config;
34
+ use crate::pca::{self, Basis};
35
+
36
+ /// A completed inference result: a square RGB image, either the coarse
37
+ /// feature colouring or a full reconstruction.
38
+ #[derive(Debug, Clone)]
39
+ pub struct FeatureGrid {
40
+ /// Row-major RGB. For [`Display::PcaColour`] this is `[tokens, 3]`
41
+ /// including the prefix tokens; for [`Display::Reconstruction`] it is
42
+ /// already `[grid * grid, 3]` with no prefix.
43
+ pub colors: Vec<f32>,
44
+ /// Side length of the displayable square: the patch grid for PCA
45
+ /// colouring, the image size for a reconstruction.
46
+ pub grid: usize,
47
+ pub prefix_tokens: usize,
48
+ /// Encoder wall time, for the on-screen/logged rate readout.
49
+ pub latency_ms: f64,
50
+ }
51
+
52
+ impl FeatureGrid {
53
+ /// The displayable `grid × grid × 3` image, with any prefix tokens
54
+ /// dropped.
55
+ pub fn patch_rgb(&self) -> &[f32] {
56
+ &self.colors[self.prefix_tokens * pca::COMPONENTS..]
57
+ }
58
+ }
59
+
60
+ /// What the encoder's output is turned into for display.
61
+ #[derive(Clone, Debug)]
62
+ pub enum Display {
63
+ /// Project patch features onto their top three principal components and
64
+ /// read those off as RGB. No training, one `[hidden, 3]` matmul, and a
65
+ /// `grid × grid` result. Shows what the features *distinguish*.
66
+ PcaColour,
67
+ /// Reconstruct RGB through the trained decoder — the actual roundtrip.
68
+ /// Produces a full `image_size × image_size` picture, so the readback
69
+ /// per frame is ~600 KB rather than ~3 KB. Shows what the features
70
+ /// *retain*.
71
+ Reconstruction(std::path::PathBuf),
72
+ }
73
+
74
+ /// What the worker is told to do next.
75
+ enum Command {
76
+ /// Encode this image. `[num_patches, patch_dim]`, from
77
+ /// [`crate::preprocess`].
78
+ Submit(Vec<f32>),
79
+ /// Refit the PCA basis from the next frame's features.
80
+ Refit,
81
+ Stop,
82
+ }
83
+
84
+ /// Handle to a running worker.
85
+ pub struct Worker {
86
+ tx: mpsc::Sender<Command>,
87
+ latest: Arc<Mutex<Option<FeatureGrid>>>,
88
+ generation: Arc<AtomicU64>,
89
+ ready: Arc<AtomicBool>,
90
+ handle: Option<std::thread::JoinHandle<()>>,
91
+ }
92
+
93
+ impl Worker {
94
+ /// Queue a frame for encoding.
95
+ ///
96
+ /// Returns `false` if the worker is busy or gone. Dropping frames is
97
+ /// the correct behaviour: the camera produces 60 Hz and the encoder
98
+ /// cannot keep up, so anything queued would be stale by the time it ran.
99
+ pub fn submit(&self, patches: Vec<f32>) -> bool {
100
+ if !self.ready.load(Ordering::Acquire) {
101
+ return false;
102
+ }
103
+ self.ready.store(false, Ordering::Release);
104
+ self.tx.send(Command::Submit(patches)).is_ok()
105
+ }
106
+
107
+ /// Ask for the colour basis to be refitted from the next frame.
108
+ pub fn request_refit(&self) {
109
+ let _ = self.tx.send(Command::Refit);
110
+ }
111
+
112
+ /// The most recent completed result, if any.
113
+ pub fn latest(&self) -> Option<FeatureGrid> {
114
+ self.latest.lock().ok().and_then(|g| g.clone())
115
+ }
116
+
117
+ /// Monotonic count of completed encodes, so the renderer can tell a
118
+ /// fresh result from a repeat without comparing pixel data.
119
+ pub fn generation(&self) -> u64 {
120
+ self.generation.load(Ordering::Acquire)
121
+ }
122
+
123
+ /// True when the worker is idle and would accept a frame.
124
+ pub fn is_ready(&self) -> bool {
125
+ self.ready.load(Ordering::Acquire)
126
+ }
127
+ }
128
+
129
+ impl Drop for Worker {
130
+ fn drop(&mut self) {
131
+ let _ = self.tx.send(Command::Stop);
132
+ if let Some(handle) = self.handle.take() {
133
+ let _ = handle.join();
134
+ }
135
+ }
136
+ }
137
+
138
+ /// How the worker should obtain its weights.
139
+ pub enum Weights {
140
+ /// Load a checkpoint from disk. On device this is a path the app has
141
+ /// pushed or unpacked from its assets.
142
+ SafeTensors(std::path::PathBuf),
143
+ /// Deterministic synthetic weights. Produces meaningless features, but
144
+ /// exercises the entire pipeline end to end — useful for bringing the
145
+ /// render path up before the real checkpoint is on the device.
146
+ Synthetic,
147
+ }
148
+
149
+ /// Start the worker.
150
+ ///
151
+ /// Returns immediately; the session is built on the worker thread, which
152
+ /// takes a few seconds on a mobile CPU. [`Worker::is_ready`] reports false
153
+ /// until it is done, and [`Worker::latest`] yields `None`.
154
+ pub fn spawn(
155
+ gpu: Arc<blade_graphics::Context>,
156
+ config: Config,
157
+ weights: Weights,
158
+ plan_cache: Option<std::path::PathBuf>,
159
+ submission_chunks: usize,
160
+ display: Display,
161
+ ) -> Worker {
162
+ let (tx, rx) = mpsc::channel();
163
+ let latest = Arc::new(Mutex::new(None));
164
+ let generation = Arc::new(AtomicU64::new(0));
165
+ let ready = Arc::new(AtomicBool::new(false));
166
+
167
+ let handle = {
168
+ let latest = Arc::clone(&latest);
169
+ let generation = Arc::clone(&generation);
170
+ let ready = Arc::clone(&ready);
171
+ std::thread::Builder::new()
172
+ .name("dinovision-inference".into())
173
+ .spawn(move || {
174
+ worker_main(
175
+ gpu,
176
+ config,
177
+ weights,
178
+ plan_cache,
179
+ submission_chunks,
180
+ display,
181
+ rx,
182
+ latest,
183
+ generation,
184
+ ready,
185
+ );
186
+ })
187
+ .expect("failed to spawn inference thread")
188
+ };
189
+
190
+ Worker {
191
+ tx,
192
+ latest,
193
+ generation,
194
+ ready,
195
+ handle: Some(handle),
196
+ }
197
+ }
198
+
199
+ #[allow(clippy::too_many_arguments)]
200
+ fn worker_main(
201
+ gpu: Arc<blade_graphics::Context>,
202
+ config: Config,
203
+ weights: Weights,
204
+ plan_cache: Option<std::path::PathBuf>,
205
+ submission_chunks: usize,
206
+ display: Display,
207
+ rx: mpsc::Receiver<Command>,
208
+ latest: Arc<Mutex<Option<FeatureGrid>>>,
209
+ generation: Arc<AtomicU64>,
210
+ ready: Arc<AtomicBool>,
211
+ ) {
212
+ // For PCA colouring both the colours and the features are outputs:
213
+ // colours every frame (3 KB), features only when refitting the basis
214
+ // (300 KB), which is why they are separate. A reconstruction needs
215
+ // neither — the decoder consumes the features inside the graph.
216
+ let reconstructing = matches!(display, Display::Reconstruction(_));
217
+ let mut g = meganeura::Graph::new();
218
+ let features = crate::dinov3::build_encoder(&mut g, &config);
219
+ if reconstructing {
220
+ let rgb = crate::decoder::attach_to_encoder(&mut g, &config, features);
221
+ g.set_outputs(vec![rgb]);
222
+ } else {
223
+ let colors = pca::add_projection(&mut g, features, config.hidden_size);
224
+ g.set_outputs(vec![colors, features]);
225
+ }
226
+
227
+ let build_start = Instant::now();
228
+ let (mut session, _) = meganeura::train::build(&g, meganeura::train::SessionConfig {
229
+ mode: meganeura::train::Mode::Inference,
230
+ gpu: Some(gpu),
231
+ cache: plan_cache.as_deref(),
232
+ ..Default::default()
233
+ });
234
+ log::info!(
235
+ "inference session ready in {:.1} s",
236
+ build_start.elapsed().as_secs_f64()
237
+ );
238
+
239
+ // Hand the queue back periodically so the renderer can get a frame in.
240
+ // One long submission is faster in isolation but starves everything
241
+ // else sharing the device.
242
+ session.set_submission_chunks(submission_chunks);
243
+
244
+ match weights {
245
+ Weights::SafeTensors(path) => {
246
+ match meganeura::data::safetensors::SafeTensorsModel::load(path.clone()) {
247
+ Ok(model) => {
248
+ if let Err(e) = crate::weights::load_encoder(&mut session, &model, &config) {
249
+ log::error!("failed to bind weights from {}: {e}", path.display());
250
+ return;
251
+ }
252
+ }
253
+ Err(e) => {
254
+ log::error!("failed to read {}: {e}", path.display());
255
+ return;
256
+ }
257
+ }
258
+ }
259
+ Weights::Synthetic => {
260
+ log::warn!("running with synthetic weights — features are meaningless");
261
+ crate::bench::fill_parameters(&mut session, &g);
262
+ }
263
+ }
264
+
265
+ let tokens = config.num_tokens();
266
+ let mut basis = Basis::placeholder(config.hidden_size);
267
+ let mut refit_pending = false;
268
+ let mut colors_out;
269
+ let mut features_out = vec![0.0f32; tokens * config.hidden_size];
270
+ // Reconstruction reads a planar [3, H, W] image; the renderer wants it
271
+ // interleaved, so keep a staging buffer for the transpose.
272
+ let mut planar = Vec::new();
273
+
274
+ match &display {
275
+ Display::PcaColour => {
276
+ // Start with a placeholder basis so the first frame displays
277
+ // something, then refit from real features as soon as one lands.
278
+ refit_pending = true;
279
+ apply_basis(&mut session, &basis);
280
+ colors_out = vec![0.0f32; tokens * pca::COMPONENTS];
281
+ }
282
+ Display::Reconstruction(path) => {
283
+ if let Err(e) = crate::decoder::load_parameters(&mut session, &g, path) {
284
+ log::error!("failed to load the decoder: {e}");
285
+ return;
286
+ }
287
+ let pixels = config.image_size * config.image_size;
288
+ planar = vec![0.0f32; 3 * pixels];
289
+ colors_out = vec![0.0f32; pixels * 3];
290
+ }
291
+ }
292
+
293
+ ready.store(true, Ordering::Release);
294
+
295
+ while let Ok(cmd) = rx.recv() {
296
+ let patches = match cmd {
297
+ Command::Submit(p) => p,
298
+ Command::Refit => {
299
+ refit_pending = true;
300
+ continue;
301
+ }
302
+ Command::Stop => break,
303
+ };
304
+
305
+ let start = Instant::now();
306
+ session.set_input("patches", &patches);
307
+ session.step();
308
+ session.wait();
309
+
310
+ if reconstructing {
311
+ session.read_output_by_index(0, &mut planar);
312
+ // [3, H, W] → [H * W, 3].
313
+ let pixels = config.image_size * config.image_size;
314
+ for p in 0..pixels {
315
+ for c in 0..3 {
316
+ colors_out[p * 3 + c] = planar[c * pixels + p];
317
+ }
318
+ }
319
+ } else {
320
+ session.read_output_by_index(0, &mut colors_out);
321
+ }
322
+
323
+ if refit_pending {
324
+ // Refitting needs the full feature matrix, so this frame pays
325
+ // for the larger readback. It happens once at startup and then
326
+ // only on request.
327
+ session.read_output_by_index(1, &mut features_out);
328
+ basis = Basis::fit(
329
+ &features_out,
330
+ tokens,
331
+ config.hidden_size,
332
+ config.num_prefix_tokens(),
333
+ );
334
+ apply_basis(&mut session, &basis);
335
+ refit_pending = false;
336
+ log::info!("refitted colour basis from live features");
337
+ // The colours just read were produced by the old basis; redo
338
+ // the projection on the CPU so this frame is not displayed with
339
+ // a stale palette.
340
+ colors_out = basis.project(&features_out, tokens);
341
+ }
342
+
343
+ let grid = FeatureGrid {
344
+ colors: colors_out.clone(),
345
+ grid: if reconstructing {
346
+ config.image_size
347
+ } else {
348
+ config.grid()
349
+ },
350
+ prefix_tokens: if reconstructing {
351
+ 0
352
+ } else {
353
+ config.num_prefix_tokens()
354
+ },
355
+ latency_ms: start.elapsed().as_secs_f64() * 1000.0,
356
+ };
357
+ if let Ok(mut slot) = latest.lock() {
358
+ *slot = Some(grid);
359
+ }
360
+ generation.fetch_add(1, Ordering::Release);
361
+ ready.store(true, Ordering::Release);
362
+ }
363
+
364
+ log::info!("inference worker stopped");
365
+ }
366
+
367
+ fn apply_basis(session: &mut meganeura::Session, basis: &Basis) {
368
+ session.set_parameter("pca.weight", &basis.weight_matrix());
369
+ session.set_parameter("pca.bias", &basis.bias_vector());
370
+ }
environment/training-source/source-snapshot/src/lib.rs ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! DINOv3 inference on Meta Quest, via Blade for graphics and Meganeura
2
+ //! for the network.
3
+ //!
4
+ //! The camera image is encoded to DINOv3 patch features and those features
5
+ //! are turned back into something viewable, so the user sees the world
6
+ //! through a DINO roundtrip.
7
+ //!
8
+ //! # Layout
9
+ //!
10
+ //! * [`dinov3`] — the encoder as a meganeura graph, plus its config.
11
+ //! * [`preprocess`] — image to patch tensor, with the flattening order the
12
+ //! folded patch-embedding matmul requires.
13
+ //! * [`weights`] — binding a HuggingFace checkpoint to graph parameters.
14
+ //! * [`bench`] — throughput measurement, shared by the desktop and
15
+ //! on-device entry points.
16
+ //!
17
+ //! # Sharing one GPU context
18
+ //!
19
+ //! The renderer and the network run on a *single*
20
+ //! `blade_graphics::Context`, created here and handed to meganeura through
21
+ //! `SessionConfig::gpu`. That is why this crate pins the same
22
+ //! blade-graphics revision meganeura does — two copies of the crate would
23
+ //! make the `Arc<Context>` types incompatible. It also means no
24
+ //! external-memory interop is needed: `Session::input_buffer` hands back a
25
+ //! `BufferPiece` a render pass can write to directly.
26
+
27
+ pub mod bench;
28
+ #[cfg(target_os = "android")]
29
+ pub mod camera;
30
+ pub mod decoder;
31
+ pub mod dinov3;
32
+ pub mod inference;
33
+ pub mod pca;
34
+ pub mod preprocess;
35
+ pub mod render;
36
+ pub mod source;
37
+ pub mod weights;
38
+
39
+ use std::sync::Arc;
40
+
41
+ /// Create the GPU context that both the renderer and inference will share.
42
+ ///
43
+ /// `xr` stays `None` for headless compute (the benchmark); the XR path
44
+ /// fills in an `XrDesc` and everything downstream is unchanged.
45
+ pub fn init_context(
46
+ xr: Option<blade_graphics::XrDesc>,
47
+ ) -> Result<Arc<blade_graphics::Context>, blade_graphics::NotSupportedError> {
48
+ let context = unsafe {
49
+ blade_graphics::Context::init(blade_graphics::ContextDesc {
50
+ presentation: false,
51
+ xr,
52
+ ray_tracing: false,
53
+ // Validation layers are not present on a retail Quest, and
54
+ // they cost real time where we can least afford it.
55
+ validation: cfg!(debug_assertions) && !cfg!(target_os = "android"),
56
+ timing: false,
57
+ capture: false,
58
+ overlay: false,
59
+ device_id: None,
60
+ })
61
+ }?;
62
+ let info = context.device_information();
63
+ log::info!(
64
+ "GPU: {} ({}), driver {}",
65
+ info.device_name,
66
+ info.driver_name,
67
+ info.driver_info
68
+ );
69
+ Ok(Arc::new(context))
70
+ }
environment/training-source/source-snapshot/src/pca.rs ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Turning 384-dimensional patch features into something a person can see.
2
+ //!
3
+ //! The first display mode is the classic DINO visualization: project patch
4
+ //! features onto their top three principal components and read those off as
5
+ //! RGB. It needs no trained decoder, costs one `[hidden, 3]` matmul, and is
6
+ //! semantically meaningful — patches of the same object land on the same
7
+ //! colour, which is precisely what the features encode.
8
+ //!
9
+ //! The basis is fitted **on device** from a captured frame rather than
10
+ //! shipped as an asset. Feature statistics depend on what the camera is
11
+ //! actually looking at, and a basis fitted on ImageNet-ish photos would
12
+ //! waste most of its dynamic range on a living room wall.
13
+ //!
14
+ //! Fitting uses power iteration with deflation. For three components out of
15
+ //! a few hundred tokens that is a handful of milliseconds on the CPU, and
16
+ //! it avoids pulling in a linear-algebra dependency for one job.
17
+
18
+ use meganeura::{Graph, NodeId};
19
+
20
+ /// Number of principal components, one per colour channel.
21
+ pub const COMPONENTS: usize = 3;
22
+
23
+ /// A fitted projection from feature space to RGB.
24
+ #[derive(Debug, Clone)]
25
+ pub struct Basis {
26
+ /// Feature-space mean, subtracted before projection.
27
+ pub mean: Vec<f32>,
28
+ /// Row-major `[COMPONENTS, dim]` — the top principal directions.
29
+ pub components: Vec<f32>,
30
+ /// Per-component scale mapping projections into roughly `[0, 1]`.
31
+ pub scale: [f32; COMPONENTS],
32
+ /// Per-component offset, applied after scaling.
33
+ pub offset: [f32; COMPONENTS],
34
+ pub dim: usize,
35
+ }
36
+
37
+ impl Basis {
38
+ /// An identity-ish basis to display before the first fit completes:
39
+ /// three arbitrary orthogonal axes. Produces a picture, just not a
40
+ /// well-conditioned one.
41
+ pub fn placeholder(dim: usize) -> Self {
42
+ let mut components = vec![0.0; COMPONENTS * dim];
43
+ for c in 0..COMPONENTS {
44
+ components[c * dim + c] = 1.0;
45
+ }
46
+ Self {
47
+ mean: vec![0.0; dim],
48
+ components,
49
+ scale: [1.0; COMPONENTS],
50
+ offset: [0.5; COMPONENTS],
51
+ dim,
52
+ }
53
+ }
54
+
55
+ /// Fit from a `[tokens, dim]` feature matrix.
56
+ ///
57
+ /// `skip` drops the leading prefix tokens: CLS and register tokens are
58
+ /// not patches, they carry global rather than spatial information, and
59
+ /// including them skews the components away from what is being
60
+ /// displayed.
61
+ pub fn fit(features: &[f32], tokens: usize, dim: usize, skip: usize) -> Self {
62
+ assert_eq!(features.len(), tokens * dim, "feature matrix shape mismatch");
63
+ assert!(skip < tokens, "nothing left after skipping {skip} tokens");
64
+ let rows = tokens - skip;
65
+ let data = &features[skip * dim..];
66
+
67
+ // Centre.
68
+ let mut mean = vec![0.0f32; dim];
69
+ for r in 0..rows {
70
+ for d in 0..dim {
71
+ mean[d] += data[r * dim + d];
72
+ }
73
+ }
74
+ for m in &mut mean {
75
+ *m /= rows as f32;
76
+ }
77
+ let mut centred: Vec<f32> = (0..rows * dim)
78
+ .map(|i| data[i] - mean[i % dim])
79
+ .collect();
80
+
81
+ let mut components = vec![0.0f32; COMPONENTS * dim];
82
+ let mut projected = vec![0.0f32; COMPONENTS * rows];
83
+
84
+ for c in 0..COMPONENTS {
85
+ // Deterministic, non-degenerate start vector. A constant vector
86
+ // would be orthogonal to components that sum to zero, so vary it.
87
+ let mut v: Vec<f32> = (0..dim)
88
+ .map(|d| ((d * 2654435761usize) % 1024) as f32 / 1024.0 - 0.5)
89
+ .collect();
90
+ normalize(&mut v);
91
+
92
+ let mut scores = vec![0.0f32; rows];
93
+ for _ in 0..48 {
94
+ // scores = X v ; v' = Xᵀ scores — power iteration on XᵀX
95
+ // without ever forming the dim×dim covariance matrix.
96
+ for r in 0..rows {
97
+ let row = &centred[r * dim..(r + 1) * dim];
98
+ scores[r] = row.iter().zip(&v).map(|(a, b)| a * b).sum();
99
+ }
100
+ let mut next = vec![0.0f32; dim];
101
+ for r in 0..rows {
102
+ let s = scores[r];
103
+ let row = &centred[r * dim..(r + 1) * dim];
104
+ for d in 0..dim {
105
+ next[d] += s * row[d];
106
+ }
107
+ }
108
+ if normalize(&mut next) < 1e-12 {
109
+ break;
110
+ }
111
+ v = next;
112
+ }
113
+
114
+ // Final scores, then deflate so the next iteration finds the
115
+ // next-strongest direction.
116
+ for r in 0..rows {
117
+ let row = &centred[r * dim..(r + 1) * dim];
118
+ let s: f32 = row.iter().zip(&v).map(|(a, b)| a * b).sum();
119
+ scores[r] = s;
120
+ projected[c * rows + r] = s;
121
+ }
122
+ for r in 0..rows {
123
+ let s = scores[r];
124
+ for d in 0..dim {
125
+ centred[r * dim + d] -= s * v[d];
126
+ }
127
+ }
128
+ components[c * dim..(c + 1) * dim].copy_from_slice(&v);
129
+ }
130
+
131
+ // Map each component to [0, 1] using a robust range rather than
132
+ // min/max: a single outlier patch would otherwise flatten the whole
133
+ // image into a narrow band of colour.
134
+ let mut lo = [0.0f32; COMPONENTS];
135
+ let mut span = [0.0f32; COMPONENTS];
136
+ for c in 0..COMPONENTS {
137
+ let mut col: Vec<f32> = projected[c * rows..(c + 1) * rows].to_vec();
138
+ col.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
139
+ lo[c] = col[(rows as f32 * 0.02) as usize];
140
+ let hi = col[((rows as f32 * 0.98) as usize).min(rows - 1)];
141
+ span[c] = hi - lo[c];
142
+ }
143
+
144
+ // A component carrying almost no variance relative to the leading
145
+ // one carries no visual information, and normalizing it to full
146
+ // range would amplify numerical noise into a psychedelic channel.
147
+ // This is not a contrived case: it is what a blank wall looks like.
148
+ // Map such a channel to flat mid-grey instead.
149
+ let dominant = span.iter().copied().fold(0.0f32, f32::max);
150
+ let floor = dominant * 1e-3;
151
+ let mut scale = [0.0f32; COMPONENTS];
152
+ let mut offset = [0.5f32; COMPONENTS];
153
+ for c in 0..COMPONENTS {
154
+ if span[c] > floor && span[c] > f32::MIN_POSITIVE {
155
+ scale[c] = 1.0 / span[c];
156
+ offset[c] = -lo[c] / span[c];
157
+ }
158
+ }
159
+
160
+ Self {
161
+ mean,
162
+ components,
163
+ scale,
164
+ offset,
165
+ dim,
166
+ }
167
+ }
168
+
169
+ /// The `[dim, COMPONENTS]` matrix the graph's projection matmul wants,
170
+ /// with the per-component scale folded in.
171
+ pub fn weight_matrix(&self) -> Vec<f32> {
172
+ let mut w = vec![0.0f32; self.dim * COMPONENTS];
173
+ for c in 0..COMPONENTS {
174
+ for d in 0..self.dim {
175
+ w[d * COMPONENTS + c] = self.components[c * self.dim + d] * self.scale[c];
176
+ }
177
+ }
178
+ w
179
+ }
180
+
181
+ /// The matching bias.
182
+ ///
183
+ /// `(x - mean) @ W * scale + offset` folds into `x @ (W * scale) + b`
184
+ /// with `b = offset - (mean @ W) * scale`, saving a subtraction pass
185
+ /// over every token.
186
+ pub fn bias_vector(&self) -> Vec<f32> {
187
+ let mut b = [0.0f32; COMPONENTS];
188
+ for c in 0..COMPONENTS {
189
+ let dot: f32 = (0..self.dim)
190
+ .map(|d| self.mean[d] * self.components[c * self.dim + d])
191
+ .sum();
192
+ b[c] = self.offset[c] - dot * self.scale[c];
193
+ }
194
+ b.to_vec()
195
+ }
196
+
197
+ /// CPU-side projection, for tests and for previewing a fit without a
198
+ /// GPU roundtrip.
199
+ pub fn project(&self, features: &[f32], tokens: usize) -> Vec<f32> {
200
+ let w = self.weight_matrix();
201
+ let b = self.bias_vector();
202
+ let mut out = vec![0.0f32; tokens * COMPONENTS];
203
+ for t in 0..tokens {
204
+ for c in 0..COMPONENTS {
205
+ let mut acc = b[c];
206
+ for d in 0..self.dim {
207
+ acc += features[t * self.dim + d] * w[d * COMPONENTS + c];
208
+ }
209
+ out[t * COMPONENTS + c] = acc;
210
+ }
211
+ }
212
+ out
213
+ }
214
+ }
215
+
216
+ fn normalize(v: &mut [f32]) -> f32 {
217
+ let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
218
+ if norm > 1e-12 {
219
+ for x in v.iter_mut() {
220
+ *x /= norm;
221
+ }
222
+ }
223
+ norm
224
+ }
225
+
226
+ /// Append the colour projection to an encoder output.
227
+ ///
228
+ /// Declares `pca.weight` and `pca.bias` as parameters, so refitting the
229
+ /// basis is a `set_parameter` call rather than a graph rebuild.
230
+ ///
231
+ /// Returns the `[tokens, COMPONENTS]` colour node. Folding this into the
232
+ /// graph rather than projecting on the CPU keeps the per-frame readback at
233
+ /// `tokens * 3` floats instead of `tokens * 384` — about 3 KB rather than
234
+ /// 300 KB, which matters on a bus shared with the compositor.
235
+ pub fn add_projection(g: &mut Graph, features: NodeId, hidden: usize) -> NodeId {
236
+ let w = g.parameter("pca.weight", &[hidden, COMPONENTS]);
237
+ let b = g.parameter("pca.bias", &[COMPONENTS]);
238
+ let projected = g.matmul(features, w);
239
+ g.bias_add(projected, b)
240
+ }
241
+
242
+ #[cfg(test)]
243
+ mod tests {
244
+ use super::*;
245
+
246
+ /// Features lying on a known plane must be recovered by the top two
247
+ /// components, with the projection spreading across the output range.
248
+ #[test]
249
+ fn recovers_a_planted_subspace() {
250
+ let dim = 32;
251
+ let tokens = 200;
252
+ let mut features = vec![0.0f32; tokens * dim];
253
+ for t in 0..tokens {
254
+ let a = (t as f32 / tokens as f32) * 2.0 - 1.0;
255
+ let b = ((t * 7 % tokens) as f32 / tokens as f32) * 2.0 - 1.0;
256
+ for d in 0..dim {
257
+ // Two strong directions plus a much weaker third.
258
+ features[t * dim + d] = if d == 3 {
259
+ 5.0 * a
260
+ } else if d == 11 {
261
+ 4.0 * b
262
+ } else {
263
+ 0.01 * ((d as f32) * 0.1 + a)
264
+ };
265
+ }
266
+ }
267
+
268
+ let basis = Basis::fit(&features, tokens, dim, 0);
269
+ // The first two components should be dominated by dims 3 and 11.
270
+ let c0 = &basis.components[0..dim];
271
+ let c1 = &basis.components[dim..2 * dim];
272
+ let strongest = |c: &[f32]| {
273
+ c.iter()
274
+ .enumerate()
275
+ .max_by(|a, b| a.1.abs().partial_cmp(&b.1.abs()).unwrap())
276
+ .unwrap()
277
+ .0
278
+ };
279
+ let (s0, s1) = (strongest(c0), strongest(c1));
280
+ assert!(
281
+ (s0 == 3 && s1 == 11) || (s0 == 11 && s1 == 3),
282
+ "expected components along dims 3 and 11, got {s0} and {s1}"
283
+ );
284
+ }
285
+
286
+ #[test]
287
+ fn components_are_orthonormal() {
288
+ let dim = 24;
289
+ let tokens = 90;
290
+ let features: Vec<f32> = (0..tokens * dim)
291
+ .map(|i| ((i * 37 % 101) as f32 / 101.0 - 0.5) * (1.0 + (i % 5) as f32))
292
+ .collect();
293
+ let basis = Basis::fit(&features, tokens, dim, 0);
294
+
295
+ for a in 0..COMPONENTS {
296
+ let va = &basis.components[a * dim..(a + 1) * dim];
297
+ let norm: f32 = va.iter().map(|x| x * x).sum::<f32>().sqrt();
298
+ assert!((norm - 1.0).abs() < 1e-3, "component {a} norm {norm}");
299
+ for b in (a + 1)..COMPONENTS {
300
+ let vb = &basis.components[b * dim..(b + 1) * dim];
301
+ let dot: f32 = va.iter().zip(vb).map(|(x, y)| x * y).sum();
302
+ assert!(dot.abs() < 1e-2, "components {a},{b} not orthogonal: {dot}");
303
+ }
304
+ }
305
+ }
306
+
307
+ /// The folded weight/bias form must agree with an explicit
308
+ /// centre-project-scale-offset, since the graph only sees the folded one.
309
+ #[test]
310
+ fn folded_weights_match_explicit_form() {
311
+ let dim = 16;
312
+ let tokens = 60;
313
+ let features: Vec<f32> = (0..tokens * dim)
314
+ .map(|i| ((i * 13 % 97) as f32 / 97.0 - 0.5) * 3.0)
315
+ .collect();
316
+ let basis = Basis::fit(&features, tokens, dim, 0);
317
+ let folded = basis.project(&features, tokens);
318
+
319
+ for t in 0..tokens {
320
+ for c in 0..COMPONENTS {
321
+ let explicit: f32 = (0..dim)
322
+ .map(|d| {
323
+ (features[t * dim + d] - basis.mean[d]) * basis.components[c * dim + d]
324
+ })
325
+ .sum::<f32>()
326
+ * basis.scale[c]
327
+ + basis.offset[c];
328
+ let got = folded[t * COMPONENTS + c];
329
+ assert!(
330
+ (got - explicit).abs() < 1e-3,
331
+ "token {t} component {c}: folded {got} vs explicit {explicit}"
332
+ );
333
+ }
334
+ }
335
+ }
336
+
337
+ /// The robust range should put the bulk of patches inside [0, 1],
338
+ /// otherwise the display is either washed out or clipped.
339
+ #[test]
340
+ fn projection_spans_the_display_range() {
341
+ let dim = 48;
342
+ let tokens = 300;
343
+ let features: Vec<f32> = (0..tokens * dim)
344
+ .map(|i| {
345
+ let t = (i / dim) as f32;
346
+ let d = (i % dim) as f32;
347
+ (t * 0.017 + d * 0.31).sin() * 2.0
348
+ })
349
+ .collect();
350
+ let basis = Basis::fit(&features, tokens, dim, 0);
351
+ let out = basis.project(&features, tokens);
352
+
353
+ for c in 0..COMPONENTS {
354
+ let vals: Vec<f32> = (0..tokens).map(|t| out[t * COMPONENTS + c]).collect();
355
+ let inside = vals.iter().filter(|v| (0.0..=1.0).contains(*v)).count();
356
+ let frac = inside as f32 / tokens as f32;
357
+ assert!(frac > 0.9, "component {c}: only {:.0}% inside [0,1]", frac * 100.0);
358
+ }
359
+ }
360
+
361
+ /// A near-featureless scene — a blank wall — must not be amplified into
362
+ /// a noise field. Degenerate components go flat grey instead.
363
+ #[test]
364
+ fn degenerate_components_stay_neutral() {
365
+ let dim = 32;
366
+ let tokens = 120;
367
+ // Rank-1 data: only the first component carries any variance.
368
+ let mut features = vec![0.0f32; tokens * dim];
369
+ for t in 0..tokens {
370
+ let a = t as f32 / tokens as f32;
371
+ for d in 0..dim {
372
+ features[t * dim + d] = a * (d as f32 * 0.05).cos();
373
+ }
374
+ }
375
+
376
+ let basis = Basis::fit(&features, tokens, dim, 0);
377
+ let out = basis.project(&features, tokens);
378
+ assert!(out.iter().all(|v| v.is_finite()), "projection produced non-finite values");
379
+
380
+ // Components 1 and 2 have no signal; every token should sit at the
381
+ // neutral value rather than spanning the range.
382
+ for c in 1..COMPONENTS {
383
+ let vals: Vec<f32> = (0..tokens).map(|t| out[t * COMPONENTS + c]).collect();
384
+ let lo = vals.iter().copied().fold(f32::INFINITY, f32::min);
385
+ let hi = vals.iter().copied().fold(f32::NEG_INFINITY, f32::max);
386
+ assert!(
387
+ (hi - lo) < 1e-3 && (lo - 0.5).abs() < 1e-3,
388
+ "component {c} should be flat mid-grey, spans [{lo}, {hi}]"
389
+ );
390
+ }
391
+ }
392
+
393
+ #[test]
394
+ fn placeholder_is_usable_before_the_first_fit() {
395
+ let basis = Basis::placeholder(384);
396
+ let features = vec![0.25f32; 8 * 384];
397
+ let out = basis.project(&features, 8);
398
+ assert_eq!(out.len(), 8 * COMPONENTS);
399
+ assert!(out.iter().all(|v| v.is_finite()));
400
+ }
401
+ }
environment/training-source/source-snapshot/src/preprocess.rs ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Turning an image into the `"patches"` tensor the encoder graph wants.
2
+ //!
3
+ //! The graph folds DINOv3's patch-embedding Conv2d into a single matmul,
4
+ //! which means the flattening order here has to agree exactly with the
5
+ //! order the convolution weight was flattened in. PyTorch stores that
6
+ //! weight as `[out_channels, in_channels, kh, kw]`, so within one patch
7
+ //! the element order is **channel-major**:
8
+ //!
9
+ //! ```text
10
+ //! index = c * patch_size² + ky * patch_size + kx
11
+ //! ```
12
+ //!
13
+ //! Get this wrong and the model still runs, producing confident nonsense
14
+ //! — so it is pinned down by tests below.
15
+
16
+ use crate::dinov3::Config;
17
+
18
+ /// ImageNet statistics from the model's `preprocessor_config.json`.
19
+ pub const IMAGE_MEAN: [f32; 3] = [0.485, 0.456, 0.406];
20
+ pub const IMAGE_STD: [f32; 3] = [0.229, 0.224, 0.225];
21
+
22
+ /// Flatten an already-normalized CHW pixel tensor into patches.
23
+ ///
24
+ /// `pixels` is `[3, image_size, image_size]`, matching what HuggingFace's
25
+ /// image processor hands to the model as `pixel_values`. Taking this form
26
+ /// directly is what lets the desktop verifier feed the exact same tensor
27
+ /// as the reference implementation, keeping resize and normalization
28
+ /// differences out of a numerics comparison.
29
+ ///
30
+ /// Returns `[num_patches, patch_dim]` in row-major grid order.
31
+ pub fn patches_from_pixels_chw(pixels: &[f32], config: &Config) -> Vec<f32> {
32
+ let size = config.image_size;
33
+ let ps = config.patch_size;
34
+ let grid = config.grid();
35
+ assert_eq!(
36
+ pixels.len(),
37
+ 3 * size * size,
38
+ "expected a [3, {size}, {size}] pixel tensor, got {} values",
39
+ pixels.len()
40
+ );
41
+
42
+ let plane = size * size;
43
+ let patch_area = ps * ps;
44
+ let mut out = vec![0.0f32; config.num_patches() * config.patch_dim()];
45
+
46
+ for gy in 0..grid {
47
+ for gx in 0..grid {
48
+ let patch = (gy * grid + gx) * config.patch_dim();
49
+ for c in 0..3 {
50
+ for ky in 0..ps {
51
+ let src_row = c * plane + (gy * ps + ky) * size + gx * ps;
52
+ let dst_row = patch + c * patch_area + ky * ps;
53
+ out[dst_row..dst_row + ps].copy_from_slice(&pixels[src_row..src_row + ps]);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ out
60
+ }
61
+
62
+ /// Flatten interleaved 8-bit RGB into patches, rescaling to `[0, 1]` and
63
+ /// applying the ImageNet normalization on the way.
64
+ ///
65
+ /// `rgb` is `[image_size, image_size, 3]` — the layout image decoders and
66
+ /// camera conversions naturally produce. No resizing happens here; the
67
+ /// caller supplies an image already at `config.image_size`.
68
+ pub fn patches_from_rgb8(rgb: &[u8], config: &Config) -> Vec<f32> {
69
+ let size = config.image_size;
70
+ let ps = config.patch_size;
71
+ let grid = config.grid();
72
+ assert_eq!(
73
+ rgb.len(),
74
+ 3 * size * size,
75
+ "expected a [{size}, {size}, 3] RGB image, got {} bytes",
76
+ rgb.len()
77
+ );
78
+
79
+ let patch_area = ps * ps;
80
+ let mut out = vec![0.0f32; config.num_patches() * config.patch_dim()];
81
+
82
+ for gy in 0..grid {
83
+ for gx in 0..grid {
84
+ let patch = (gy * grid + gx) * config.patch_dim();
85
+ for ky in 0..ps {
86
+ let y = gy * ps + ky;
87
+ for kx in 0..ps {
88
+ let x = gx * ps + kx;
89
+ let src = (y * size + x) * 3;
90
+ for c in 0..3 {
91
+ let v = rgb[src + c] as f32 / 255.0;
92
+ out[patch + c * patch_area + ky * ps + kx] =
93
+ (v - IMAGE_MEAN[c]) / IMAGE_STD[c];
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ out
101
+ }
102
+
103
+ /// Reshape a `[out, 3, patch, patch]` Conv2d weight into the
104
+ /// `[patch_dim, out]` matrix the graph's patch-embedding matmul expects.
105
+ ///
106
+ /// The source is already contiguous in channel-major order per output
107
+ /// channel, so this is purely a transpose of a `[out, patch_dim]` view.
108
+ pub fn conv_weight_to_matmul(weight: &[f32], out_channels: usize, patch_dim: usize) -> Vec<f32> {
109
+ assert_eq!(
110
+ weight.len(),
111
+ out_channels * patch_dim,
112
+ "conv weight has {} values, expected {out_channels} * {patch_dim}",
113
+ weight.len()
114
+ );
115
+ let mut m = vec![0.0f32; patch_dim * out_channels];
116
+ for o in 0..out_channels {
117
+ for i in 0..patch_dim {
118
+ m[i * out_channels + o] = weight[o * patch_dim + i];
119
+ }
120
+ }
121
+ m
122
+ }
123
+
124
+ #[cfg(test)]
125
+ mod tests {
126
+ use super::*;
127
+
128
+ #[test]
129
+ fn chw_patch_layout_is_channel_major() {
130
+ let c = Config::vits16();
131
+ // Encode each pixel's identity as its flat CHW index so the
132
+ // mapping is checkable by arithmetic.
133
+ let size = c.image_size;
134
+ let pixels: Vec<f32> = (0..3 * size * size).map(|i| i as f32).collect();
135
+ let patches = patches_from_pixels_chw(&pixels, &c);
136
+
137
+ let ps = c.patch_size;
138
+ let plane = size * size;
139
+ // Patch (gy=3, gx=5), channel 2, offset (ky=7, kx=11).
140
+ let (gy, gx, ch, ky, kx) = (3, 5, 2, 7, 11);
141
+ let got = patches[(gy * c.grid() + gx) * c.patch_dim() + ch * ps * ps + ky * ps + kx];
142
+ let want = (ch * plane + (gy * ps + ky) * size + gx * ps + kx) as f32;
143
+ assert_eq!(got, want);
144
+ }
145
+
146
+ #[test]
147
+ fn rgb8_and_chw_paths_agree() {
148
+ let c = Config::vits16();
149
+ let size = c.image_size;
150
+ // Build an arbitrary but reproducible RGB image, then the
151
+ // equivalent normalized CHW tensor, and check both flatteners
152
+ // land on the same patch tensor.
153
+ let rgb: Vec<u8> = (0..3 * size * size).map(|i| (i % 251) as u8).collect();
154
+ let mut chw = vec![0.0f32; 3 * size * size];
155
+ for y in 0..size {
156
+ for x in 0..size {
157
+ for ch in 0..3 {
158
+ let v = rgb[(y * size + x) * 3 + ch] as f32 / 255.0;
159
+ chw[ch * size * size + y * size + x] = (v - IMAGE_MEAN[ch]) / IMAGE_STD[ch];
160
+ }
161
+ }
162
+ }
163
+
164
+ let from_rgb = patches_from_rgb8(&rgb, &c);
165
+ let from_chw = patches_from_pixels_chw(&chw, &c);
166
+ assert_eq!(from_rgb.len(), from_chw.len());
167
+ let worst = from_rgb
168
+ .iter()
169
+ .zip(&from_chw)
170
+ .map(|(a, b)| (a - b).abs())
171
+ .fold(0.0f32, f32::max);
172
+ assert!(worst < 1e-6, "paths disagree by {worst}");
173
+ }
174
+
175
+ #[test]
176
+ fn normalization_maps_midgray_near_zero() {
177
+ let c = Config::vits16();
178
+ // 0.485*255 ≈ 124 is the red-channel mean, so red lands near 0.
179
+ let rgb = vec![124u8; 3 * c.image_size * c.image_size];
180
+ let patches = patches_from_rgb8(&rgb, &c);
181
+ assert!(patches[0].abs() < 0.02, "red channel not centred: {}", patches[0]);
182
+ }
183
+
184
+ #[test]
185
+ fn conv_weight_transpose_roundtrip() {
186
+ // [out=2, patch_dim=3] stored row-major becomes [3, 2].
187
+ let w = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
188
+ let m = conv_weight_to_matmul(&w, 2, 3);
189
+ assert_eq!(m, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
190
+ }
191
+ }
environment/training-source/source-snapshot/src/render.rs ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Drawing the coloured feature grid to an eye buffer.
2
+ //!
3
+ //! Deliberately tiny: a full-screen triangle and a `grid x grid x 3` float
4
+ //! buffer. The renderer runs at the headset's refresh rate regardless of
5
+ //! how slowly inference produces new grids, so the expensive part of the
6
+ //! frame is never on the display path.
7
+
8
+ use blade_graphics as gpu;
9
+
10
+ use crate::pca::COMPONENTS;
11
+
12
+ /// Maximum eyes we allocate parameter slots for.
13
+ pub const MAX_EYES: usize = 2;
14
+
15
+ #[repr(C)]
16
+ #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
17
+ struct Params {
18
+ scale: [f32; 2],
19
+ offset: [f32; 2],
20
+ grid: u32,
21
+ pad: [u32; 3],
22
+ }
23
+
24
+ /// Where a flat overlay lands in one eye's clip space.
25
+ ///
26
+ /// Purely 2D. The only thing an eye's frustum contributes to a head-locked
27
+ /// image is where its axis sits and how wide the image should be — a scale
28
+ /// and an offset — so that is all this carries.
29
+ #[derive(Clone, Copy, Debug)]
30
+ pub struct EyeTransform {
31
+ pub scale: [f32; 2],
32
+ pub offset: [f32; 2],
33
+ }
34
+
35
+ impl EyeTransform {
36
+ /// Fills the eye buffer exactly. Right for a flat window, wrong for a
37
+ /// headset, where it stretches the image over the whole field of view
38
+ /// and ignores the frustum's asymmetry.
39
+ pub const FULLSCREEN: Self = Self {
40
+ scale: [1.0, 1.0],
41
+ offset: [0.0, 0.0],
42
+ };
43
+
44
+ /// Fill an eye's field of view, centred on a given direction rather
45
+ /// than on the eye's axis.
46
+ ///
47
+ /// `centre_tan` is where the image's middle should sit, in tangent
48
+ /// units: `(x/-z, y/-z)` of the target direction expressed in the eye's
49
+ /// space. Zero is straight ahead and reproduces [`filling`].
50
+ ///
51
+ /// This is how the image gets to stay put while the head moves. The
52
+ /// content is as old as the last completed inference — a tenth of a
53
+ /// second here — so labelling it with the current head pose tells the
54
+ /// compositor to compensate for nothing, and it slides around with the
55
+ /// head. Feeding the direction the camera was *pointing when the frame
56
+ /// was captured* moves the image opposite to head motion, which is what
57
+ /// world-locked looks like.
58
+ pub fn filling_at(fov: [f32; 4], centre_tan: [f32; 2]) -> Self {
59
+ let [left, right, up, down] = fov;
60
+ let (tan_l, tan_r) = (left.tan(), right.tan());
61
+ let (tan_u, tan_d) = (up.tan(), down.tan());
62
+ let (span_x, span_y) = (tan_r - tan_l, tan_u - tan_d);
63
+ Self {
64
+ // Same size as filling the buffer.
65
+ scale: [1.0, 1.0],
66
+ offset: [
67
+ (2.0 * centre_tan[0] - (tan_r + tan_l)) / span_x,
68
+ (2.0 * centre_tan[1] - (tan_u + tan_d)) / span_y,
69
+ ],
70
+ }
71
+ }
72
+
73
+ /// Fill an eye's field of view, recentred on its axis.
74
+ ///
75
+ /// The right default for passthrough parity: the system's own
76
+ /// passthrough maps these cameras across your whole view, so matching it
77
+ /// means spanning the frustum rather than inventing an angular size.
78
+ ///
79
+ /// Note this is *not* the same as filling the eye buffer. Headset frusta
80
+ /// are asymmetric and mirror between the eyes, so an image painted
81
+ /// edge-to-edge has its centre at a different angle in each eye, and the
82
+ /// two will not fuse. Spanning the frustum symmetrically about the axis
83
+ /// is what makes them agree.
84
+ pub fn filling(fov: [f32; 4]) -> Self {
85
+ let [left, right, up, down] = fov;
86
+ let half_tan = [
87
+ (right.tan() - left.tan()) * 0.5,
88
+ (up.tan() - down.tan()) * 0.5,
89
+ ];
90
+ Self::for_eye(fov, half_tan)
91
+ }
92
+
93
+ /// Place an image into an eye whose frustum is `[left, right, up, down]`
94
+ /// radians, as OpenXR reports it.
95
+ ///
96
+ /// `half_tan` is the tangent of the image's half-angle on each axis.
97
+ /// Separate axes matter: the camera frame is squashed into a square for
98
+ /// the encoder, and giving x and y their true angular extents here is
99
+ /// what unsquashes it, so the view keeps the camera's full field
100
+ /// without looking stretched.
101
+ ///
102
+ /// A ray at angle θ from the view axis lands at clip
103
+ /// `(2·tanθ − (tanR + tanL)) / (tanR − tanL)`, so the quad's ±1 edges
104
+ /// map to a scale of `2·half_tan / (tanR − tanL)` about an offset of
105
+ /// `−(tanR + tanL) / (tanR − tanL)`.
106
+ ///
107
+ /// Headset frusta are asymmetric and differ between eyes, which is why
108
+ /// the offset is not zero and not shared: dropping it is what leaves the
109
+ /// two views unfusable.
110
+ pub fn for_eye(fov: [f32; 4], half_tan: [f32; 2]) -> Self {
111
+ let [left, right, up, down] = fov;
112
+ let (tan_l, tan_r) = (left.tan(), right.tan());
113
+ let (tan_u, tan_d) = (up.tan(), down.tan());
114
+ let (span_x, span_y) = (tan_r - tan_l, tan_u - tan_d);
115
+ Self {
116
+ scale: [
117
+ 2.0 * half_tan[0] / span_x,
118
+ 2.0 * half_tan[1] / span_y,
119
+ ],
120
+ offset: [-(tan_r + tan_l) / span_x, -(tan_u + tan_d) / span_y],
121
+ }
122
+ }
123
+ }
124
+
125
+ #[derive(blade_macros::ShaderData)]
126
+ struct ViewData {
127
+ params: gpu::BufferPiece,
128
+ cells: gpu::BufferPiece,
129
+ }
130
+
131
+ /// Renders the latest feature grid full-screen.
132
+ pub struct GridView {
133
+ pipeline: gpu::RenderPipeline,
134
+ params_buf: gpu::Buffer,
135
+ cells_buf: gpu::Buffer,
136
+ grid: usize,
137
+ }
138
+
139
+ impl GridView {
140
+ /// `color_format` must match the eye buffer being rendered into —
141
+ /// on an XR surface, `XrSurface::format()`.
142
+ pub fn new(context: &gpu::Context, color_format: gpu::TextureFormat, grid: usize) -> Self {
143
+ let shader = context.create_shader(gpu::ShaderDesc {
144
+ source: include_str!("shaders/grid_view.wgsl"),
145
+ naga_module: None,
146
+ });
147
+ let data_layout = <ViewData as gpu::ShaderData>::layout();
148
+ let pipeline = context.create_render_pipeline(gpu::RenderPipelineDesc {
149
+ name: "grid-view",
150
+ data_layouts: &[&data_layout],
151
+ vertex: shader.at("vs_main"),
152
+ vertex_fetches: &[],
153
+ primitive: gpu::PrimitiveState::default(),
154
+ // The view covers every pixel, so there is nothing to depth-test
155
+ // against and no depth buffer to allocate.
156
+ depth_stencil: None,
157
+ fragment: Some(shader.at("fs_main")),
158
+ color_targets: &[gpu::ColorTargetState::from(color_format)],
159
+ multisample_state: gpu::MultisampleState::default(),
160
+ });
161
+
162
+ // One slot per eye: the projection differs between them, and both
163
+ // are in flight within a single frame's submission.
164
+ let params_buf = context.create_buffer(gpu::BufferDesc {
165
+ name: "grid-view-params",
166
+ size: (std::mem::size_of::<Params>() * MAX_EYES) as u64,
167
+ memory: gpu::Memory::Shared,
168
+ });
169
+ let cells_buf = context.create_buffer(gpu::BufferDesc {
170
+ name: "grid-view-cells",
171
+ size: (grid * grid * COMPONENTS * std::mem::size_of::<f32>()) as u64,
172
+ memory: gpu::Memory::Shared,
173
+ });
174
+
175
+ let mut view = Self {
176
+ pipeline,
177
+ params_buf,
178
+ cells_buf,
179
+ grid,
180
+ };
181
+ // Full-screen until the caller supplies per-eye transforms, which
182
+ // is right for a window and wrong for a headset.
183
+ for eye in 0..MAX_EYES {
184
+ view.set_transform(context, eye, EyeTransform::FULLSCREEN);
185
+ }
186
+ // A mid-grey field, so a failure to ever produce a result looks
187
+ // like "no data" rather than like a working black-and-nothing view.
188
+ view.upload(context, &vec![0.5; grid * grid * COMPONENTS]);
189
+ view
190
+ }
191
+
192
+ /// Replace the displayed grid. `rgb` is `[grid * grid, 3]`, row-major.
193
+ pub fn upload(&mut self, context: &gpu::Context, rgb: &[f32]) {
194
+ let expected = self.grid * self.grid * COMPONENTS;
195
+ debug_assert_eq!(rgb.len(), expected, "grid payload is the wrong size");
196
+ let n = rgb.len().min(expected);
197
+ unsafe {
198
+ std::ptr::copy_nonoverlapping(rgb.as_ptr(), self.cells_buf.data() as *mut f32, n);
199
+ }
200
+ context.sync_buffer(self.cells_buf);
201
+ }
202
+
203
+ /// Set where the overlay lands in one eye.
204
+ pub fn set_transform(&mut self, context: &gpu::Context, eye: usize, t: EyeTransform) {
205
+ debug_assert!(eye < MAX_EYES);
206
+ unsafe {
207
+ let slot = (self.params_buf.data() as *mut Params).add(eye);
208
+ *slot = Params {
209
+ scale: t.scale,
210
+ offset: t.offset,
211
+ grid: self.grid as u32,
212
+ pad: [0; 3],
213
+ };
214
+ }
215
+ context.sync_buffer(self.params_buf);
216
+ }
217
+
218
+ /// Draw into an already-started render pass, using `eye`'s transform.
219
+ pub fn draw(&self, pass: &mut gpu::RenderCommandEncoder, eye: usize) {
220
+ let offset = (eye.min(MAX_EYES - 1) * std::mem::size_of::<Params>()) as u64;
221
+ let mut encoder = pass.with(&self.pipeline);
222
+ encoder.bind(
223
+ 0,
224
+ &ViewData {
225
+ params: self.params_buf.at(offset),
226
+ cells: self.cells_buf.into(),
227
+ },
228
+ );
229
+ encoder.draw(0, 6, 0, 1);
230
+ }
231
+
232
+ pub fn destroy(mut self, context: &gpu::Context) {
233
+ context.destroy_buffer(self.cells_buf);
234
+ context.destroy_buffer(self.params_buf);
235
+ context.destroy_render_pipeline(&mut self.pipeline);
236
+ }
237
+ }
238
+
239
+ /// Rotate a vector by a quaternion `[x, y, z, w]`.
240
+ fn rotate(q: [f32; 4], v: [f32; 3]) -> [f32; 3] {
241
+ let (qx, qy, qz, qw) = (q[0], q[1], q[2], q[3]);
242
+ // t = 2 * (q_vec × v); v' = v + qw * t + q_vec × t
243
+ let tx = 2.0 * (qy * v[2] - qz * v[1]);
244
+ let ty = 2.0 * (qz * v[0] - qx * v[2]);
245
+ let tz = 2.0 * (qx * v[1] - qy * v[0]);
246
+ [
247
+ v[0] + qw * tx + qy * tz - qz * ty,
248
+ v[1] + qw * ty + qz * tx - qx * tz,
249
+ v[2] + qw * tz + qx * ty - qy * tx,
250
+ ]
251
+ }
252
+
253
+ /// Where a direction that was straight ahead under `then` appears under
254
+ /// `now`, in tangent units suitable for [`EyeTransform::filling_at`].
255
+ ///
256
+ /// Both quaternions are `[x, y, z, w]` in the same reference space. The
257
+ /// result is the image's centre after the head has moved, so a rotation to
258
+ /// the right pushes the content left — the image staying put in the world
259
+ /// while the view sweeps across it.
260
+ ///
261
+ /// Returns `None` when the old direction has swung behind the viewer, where
262
+ /// a tangent-plane shift stops meaning anything.
263
+ pub fn reprojection_offset(then: [f32; 4], now: [f32; 4]) -> Option<[f32; 2]> {
264
+ // Forward under the capture pose, brought into the current eye's space
265
+ // by the inverse of the current pose.
266
+ let forward = rotate(then, [0.0, 0.0, -1.0]);
267
+ let inverse_now = [-now[0], -now[1], -now[2], now[3]];
268
+ let v = rotate(inverse_now, forward);
269
+ if v[2] >= -1e-3 {
270
+ return None;
271
+ }
272
+ Some([v[0] / -v[2], v[1] / -v[2]])
273
+ }
274
+
275
+ #[cfg(test)]
276
+ mod tests {
277
+ use super::*;
278
+
279
+ /// Straight ahead stays straight ahead when the head has not moved.
280
+ #[test]
281
+ fn no_motion_means_no_shift() {
282
+ let identity = [0.0, 0.0, 0.0, 1.0];
283
+ let offset = reprojection_offset(identity, identity).unwrap();
284
+ assert!(offset[0].abs() < 1e-6 && offset[1].abs() < 1e-6);
285
+ }
286
+
287
+ /// Turning the head right must push the content left, so the image
288
+ /// appears to stay where it was in the world.
289
+ #[test]
290
+ fn turning_right_pushes_content_left() {
291
+ let identity = [0.0, 0.0, 0.0, 1.0];
292
+ // +15 degrees about Y is a leftward yaw in a right-handed system
293
+ // looking down -Z, so -15 turns the view to the right.
294
+ let half = (-15.0f32).to_radians() * 0.5;
295
+ let turned_right = [0.0, half.sin(), 0.0, half.cos()];
296
+ let offset = reprojection_offset(identity, turned_right).unwrap();
297
+ assert!(
298
+ offset[0] < -0.1,
299
+ "expected the image to move left, got {offset:?}"
300
+ );
301
+ // The magnitude should be tan(15 deg) = 0.268.
302
+ assert!((offset[0] + 15.0f32.to_radians().tan()).abs() < 1e-3);
303
+ assert!(offset[1].abs() < 1e-6, "yaw should not shift vertically");
304
+ }
305
+
306
+ /// Looking up must push the content down.
307
+ #[test]
308
+ fn looking_up_pushes_content_down() {
309
+ let identity = [0.0, 0.0, 0.0, 1.0];
310
+ let half = 10.0f32.to_radians() * 0.5;
311
+ let looked_up = [half.sin(), 0.0, 0.0, half.cos()];
312
+ let offset = reprojection_offset(identity, looked_up).unwrap();
313
+ assert!(offset[1] < -0.1, "expected downward shift, got {offset:?}");
314
+ assert!(offset[0].abs() < 1e-6);
315
+ }
316
+
317
+ /// A view swung right round has nothing sensible to show.
318
+ #[test]
319
+ fn facing_away_has_no_offset() {
320
+ let identity = [0.0, 0.0, 0.0, 1.0];
321
+ let behind = [0.0, 1.0, 0.0, 0.0];
322
+ assert!(reprojection_offset(identity, behind).is_none());
323
+ }
324
+ }
environment/training-source/source-snapshot/src/shaders/grid_view.wgsl ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The feature grid, drawn as a head-locked 2D overlay.
2
+ //
3
+ // A quad in clip space with a per-eye scale and offset. No 3D: the content
4
+ // is flat and pinned to the head, so nothing here needs a view or a
5
+ // projection matrix.
6
+ //
7
+ // The per-eye transform is not optional decoration, though. A headset's
8
+ // frusta are asymmetric — the view axis is not at the centre of the eye
9
+ // buffer, and the two eyes differ — so painting identical pixels into both
10
+ // buffers puts the same content at different apparent angles and the views
11
+ // refuse to fuse. `offset` recentres on each eye's view axis; `scale` sizes
12
+ // the image to the angle the camera actually saw, instead of stretching it
13
+ // across the whole display and looking zoomed.
14
+
15
+ struct Params {
16
+ scale: vec2<f32>,
17
+ offset: vec2<f32>,
18
+ grid: u32,
19
+ pad0: u32,
20
+ pad1: u32,
21
+ pad2: u32,
22
+ };
23
+ var<storage, read> params: Params;
24
+ var<storage, read> cells: array<f32>;
25
+
26
+ struct VsOut {
27
+ @builtin(position) position: vec4<f32>,
28
+ @location(0) uv: vec2<f32>,
29
+ };
30
+
31
+ @vertex
32
+ fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
33
+ // Two triangles over the unit square.
34
+ var xs = array<f32, 6>(-1.0, 1.0, -1.0, -1.0, 1.0, 1.0);
35
+ var ys = array<f32, 6>(-1.0, -1.0, 1.0, 1.0, -1.0, 1.0);
36
+ let x = xs[vi];
37
+ let y = ys[vi];
38
+
39
+ var out: VsOut;
40
+ out.position = vec4<f32>(
41
+ x * params.scale.x + params.offset.x,
42
+ y * params.scale.y + params.offset.y,
43
+ 0.0,
44
+ 1.0,
45
+ );
46
+ // Blade uses a negative-height viewport, so clip +y is the top of the
47
+ // framebuffer. Row 0 of the grid is the top of the image, so v runs
48
+ // opposite to y.
49
+ out.uv = vec2<f32>(x * 0.5 + 0.5, 0.5 - y * 0.5);
50
+ return out;
51
+ }
52
+
53
+ fn cell(ix: i32, iy: i32) -> vec3<f32> {
54
+ let g = i32(params.grid);
55
+ let x = clamp(ix, 0, g - 1);
56
+ let y = clamp(iy, 0, g - 1);
57
+ let base = u32((y * g + x) * 3);
58
+ return vec3<f32>(cells[base], cells[base + 1u], cells[base + 2u]);
59
+ }
60
+
61
+ @fragment
62
+ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
63
+ let p = in.uv * f32(params.grid) - 0.5;
64
+ let corner = floor(p);
65
+ let frac = p - corner;
66
+ let ix = i32(corner.x);
67
+ let iy = i32(corner.y);
68
+
69
+ let top = mix(cell(ix, iy), cell(ix + 1, iy), frac.x);
70
+ let bottom = mix(cell(ix, iy + 1), cell(ix + 1, iy + 1), frac.x);
71
+ let rgb = clamp(mix(top, bottom, frac.y), vec3<f32>(0.0), vec3<f32>(1.0));
72
+ return vec4<f32>(rgb, 1.0);
73
+ }
environment/training-source/source-snapshot/src/source.rs ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Where frames come from.
2
+ //!
3
+ //! The passthrough camera is not wired up yet, so this exists mainly as the
4
+ //! seam it will slot into: the XR loop pulls from a [`FrameSource`] and does
5
+ //! not care whether the pixels came from a camera or were made up.
6
+ //!
7
+ //! Until then [`TestPattern`] provides something with real spatial
8
+ //! structure. That matters more than it sounds — a flat or noisy image
9
+ //! makes every patch feature statistically identical, so the PCA colouring
10
+ //! would look plausible while proving nothing. A scene with distinct
11
+ //! regions shows immediately whether features are tracking content.
12
+
13
+ /// A source of square RGB8 frames at the encoder's input resolution.
14
+ pub trait FrameSource {
15
+ /// Side length in pixels; must match `Config::image_size`.
16
+ fn size(&self) -> usize;
17
+
18
+ /// The next frame as interleaved RGB8, `[size, size, 3]`, or `None`
19
+ /// when nothing new is available.
20
+ fn next_frame(&mut self) -> Option<&[u8]>;
21
+ }
22
+
23
+ /// A moving synthetic scene: coloured discs drifting over a gradient, with
24
+ /// a checkerboard patch for high-frequency contrast.
25
+ ///
26
+ /// Deliberately built from a few large, distinctly-coloured regions, since
27
+ /// that is what DINO features separate well and therefore what makes the
28
+ /// PCA view legible while bringing the pipeline up.
29
+ pub struct TestPattern {
30
+ size: usize,
31
+ buf: Vec<u8>,
32
+ frame: u32,
33
+ }
34
+
35
+ impl TestPattern {
36
+ pub fn new(size: usize) -> Self {
37
+ Self {
38
+ size,
39
+ buf: vec![0; size * size * 3],
40
+ frame: 0,
41
+ }
42
+ }
43
+
44
+ fn render(&mut self) {
45
+ let size = self.size;
46
+ let t = self.frame as f32 * 0.02;
47
+ // Three discs on circular paths, each a saturated primary so the
48
+ // top principal components have something unambiguous to separate.
49
+ let discs = [
50
+ (0.30 + 0.18 * t.cos(), 0.30 + 0.18 * t.sin(), 0.16, [230u8, 60, 50]),
51
+ (0.70 + 0.15 * (t * 0.7 + 2.0).cos(), 0.35 + 0.15 * (t * 0.7).sin(), 0.13, [60, 200, 90]),
52
+ (0.50 + 0.20 * (t * 0.5 + 4.0).sin(), 0.72 + 0.10 * (t * 0.9).cos(), 0.15, [70, 110, 235]),
53
+ ];
54
+
55
+ for y in 0..size {
56
+ let v = y as f32 / size as f32;
57
+ for x in 0..size {
58
+ let u = x as f32 / size as f32;
59
+
60
+ // Background: a slow vertical gradient.
61
+ let mut rgb = [
62
+ (40.0 + 60.0 * v) as u8,
63
+ (50.0 + 40.0 * (1.0 - v)) as u8,
64
+ (70.0 + 50.0 * v) as u8,
65
+ ];
66
+
67
+ // A checkerboard corner, for a region whose texture differs
68
+ // from everything else without its colour doing so.
69
+ if u > 0.72 && v > 0.72 {
70
+ let cell = ((x / 8) + (y / 8)) % 2;
71
+ let shade = if cell == 0 { 200 } else { 90 };
72
+ rgb = [shade, shade, shade];
73
+ }
74
+
75
+ for &(cx, cy, r, color) in &discs {
76
+ let dx = u - cx;
77
+ let dy = v - cy;
78
+ if dx * dx + dy * dy < r * r {
79
+ rgb = color;
80
+ }
81
+ }
82
+
83
+ let i = (y * size + x) * 3;
84
+ self.buf[i] = rgb[0];
85
+ self.buf[i + 1] = rgb[1];
86
+ self.buf[i + 2] = rgb[2];
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ impl FrameSource for TestPattern {
93
+ fn size(&self) -> usize {
94
+ self.size
95
+ }
96
+
97
+ fn next_frame(&mut self) -> Option<&[u8]> {
98
+ self.render();
99
+ self.frame = self.frame.wrapping_add(1);
100
+ Some(&self.buf)
101
+ }
102
+ }
103
+
104
+ /// Centre-crop an interleaved RGBA image to a square and resample it down
105
+ /// to `size`, dropping alpha.
106
+ ///
107
+ /// Box-averaging rather than nearest: a patch embedding sees 16×16 pixels,
108
+ /// and point-sampling a 2560-wide screen down to 224 would alias hard
109
+ /// enough to change what the features encode. Cheap here — it runs once per
110
+ /// captured frame, not per patch.
111
+ pub fn square_downscale_rgba(rgba: &[u8], width: usize, height: usize, size: usize) -> Vec<u8> {
112
+ let side = width.min(height);
113
+ let x0 = (width - side) / 2;
114
+ let y0 = (height - side) / 2;
115
+
116
+ let mut out = vec![0u8; size * size * 3];
117
+ for oy in 0..size {
118
+ let sy0 = y0 + oy * side / size;
119
+ let sy1 = (y0 + (oy + 1) * side / size).max(sy0 + 1);
120
+ for ox in 0..size {
121
+ let sx0 = x0 + ox * side / size;
122
+ let sx1 = (x0 + (ox + 1) * side / size).max(sx0 + 1);
123
+
124
+ let mut acc = [0u32; 3];
125
+ let mut n = 0u32;
126
+ for sy in sy0..sy1.min(height) {
127
+ for sx in sx0..sx1.min(width) {
128
+ let i = (sy * width + sx) * 4;
129
+ acc[0] += rgba[i] as u32;
130
+ acc[1] += rgba[i + 1] as u32;
131
+ acc[2] += rgba[i + 2] as u32;
132
+ n += 1;
133
+ }
134
+ }
135
+ let n = n.max(1);
136
+ let o = (oy * size + ox) * 3;
137
+ for c in 0..3 {
138
+ out[o + c] = (acc[c] / n) as u8;
139
+ }
140
+ }
141
+ }
142
+ out
143
+ }
144
+
145
+ /// Live screen capture.
146
+ ///
147
+ /// Exists mostly so the whole pipeline can be developed and demonstrated
148
+ /// without a headset: it is the same `FrameSource` contract the Quest
149
+ /// passthrough camera will implement, so the capture loop, colour handling,
150
+ /// and downscale are all exercised here first.
151
+ #[cfg(feature = "capture")]
152
+ pub struct ScreenCapture {
153
+ monitor: xcap::Monitor,
154
+ size: usize,
155
+ buf: Vec<u8>,
156
+ }
157
+
158
+ #[cfg(feature = "capture")]
159
+ impl ScreenCapture {
160
+ /// Capture the monitor at `index`, or the primary one if out of range.
161
+ pub fn new(size: usize, index: usize) -> Result<Self, Box<dyn std::error::Error>> {
162
+ let monitors = xcap::Monitor::all()?;
163
+ if monitors.is_empty() {
164
+ return Err("no monitors found".into());
165
+ }
166
+ for (i, m) in monitors.iter().enumerate() {
167
+ log::info!(
168
+ "monitor {i}: {}x{}{}",
169
+ m.width().unwrap_or(0),
170
+ m.height().unwrap_or(0),
171
+ if i == index { " (selected)" } else { "" }
172
+ );
173
+ }
174
+ let monitor = monitors
175
+ .into_iter()
176
+ .nth(index)
177
+ .ok_or("monitor index out of range")?;
178
+ Ok(Self {
179
+ monitor,
180
+ size,
181
+ buf: vec![128; size * size * 3],
182
+ })
183
+ }
184
+ }
185
+
186
+ #[cfg(feature = "capture")]
187
+ impl FrameSource for ScreenCapture {
188
+ fn size(&self) -> usize {
189
+ self.size
190
+ }
191
+
192
+ fn next_frame(&mut self) -> Option<&[u8]> {
193
+ // A dropped frame is not worth failing over — the previous one is
194
+ // still displayable, and capture hiccups when windows change.
195
+ match self.monitor.capture_image() {
196
+ Ok(image) => {
197
+ let (w, h) = (image.width() as usize, image.height() as usize);
198
+ self.buf = square_downscale_rgba(&image.into_raw(), w, h, self.size);
199
+ Some(&self.buf)
200
+ }
201
+ Err(e) => {
202
+ log::warn!("screen capture failed: {e}");
203
+ None
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ #[cfg(test)]
210
+ mod tests {
211
+ use super::*;
212
+
213
+ #[test]
214
+ fn downscale_centre_crops_and_averages() {
215
+ // A 40×20 image: left half red, right half blue. The centre crop is
216
+ // the middle 20×20, which straddles the boundary evenly.
217
+ let (w, h) = (40usize, 20usize);
218
+ let mut rgba = vec![0u8; w * h * 4];
219
+ for y in 0..h {
220
+ for x in 0..w {
221
+ let i = (y * w + x) * 4;
222
+ let c = if x < w / 2 { [255, 0, 0] } else { [0, 0, 255] };
223
+ rgba[i..i + 3].copy_from_slice(&c);
224
+ rgba[i + 3] = 255;
225
+ }
226
+ }
227
+
228
+ let out = square_downscale_rgba(&rgba, w, h, 4);
229
+ assert_eq!(out.len(), 4 * 4 * 3);
230
+ // Left column should be red, right column blue.
231
+ let px = |x: usize, y: usize| {
232
+ let i = (y * 4 + x) * 3;
233
+ [out[i], out[i + 1], out[i + 2]]
234
+ };
235
+ assert_eq!(px(0, 0), [255, 0, 0], "left edge should be red");
236
+ assert_eq!(px(3, 0), [0, 0, 255], "right edge should be blue");
237
+ }
238
+
239
+ #[test]
240
+ fn downscale_averages_rather_than_point_samples() {
241
+ // Alternating single-pixel columns must average to grey, not pick
242
+ // one extreme. Point sampling would give 0 or 255.
243
+ let (w, h) = (64usize, 64usize);
244
+ let mut rgba = vec![255u8; w * h * 4];
245
+ for y in 0..h {
246
+ for x in 0..w {
247
+ let v = if x % 2 == 0 { 0 } else { 255 };
248
+ let i = (y * w + x) * 4;
249
+ rgba[i..i + 3].copy_from_slice(&[v, v, v]);
250
+ }
251
+ }
252
+ let out = square_downscale_rgba(&rgba, w, h, 8);
253
+ for px in out.chunks_exact(3) {
254
+ assert!(
255
+ (100..=155).contains(&px[0]),
256
+ "expected mid-grey from averaging, got {}",
257
+ px[0]
258
+ );
259
+ }
260
+ }
261
+
262
+ #[test]
263
+ fn test_pattern_has_spatial_structure() {
264
+ let mut src = TestPattern::new(224);
265
+ let frame = src.next_frame().unwrap().to_vec();
266
+ assert_eq!(frame.len(), 224 * 224 * 3);
267
+
268
+ // A uniform image would make the whole exercise meaningless, so
269
+ // check there is real variation to encode.
270
+ let mean = frame.iter().map(|&b| b as f64).sum::<f64>() / frame.len() as f64;
271
+ let var = frame
272
+ .iter()
273
+ .map(|&b| (b as f64 - mean).powi(2))
274
+ .sum::<f64>()
275
+ / frame.len() as f64;
276
+ assert!(var > 400.0, "test pattern is too flat: variance {var:.1}");
277
+ }
278
+
279
+ #[test]
280
+ fn test_pattern_animates() {
281
+ let mut src = TestPattern::new(64);
282
+ let a = src.next_frame().unwrap().to_vec();
283
+ for _ in 0..20 {
284
+ src.next_frame();
285
+ }
286
+ let b = src.next_frame().unwrap().to_vec();
287
+ assert_ne!(a, b, "frames are identical; the scene is not moving");
288
+ }
289
+ }
environment/training-source/source-snapshot/src/weights.rs ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Binding a HuggingFace DINOv3 checkpoint to the encoder graph's
2
+ //! parameters.
3
+ //!
4
+ //! Three shape conventions differ between the checkpoint and the graph,
5
+ //! and all three are handled here rather than in the graph:
6
+ //!
7
+ //! * `nn.Linear` stores `[out_features, in_features]`; meganeura's
8
+ //! `matmul` wants `[in, out]`. Every projection is transposed.
9
+ //! * The patch embedding is a 4D `[out, 3, k, k]` conv weight; it is
10
+ //! flattened to `[out, patch_dim]` and transposed.
11
+ //! * `cls_token` and `register_tokens` are separate parameters; the graph
12
+ //! declares one `prefix_tokens` matrix, assembled here.
13
+
14
+ use meganeura::Session;
15
+ use meganeura::data::safetensors::SafeTensorsModel;
16
+
17
+ use crate::dinov3::Config;
18
+ use crate::preprocess::conv_weight_to_matmul;
19
+
20
+ type Error = Box<dyn std::error::Error>;
21
+
22
+ /// Checkpoints appear both with and without a `model.` prefix on the
23
+ /// encoder layers, depending on whether they were saved from
24
+ /// `DINOv3ViTModel` or from the bare backbone. Resolve whichever this one
25
+ /// uses once, up front, instead of guessing per tensor.
26
+ fn layer_prefix(model: &SafeTensorsModel) -> Result<&'static str, Error> {
27
+ let info = model.tensor_info();
28
+ for candidate in ["layer.", "model.layer."] {
29
+ if info.keys().any(|k| k.starts_with(candidate)) {
30
+ return Ok(candidate);
31
+ }
32
+ }
33
+ Err(format!(
34
+ "no encoder layers found; checkpoint has {} tensors, e.g. {:?}",
35
+ info.len(),
36
+ info.keys().take(5).collect::<Vec<_>>()
37
+ )
38
+ .into())
39
+ }
40
+
41
+ /// Read a tensor under an optional `model.` prefix.
42
+ fn embedding_tensor(model: &SafeTensorsModel, name: &str) -> Result<Vec<f32>, Error> {
43
+ if model.tensor_info().contains_key(name) {
44
+ model.tensor_f32_auto(name)
45
+ } else {
46
+ model.tensor_f32_auto(&format!("model.{name}"))
47
+ }
48
+ }
49
+
50
+ /// Upload every parameter the encoder graph declares.
51
+ ///
52
+ /// Fails loudly on a missing or mis-shaped tensor: a silently skipped
53
+ /// weight would leave a zero buffer and produce plausible-looking
54
+ /// garbage.
55
+ pub fn load_encoder(
56
+ session: &mut Session,
57
+ model: &SafeTensorsModel,
58
+ config: &Config,
59
+ ) -> Result<(), Error> {
60
+ let hidden = config.hidden_size;
61
+ let prefix = layer_prefix(model)?;
62
+ log::info!("checkpoint layer prefix: {prefix:?}");
63
+
64
+ // --- Patch embedding: [out, 3, k, k] -> [patch_dim, out] ---
65
+ let conv = embedding_tensor(model, "embeddings.patch_embeddings.weight")?;
66
+ let expected = hidden * config.patch_dim();
67
+ if conv.len() != expected {
68
+ return Err(format!(
69
+ "patch embedding has {} values, expected {expected} \
70
+ (is the checkpoint's patch_size or hidden_size different?)",
71
+ conv.len()
72
+ )
73
+ .into());
74
+ }
75
+ session.set_parameter(
76
+ "embeddings.patch_embeddings.weight",
77
+ &conv_weight_to_matmul(&conv, hidden, config.patch_dim()),
78
+ );
79
+ session.set_parameter(
80
+ "embeddings.patch_embeddings.bias",
81
+ &embedding_tensor(model, "embeddings.patch_embeddings.bias")?,
82
+ );
83
+
84
+ // --- Prefix tokens: CLS first, then the registers ---
85
+ //
86
+ // Order matters and is not arbitrary: the reference builds the
87
+ // sequence as cat([cls, registers, patches]), and RoPE identifies
88
+ // prefix tokens purely by their position at the front.
89
+ let cls = embedding_tensor(model, "embeddings.cls_token")?;
90
+ let registers = embedding_tensor(model, "embeddings.register_tokens")?;
91
+ if cls.len() != hidden {
92
+ return Err(format!("cls_token has {} values, expected {hidden}", cls.len()).into());
93
+ }
94
+ if registers.len() != config.num_register_tokens * hidden {
95
+ return Err(format!(
96
+ "register_tokens has {} values, expected {} * {hidden}",
97
+ registers.len(),
98
+ config.num_register_tokens
99
+ )
100
+ .into());
101
+ }
102
+ let mut prefix_tokens = Vec::with_capacity(config.num_prefix_tokens() * hidden);
103
+ prefix_tokens.extend_from_slice(&cls);
104
+ prefix_tokens.extend_from_slice(&registers);
105
+ session.set_parameter("prefix_tokens", &prefix_tokens);
106
+
107
+ // --- Encoder layers ---
108
+ for i in 0..config.num_hidden_layers {
109
+ let src = format!("{prefix}{i}");
110
+ let dst = format!("layer.{i}");
111
+
112
+ for norm in ["norm1", "norm2"] {
113
+ for part in ["weight", "bias"] {
114
+ session.set_parameter(
115
+ &format!("{dst}.{norm}.{part}"),
116
+ &model.tensor_f32_auto(&format!("{src}.{norm}.{part}"))?,
117
+ );
118
+ }
119
+ }
120
+
121
+ // K has no bias — `key_bias: false` in the config.
122
+ for (proj, has_bias) in [
123
+ ("q_proj", true),
124
+ ("k_proj", false),
125
+ ("v_proj", true),
126
+ ("o_proj", true),
127
+ ] {
128
+ session.set_parameter(
129
+ &format!("{dst}.attention.{proj}.weight"),
130
+ &model.tensor_f32_auto_transposed(&format!("{src}.attention.{proj}.weight"))?,
131
+ );
132
+ if has_bias {
133
+ session.set_parameter(
134
+ &format!("{dst}.attention.{proj}.bias"),
135
+ &model.tensor_f32_auto(&format!("{src}.attention.{proj}.bias"))?,
136
+ );
137
+ }
138
+ }
139
+
140
+ for proj in ["up_proj", "down_proj"] {
141
+ session.set_parameter(
142
+ &format!("{dst}.mlp.{proj}.weight"),
143
+ &model.tensor_f32_auto_transposed(&format!("{src}.mlp.{proj}.weight"))?,
144
+ );
145
+ session.set_parameter(
146
+ &format!("{dst}.mlp.{proj}.bias"),
147
+ &model.tensor_f32_auto(&format!("{src}.mlp.{proj}.bias"))?,
148
+ );
149
+ }
150
+
151
+ for ls in ["layer_scale1", "layer_scale2"] {
152
+ session.set_parameter(
153
+ &format!("{dst}.{ls}.lambda1"),
154
+ &model.tensor_f32_auto(&format!("{src}.{ls}.lambda1"))?,
155
+ );
156
+ }
157
+ }
158
+
159
+ // --- Final norm ---
160
+ for part in ["weight", "bias"] {
161
+ session.set_parameter(
162
+ &format!("norm.{part}"),
163
+ &embedding_tensor(model, &format!("norm.{part}"))?,
164
+ );
165
+ }
166
+
167
+ log::info!(
168
+ "loaded DINOv3 weights: {} layers, hidden {hidden}",
169
+ config.num_hidden_layers
170
+ );
171
+ Ok(())
172
+ }
environment/training-source/source-snapshot/tests/dinov3_ops.rs ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Numerical checks at the exact tensor shapes used by DINOv3.
2
+
3
+ use meganeura::Graph;
4
+ use meganeura::train::{Mode, SessionConfig};
5
+
6
+ #[test]
7
+ fn full_attention_matches_cpu_at_dinov3_shape() {
8
+ let sequence = 201usize;
9
+ let heads = 6usize;
10
+ let head_dim = 64usize;
11
+ let hidden = heads * head_dim;
12
+
13
+ let mut graph = Graph::new();
14
+ let q_node = graph.input("q", &[sequence, hidden]);
15
+ let k_node = graph.input("k", &[sequence, hidden]);
16
+ let v_node = graph.input("v", &[sequence, hidden]);
17
+ let output = graph.full_attention(
18
+ q_node,
19
+ k_node,
20
+ v_node,
21
+ heads as u32,
22
+ heads as u32,
23
+ head_dim as u32,
24
+ );
25
+ graph.set_outputs(vec![output]);
26
+
27
+ let q: Vec<f32> = (0..sequence * hidden)
28
+ .map(|i| (i as f32 * 0.013).sin() * 0.2)
29
+ .collect();
30
+ let k: Vec<f32> = (0..sequence * hidden)
31
+ .map(|i| (i as f32 * 0.017 + 0.3).cos() * 0.2)
32
+ .collect();
33
+ let v: Vec<f32> = (0..sequence * hidden)
34
+ .map(|i| (i as f32 * 0.019 - 0.2).sin() * 0.2)
35
+ .collect();
36
+
37
+ let mut expected = vec![0.0f32; sequence * hidden];
38
+ let scale = 1.0 / (head_dim as f32).sqrt();
39
+ for query in 0..sequence {
40
+ for head in 0..heads {
41
+ let offset = head * head_dim;
42
+ let mut scores = vec![0.0f32; sequence];
43
+ for key in 0..sequence {
44
+ scores[key] = (0..head_dim)
45
+ .map(|dimension| {
46
+ q[query * hidden + offset + dimension]
47
+ * k[key * hidden + offset + dimension]
48
+ })
49
+ .sum::<f32>()
50
+ * scale;
51
+ }
52
+ let maximum = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
53
+ let denominator: f32 = scores.iter().map(|score| (*score - maximum).exp()).sum();
54
+ for dimension in 0..head_dim {
55
+ expected[query * hidden + offset + dimension] = scores
56
+ .iter()
57
+ .enumerate()
58
+ .map(|(key, score)| {
59
+ (*score - maximum).exp() * v[key * hidden + offset + dimension]
60
+ })
61
+ .sum::<f32>()
62
+ / denominator;
63
+ }
64
+ }
65
+ }
66
+
67
+ let gpu = dinovision::init_context(None).expect("GPU context");
68
+ let (mut session, _) = meganeura::train::build(
69
+ &graph,
70
+ SessionConfig {
71
+ mode: Mode::Inference,
72
+ gpu: Some(gpu),
73
+ ..Default::default()
74
+ },
75
+ );
76
+ session.set_input("q", &q);
77
+ session.set_input("k", &k);
78
+ session.set_input("v", &v);
79
+ session.step();
80
+ session.wait();
81
+ let actual = session.read_output(expected.len());
82
+ let maximum_error = actual
83
+ .iter()
84
+ .zip(&expected)
85
+ .map(|(a, b)| (a - b).abs())
86
+ .fold(0.0f32, f32::max);
87
+ assert!(
88
+ maximum_error < 2e-4,
89
+ "full attention differs by {maximum_error} at DINOv3 shape"
90
+ );
91
+ }
92
+
93
+ #[test]
94
+ fn trailing_layer_scale_matches_cpu_at_dinov3_shape() {
95
+ let tokens = 201usize;
96
+ let hidden = 384usize;
97
+ let mut graph = Graph::new();
98
+ let input = graph.input("input", &[tokens, hidden]);
99
+ let gain = graph.input("gain", &[hidden]);
100
+ let transposed = graph.transpose(input);
101
+ let flat = graph.reshape(transposed, &[hidden * tokens]);
102
+ let scaled = graph.mul_per_channel(flat, gain, hidden as u32, tokens as u32);
103
+ let scaled = graph.reshape(scaled, &[hidden, tokens]);
104
+ let output = graph.transpose(scaled);
105
+ graph.set_outputs(vec![output]);
106
+
107
+ let values: Vec<f32> = (0..tokens * hidden)
108
+ .map(|index| (index as f32 * 0.017).sin())
109
+ .collect();
110
+ let gains: Vec<f32> = (0..hidden)
111
+ .map(|index| 0.1 + index as f32 / hidden as f32)
112
+ .collect();
113
+ let expected: Vec<f32> = values
114
+ .iter()
115
+ .enumerate()
116
+ .map(|(index, value)| value * gains[index % hidden])
117
+ .collect();
118
+
119
+ let gpu = dinovision::init_context(None).expect("GPU context");
120
+ let (mut session, _) = meganeura::train::build(
121
+ &graph,
122
+ SessionConfig {
123
+ mode: Mode::Inference,
124
+ gpu: Some(gpu),
125
+ ..Default::default()
126
+ },
127
+ );
128
+ session.set_input("input", &values);
129
+ session.set_input("gain", &gains);
130
+ session.step();
131
+ session.wait();
132
+ let actual = session.read_output(expected.len());
133
+ let maximum_error = actual
134
+ .iter()
135
+ .zip(&expected)
136
+ .map(|(a, b)| (a - b).abs())
137
+ .fold(0.0f32, f32::max);
138
+ assert!(
139
+ maximum_error < 1e-5,
140
+ "trailing LayerScale differs by {maximum_error}"
141
+ );
142
+ }
environment/training-source/source-snapshot/tests/semantics.rs ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Do the features actually mean anything?
2
+ //!
3
+ //! The point of DINO features is that patches of the same thing land near
4
+ //! each other in feature space regardless of position, and patches of
5
+ //! different things do not. That property is what the PCA colouring
6
+ //! displays and what any downstream use would rely on, and it is
7
+ //! surprisingly sensitive to exactly the mistakes that are easy to make
8
+ //! here: a transposed projection, a mis-paired RoPE half, or a patch
9
+ //! flattening in the wrong channel order all leave the magnitudes looking
10
+ //! healthy while destroying the structure.
11
+ //!
12
+ //! So rather than compare against a reference dump — which would need
13
+ //! torch on the machine — this checks the property directly, using a scene
14
+ //! with known regions.
15
+ //!
16
+ //! Requires the checkpoint. Set `DINOVISION_WEIGHTS` to a
17
+ //! `model.safetensors`; without it the test reports itself skipped, since
18
+ //! failing for a missing asset would be noise.
19
+
20
+ use dinovision::dinov3::Config;
21
+
22
+ /// Cosine similarity, the metric DINO features are usually compared under.
23
+ fn cosine(a: &[f32], b: &[f32]) -> f32 {
24
+ let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
25
+ let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
26
+ let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
27
+ dot / (na * nb).max(f32::MIN_POSITIVE)
28
+ }
29
+
30
+ /// A scene with four unambiguous regions, drawn as flat colour blocks so
31
+ /// the expected grouping is not a matter of opinion. Quadrants, at 224²:
32
+ /// top-left red, top-right green, bottom-left blue, bottom-right a fine
33
+ /// checkerboard (same mean colour as the background, different texture).
34
+ fn quadrant_scene(size: usize) -> Vec<u8> {
35
+ let mut rgb = vec![0u8; size * size * 3];
36
+ let half = size / 2;
37
+ for y in 0..size {
38
+ for x in 0..size {
39
+ let px = match (x < half, y < half) {
40
+ (true, true) => [200, 40, 40],
41
+ (false, true) => [40, 180, 60],
42
+ (true, false) => [50, 70, 210],
43
+ (false, false) => {
44
+ let on = ((x / 4) + (y / 4)) % 2 == 0;
45
+ if on { [230, 230, 230] } else { [30, 30, 30] }
46
+ }
47
+ };
48
+ let i = (y * size + x) * 3;
49
+ rgb[i..i + 3].copy_from_slice(&px);
50
+ }
51
+ }
52
+ rgb
53
+ }
54
+
55
+ /// Index of the patch at grid position (gx, gy), skipping prefix tokens.
56
+ fn patch(features: &[f32], config: &Config, gx: usize, gy: usize) -> Vec<f32> {
57
+ let t = config.num_prefix_tokens() + gy * config.grid() + gx;
58
+ let h = config.hidden_size;
59
+ features[t * h..(t + 1) * h].to_vec()
60
+ }
61
+
62
+ #[test]
63
+ fn features_group_by_content_not_position() {
64
+ let Ok(weights) = std::env::var("DINOVISION_WEIGHTS") else {
65
+ eprintln!("skipped: set DINOVISION_WEIGHTS to a model.safetensors to run this");
66
+ return;
67
+ };
68
+ let weights = std::path::PathBuf::from(weights);
69
+ if !weights.exists() {
70
+ eprintln!("skipped: {} does not exist", weights.display());
71
+ return;
72
+ }
73
+
74
+ let config = Config::vits16().at_resolution(224);
75
+ let gpu = dinovision::init_context(None).expect("GPU context");
76
+ let (mut session, _) = dinovision::bench::build_encoder_session(gpu, &config, None);
77
+ let model = meganeura::data::safetensors::SafeTensorsModel::load(weights).expect("read weights");
78
+ dinovision::weights::load_encoder(&mut session, &model, &config).expect("bind weights");
79
+
80
+ let rgb = quadrant_scene(config.image_size);
81
+ let patches = dinovision::preprocess::patches_from_rgb8(&rgb, &config);
82
+ session.set_input("patches", &patches);
83
+ session.step();
84
+ session.wait();
85
+ let features = session.read_output(config.num_tokens() * config.hidden_size);
86
+ assert!(
87
+ features.iter().all(|v| v.is_finite()),
88
+ "features contain non-finite values"
89
+ );
90
+
91
+ // Sample two well-separated patches inside each quadrant, staying away
92
+ // from the boundaries where receptive fields mix regions.
93
+ let g = config.grid();
94
+ let q = g / 4;
95
+ let regions = [
96
+ ("red", [(q, q), (q + 1, q + 1)]),
97
+ ("green", [(g - q - 1, q), (g - q - 2, q + 1)]),
98
+ ("blue", [(q, g - q - 1), (q + 1, g - q - 2)]),
99
+ ("checker", [(g - q - 1, g - q - 1), (g - q - 2, g - q - 2)]),
100
+ ];
101
+
102
+ // Within a region, two patches of the same material should be close.
103
+ let mut worst_within = 1.0f32;
104
+ for (name, pts) in &regions {
105
+ let a = patch(&features, &config, pts[0].0, pts[0].1);
106
+ let b = patch(&features, &config, pts[1].0, pts[1].1);
107
+ let c = cosine(&a, &b);
108
+ eprintln!("within {name:>8}: {c:.3}");
109
+ worst_within = worst_within.min(c);
110
+ }
111
+
112
+ // Across regions, patches of different material should be further apart
113
+ // than any same-material pair.
114
+ let mut best_across = -1.0f32;
115
+ for i in 0..regions.len() {
116
+ for j in (i + 1)..regions.len() {
117
+ let a = patch(&features, &config, regions[i].1[0].0, regions[i].1[0].1);
118
+ let b = patch(&features, &config, regions[j].1[0].0, regions[j].1[0].1);
119
+ let c = cosine(&a, &b);
120
+ eprintln!("across {:>8}/{:<8}: {c:.3}", regions[i].0, regions[j].0);
121
+ best_across = best_across.max(c);
122
+ }
123
+ }
124
+
125
+ eprintln!("worst within-region {worst_within:.3}, best across-region {best_across:.3}");
126
+ assert!(
127
+ worst_within > best_across,
128
+ "features do not separate content: the least similar same-region pair \
129
+ ({worst_within:.3}) scored below the most similar different-region pair \
130
+ ({best_across:.3}). A scrambled patch order or transposed projection \
131
+ looks exactly like this."
132
+ );
133
+ }
environment/training-source/source-snapshot/tests/threading.rs ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Compile-time constraints that dictate how inference is threaded.
2
+ //!
3
+ //! `blade_graphics::Context` is `Send + Sync` — it guards its queue,
4
+ //! memory manager, and XR session state behind mutexes — so an
5
+ //! `Arc<Context>` can be shared with a worker thread.
6
+ //!
7
+ //! `meganeura::Session` is **not** `Send`: it owns a
8
+ //! `blade_graphics::CommandEncoder`. The single offending field is
9
+ //! `ScratchBuffer::mapped`, a raw pointer into a persistently mapped
10
+ //! allocation — everything else is Vulkan handles, which are fine. (Rust has
11
+ //! no stable way to *assert* a negative, hence a comment not a test.)
12
+ //!
13
+ //! That is why [`dinovision::inference`] hands the worker an
14
+ //! `Arc<Context>` and has it construct the session in place. A one-line
15
+ //! `unsafe impl Send for ScratchBuffer` in blade lifts the constraint —
16
+ //! verified, and on the `command-encoder-send` branch there.
17
+
18
+ fn assert_send<T: Send>() {}
19
+ fn assert_sync<T: Sync>() {}
20
+
21
+ #[test]
22
+ fn context_can_be_shared_across_threads() {
23
+ assert_send::<blade_graphics::Context>();
24
+ assert_sync::<blade_graphics::Context>();
25
+ // Buffer handles carry explicit `unsafe impl Send/Sync`, so GPU-side
26
+ // results can be published to the render thread by handle.
27
+ assert_send::<blade_graphics::Buffer>();
28
+ assert_sync::<blade_graphics::Buffer>();
29
+ }
environment/training-source/source-snapshot/tools/build_android_artifacts.ps1 ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [string]$OutputDir,
4
+ [string]$AndroidSdk,
5
+ [string]$NdkRoot
6
+ )
7
+
8
+ $ErrorActionPreference = 'Stop'
9
+ $repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
10
+ if (-not $AndroidSdk) {
11
+ if (-not $env:LOCALAPPDATA) { throw 'pass -AndroidSdk when LOCALAPPDATA is unavailable' }
12
+ $AndroidSdk = Join-Path $env:LOCALAPPDATA 'Android\Sdk'
13
+ }
14
+ $sdk = (Resolve-Path $AndroidSdk).Path
15
+ if (-not $NdkRoot) { $NdkRoot = Join-Path $sdk 'ndk\27.0.12077973' }
16
+ $ndk = (Resolve-Path $NdkRoot).Path
17
+ $clang = Join-Path $ndk 'toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android34-clang.cmd'
18
+ $ar = Join-Path $ndk 'toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-ar.exe'
19
+ foreach ($tool in @($clang, $ar)) {
20
+ if (-not (Test-Path -LiteralPath $tool -PathType Leaf)) { throw "missing NDK tool: $tool" }
21
+ }
22
+ $sourceProperties = Get-Content -Raw -LiteralPath (Join-Path $ndk 'source.properties')
23
+ if ($sourceProperties -notmatch '(?m)^Pkg\.Revision\s*=\s*27\.') {
24
+ throw "DinoVision's API-34 camera build requires NDK r27; got $sourceProperties"
25
+ }
26
+
27
+ $destination = [System.IO.Path]::GetFullPath($OutputDir)
28
+ if (Test-Path -LiteralPath $destination) {
29
+ if (@(Get-ChildItem -LiteralPath $destination -Force).Count -ne 0) {
30
+ throw "Refusing to merge Android build output into non-empty directory: $destination"
31
+ }
32
+ } else {
33
+ New-Item -ItemType Directory -Path $destination | Out-Null
34
+ }
35
+
36
+ $env:ANDROID_HOME = $sdk
37
+ $env:ANDROID_SDK_ROOT = $sdk
38
+ $env:ANDROID_NDK_ROOT = $ndk
39
+ $env:ANDROID_NDK_HOME = $ndk
40
+ $env:CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER = $clang
41
+ $env:CC_aarch64_linux_android = $clang
42
+ $env:AR_aarch64_linux_android = $ar
43
+ $cargo = (Get-Command cargo).Source
44
+
45
+ function Run-Cargo([string[]]$Arguments, [string]$Name) {
46
+ $process = Start-Process `
47
+ -FilePath $cargo `
48
+ -ArgumentList $Arguments `
49
+ -NoNewWindow `
50
+ -Wait `
51
+ -PassThru `
52
+ -RedirectStandardOutput (Join-Path $destination "$Name.stdout.log") `
53
+ -RedirectStandardError (Join-Path $destination "$Name.stderr.log")
54
+ if ($process.ExitCode -ne 0) {
55
+ throw "$Name failed with code $($process.ExitCode)"
56
+ }
57
+ }
58
+
59
+ Push-Location $repo
60
+ try {
61
+ Run-Cargo @(
62
+ 'build', '--locked', '--release', '--target', 'aarch64-linux-android',
63
+ '--example', 'bench', '--example', 'evaluate_decoder'
64
+ ) 'native-build'
65
+ Run-Cargo @('apk', 'build', '--release', '--package', 'dinovision-xr') 'apk-build'
66
+ } finally {
67
+ Pop-Location
68
+ }
69
+
70
+ $built = [ordered]@{
71
+ 'bench' = Join-Path $repo 'target\aarch64-linux-android\release\examples\bench'
72
+ 'evaluate_decoder' = Join-Path $repo 'target\aarch64-linux-android\release\examples\evaluate_decoder'
73
+ 'dinovision_xr.apk' = Join-Path $repo 'target\release\apk\dinovision_xr.apk'
74
+ }
75
+ $artifacts = @()
76
+ foreach ($entry in $built.GetEnumerator()) {
77
+ if (-not (Test-Path -LiteralPath $entry.Value -PathType Leaf)) {
78
+ throw "expected Android artifact is missing: $($entry.Value)"
79
+ }
80
+ $target = Join-Path $destination $entry.Key
81
+ Copy-Item -LiteralPath $entry.Value -Destination $target
82
+ $artifacts += [ordered]@{
83
+ name = $entry.Key
84
+ bytes = (Get-Item -LiteralPath $target).Length
85
+ sha256 = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant()
86
+ }
87
+ }
88
+
89
+ $buildTools = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Directory |
90
+ Sort-Object { [version]$_.Name } -Descending |
91
+ Select-Object -First 1
92
+ $aapt = Join-Path $buildTools.FullName 'aapt.exe'
93
+ if (-not (Test-Path -LiteralPath $aapt -PathType Leaf)) { throw 'aapt.exe was not found' }
94
+ & $aapt dump badging (Join-Path $destination 'dinovision_xr.apk') |
95
+ Set-Content -LiteralPath (Join-Path $destination 'apk-badging.txt') -Encoding utf8
96
+ if ($LASTEXITCODE -ne 0) { throw 'could not inspect the XR APK manifest' }
97
+ $badging = Get-Content -Raw -LiteralPath (Join-Path $destination 'apk-badging.txt')
98
+ if ($badging -notmatch "package: name='rust\.dinovision_xr'" -or
99
+ $badging -notmatch "launchable-activity: name='android\.app\.NativeActivity'") {
100
+ throw 'XR APK package/activity does not match the device harness defaults'
101
+ }
102
+
103
+ $cargoApk = (Get-Command cargo-apk).Source
104
+ $record = [ordered]@{
105
+ schema_version = 1
106
+ completed_utc = [DateTime]::UtcNow.ToString('o')
107
+ target = 'aarch64-linux-android'
108
+ android_api = 34
109
+ android_sdk = $sdk
110
+ ndk = $ndk
111
+ ndk_source_properties = $sourceProperties.Trim()
112
+ rustc = (& rustc --version --verbose) -join "`n"
113
+ cargo = (& $cargo --version --verbose) -join "`n"
114
+ cargo_apk_sha256 = (Get-FileHash -LiteralPath $cargoApk -Algorithm SHA256).Hash.ToLowerInvariant()
115
+ package = 'rust.dinovision_xr'
116
+ activity = 'android.app.NativeActivity'
117
+ artifacts = $artifacts
118
+ }
119
+ $record | ConvertTo-Json -Depth 6 |
120
+ Set-Content -LiteralPath (Join-Path $destination 'android-build.json') -Encoding utf8
121
+ Write-Output "wrote checked Android artifacts to $destination"
environment/training-source/source-snapshot/tools/collect_artifact_metadata.ps1 ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [string]$OutputDir,
4
+ [string]$Model,
5
+ [string]$Decoder
6
+ )
7
+
8
+ $ErrorActionPreference = 'Stop'
9
+ $git = 'C:\Portables\cmder\vendor\git-for-windows\cmd\git.exe'
10
+ $root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
11
+ $meganeura = (Resolve-Path (Join-Path $root '..\meganeura')).Path
12
+ $blade = (Resolve-Path (Join-Path $root '..\blade')).Path
13
+ $adbCandidates = @()
14
+ if ($env:LOCALAPPDATA) {
15
+ $adbCandidates += Join-Path $env:LOCALAPPDATA 'Android\Sdk\platform-tools\adb.exe'
16
+ }
17
+ if ($env:ANDROID_HOME) {
18
+ $adbCandidates += Join-Path $env:ANDROID_HOME 'platform-tools\adb.exe'
19
+ }
20
+ $adbCandidates = @($adbCandidates | Where-Object { Test-Path -LiteralPath $_ })
21
+ $adb = if ($adbCandidates.Count -gt 0) { $adbCandidates[0] } else { $null }
22
+ $out = [System.IO.Path]::GetFullPath($OutputDir)
23
+ if (Test-Path -LiteralPath $out) {
24
+ if (@(Get-ChildItem -LiteralPath $out -Force).Count -ne 0) {
25
+ throw "Refusing to merge environment metadata into non-empty directory: $out"
26
+ }
27
+ } else {
28
+ New-Item -ItemType Directory -Path $out | Out-Null
29
+ }
30
+
31
+ function Git-State([string]$repo) {
32
+ $revision = (& $git -C $repo rev-parse HEAD).Trim()
33
+ $branch = (& $git -C $repo branch --show-current).Trim()
34
+ $status = @(& $git -C $repo status --porcelain=v1)
35
+ [ordered]@{
36
+ path = $repo
37
+ revision = $revision
38
+ branch = $branch
39
+ clean = $status.Count -eq 0
40
+ status = $status
41
+ }
42
+ }
43
+
44
+ $cargoMetadata = (& cargo metadata --format-version 1 --locked | ConvertFrom-Json)
45
+ $resolvedPackages = [ordered]@{}
46
+ foreach ($name in @('meganeura', 'blade-graphics')) {
47
+ $package = @($cargoMetadata.packages | Where-Object { $_.name -eq $name })
48
+ $resolvedPackages[$name] = @(
49
+ $package | ForEach-Object {
50
+ [ordered]@{
51
+ version = $_.version
52
+ source = $_.source
53
+ manifest_path = $_.manifest_path
54
+ }
55
+ }
56
+ )
57
+ }
58
+
59
+ $metadata = [ordered]@{
60
+ schema_version = 1
61
+ collected_utc = [DateTime]::UtcNow.ToString('o')
62
+ dinovision = Git-State $root
63
+ meganeura = Git-State $meganeura
64
+ blade_checkout = Git-State $blade
65
+ resolved_packages = $resolvedPackages
66
+ rustc = (& rustc --version --verbose) -join "`n"
67
+ cargo = (& cargo --version --verbose) -join "`n"
68
+ os = [System.Environment]::OSVersion.VersionString
69
+ android_ndk_root = $env:ANDROID_NDK_ROOT
70
+ android_ndks = if ($env:ANDROID_HOME) {
71
+ @(
72
+ Get-ChildItem -LiteralPath (Join-Path $env:ANDROID_HOME 'ndk') -Directory -ErrorAction SilentlyContinue |
73
+ Select-Object -ExpandProperty Name
74
+ )
75
+ } else { @() }
76
+ nvidia_smi = if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) {
77
+ (& nvidia-smi --query-gpu=name,driver_version --format=csv,noheader) -join "`n"
78
+ } else { $null }
79
+ adb = if ($adb) { (& $adb version) -join "`n" } else { $null }
80
+ model = $null
81
+ decoder = $null
82
+ quest_connected = $false
83
+ }
84
+
85
+ # A Git revision alone is not sufficient while the experiment is being
86
+ # developed in a dirty tree. Preserve the exact tracked patch and hash every
87
+ # tracked or untracked, non-ignored source file. The paper freeze should be a
88
+ # clean commit; this fallback makes development runs auditable instead of
89
+ # silently attributing them to HEAD.
90
+ $workingPatch = Join-Path $out 'dinovision-working-tree.patch'
91
+ & $git -C $root diff --binary --no-ext-diff --output=$workingPatch HEAD
92
+ if ($LASTEXITCODE -ne 0) { throw 'could not preserve the DinoVision working-tree patch' }
93
+ $sourceFiles = @(
94
+ & $git -C $root ls-files --cached --others --exclude-standard |
95
+ Where-Object { $_ -and (Test-Path -LiteralPath (Join-Path $root $_)) }
96
+ )
97
+ $metadata.dinovision['working_patch_sha256'] = (
98
+ Get-FileHash -LiteralPath $workingPatch -Algorithm SHA256
99
+ ).Hash.ToLowerInvariant()
100
+ $metadata.dinovision['source_files'] = @(
101
+ $sourceFiles | ForEach-Object {
102
+ $path = Join-Path $root $_
103
+ [ordered]@{
104
+ path = $_.Replace('\', '/')
105
+ bytes = (Get-Item -LiteralPath $path).Length
106
+ sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
107
+ }
108
+ }
109
+ )
110
+ $sourceSnapshot = Join-Path $out 'dinovision-source-snapshot'
111
+ foreach ($relative in $sourceFiles) {
112
+ $source = Join-Path $root $relative
113
+ $target = Join-Path $sourceSnapshot $relative
114
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null
115
+ Copy-Item -LiteralPath $source -Destination $target
116
+ }
117
+
118
+ foreach ($asset in @(@('model', $Model), @('decoder', $Decoder))) {
119
+ if ($asset[1] -and (Test-Path -LiteralPath $asset[1])) {
120
+ $file = Get-Item -LiteralPath $asset[1]
121
+ $metadata[$asset[0]] = [ordered]@{
122
+ path = $file.FullName
123
+ bytes = $file.Length
124
+ sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant()
125
+ }
126
+ }
127
+ }
128
+
129
+ & cargo tree --locked | Set-Content -Encoding utf8 (Join-Path $out 'cargo-tree.txt')
130
+ Copy-Item -LiteralPath (Join-Path $root 'Cargo.lock') -Destination (Join-Path $out 'Cargo.lock')
131
+
132
+ $deviceLines = if ($adb) { @(& $adb devices) } else { @() }
133
+ $connected = @($deviceLines | Select-Object -Skip 1 | Where-Object { $_ -match "`tdevice$" })
134
+ if ($connected.Count -gt 0) {
135
+ $metadata.quest_connected = $true
136
+ & $adb shell getprop | Set-Content -Encoding utf8 (Join-Path $out 'quest-getprop.txt')
137
+ & $adb shell dumpsys thermalservice | Set-Content -Encoding utf8 (Join-Path $out 'quest-thermal.txt')
138
+ & $adb shell dumpsys battery | Set-Content -Encoding utf8 (Join-Path $out 'quest-battery.txt')
139
+ & $adb shell dumpsys power | Set-Content -Encoding utf8 (Join-Path $out 'quest-power.txt')
140
+ & $adb shell dumpsys SurfaceFlinger | Select-String -Pattern 'refresh|fps|Display' |
141
+ Set-Content -Encoding utf8 (Join-Path $out 'quest-display.txt')
142
+ $toggles = @(
143
+ 'raw_camera', 'mono', 'camera_half_fov_deg',
144
+ 'inference_interval_ms', 'submission_chunks', 'capture'
145
+ )
146
+ $toggleState = [ordered]@{}
147
+ foreach ($toggle in $toggles) {
148
+ $path = "/data/local/tmp/dinovision/$toggle"
149
+ $value = (& $adb shell "if [ -e '$path' ]; then cat '$path' 2>/dev/null || echo '<present>'; else echo '<absent>'; fi").Trim()
150
+ $toggleState[$toggle] = $value
151
+ }
152
+ $toggleState | ConvertTo-Json -Depth 4 | Set-Content -Encoding utf8 (Join-Path $out 'quest-runtime-toggles.json')
153
+ }
154
+
155
+ $metadata | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 (Join-Path $out 'metadata.json')
156
+ Write-Output "wrote metadata to $out"
environment/training-source/source-snapshot/tools/compare_f32.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Compare two raw little-endian f32 tensors and emit an auditable record."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import array
8
+ import hashlib
9
+ import json
10
+ import math
11
+ import sys
12
+ from pathlib import Path
13
+
14
+
15
+ def read_tensor(path: Path) -> array.array[float]:
16
+ data = path.read_bytes()
17
+ if len(data) % 4:
18
+ raise SystemExit(f"{path} is not a whole number of f32 values")
19
+ values = array.array("f")
20
+ values.frombytes(data)
21
+ if sys.byteorder != "little":
22
+ values.byteswap()
23
+ return values
24
+
25
+
26
+ def sha256(path: Path) -> str:
27
+ return hashlib.sha256(path.read_bytes()).hexdigest()
28
+
29
+
30
+ def main() -> None:
31
+ parser = argparse.ArgumentParser()
32
+ parser.add_argument("reference", type=Path)
33
+ parser.add_argument("candidate", type=Path)
34
+ parser.add_argument("--output", required=True, type=Path)
35
+ parser.add_argument("--max-relative-l2", type=float)
36
+ parser.add_argument("--min-cosine", type=float)
37
+ parser.add_argument("--row-size", type=int)
38
+ parser.add_argument("--min-row-cosine", type=float)
39
+ args = parser.parse_args()
40
+ if args.min_row_cosine is not None and args.row_size is None:
41
+ raise SystemExit("--min-row-cosine requires --row-size")
42
+
43
+ reference = read_tensor(args.reference)
44
+ candidate = read_tensor(args.candidate)
45
+ if len(reference) != len(candidate):
46
+ raise SystemExit(
47
+ f"length mismatch: {args.reference} has {len(reference)}, "
48
+ f"{args.candidate} has {len(candidate)}"
49
+ )
50
+
51
+ squared_error = 0.0
52
+ squared_reference = 0.0
53
+ squared_candidate = 0.0
54
+ dot = 0.0
55
+ absolute_error = 0.0
56
+ max_absolute_error = -1.0
57
+ max_absolute_index = 0
58
+ for index, (expected, actual) in enumerate(zip(reference, candidate)):
59
+ difference = float(actual) - float(expected)
60
+ squared_error += difference * difference
61
+ squared_reference += float(expected) * float(expected)
62
+ squared_candidate += float(actual) * float(actual)
63
+ dot += float(expected) * float(actual)
64
+ absolute_error += abs(difference)
65
+ if abs(difference) > max_absolute_error:
66
+ max_absolute_error = abs(difference)
67
+ max_absolute_index = index
68
+
69
+ relative_l2 = math.sqrt(squared_error) / max(math.sqrt(squared_reference), sys.float_info.min)
70
+ cosine = dot / max(
71
+ math.sqrt(squared_reference) * math.sqrt(squared_candidate),
72
+ sys.float_info.min,
73
+ )
74
+ row_metrics = None
75
+ if args.row_size is not None:
76
+ if args.row_size <= 0 or len(reference) % args.row_size:
77
+ raise SystemExit("--row-size must be positive and divide the tensor length")
78
+ minimum_cosine = math.inf
79
+ minimum_cosine_row = 0
80
+ maximum_relative_l2 = -math.inf
81
+ maximum_relative_l2_row = 0
82
+ for row in range(len(reference) // args.row_size):
83
+ start = row * args.row_size
84
+ end = start + args.row_size
85
+ row_reference = reference[start:end]
86
+ row_candidate = candidate[start:end]
87
+ row_dot = sum(float(a) * float(b) for a, b in zip(row_reference, row_candidate))
88
+ row_reference_squared = sum(float(value) ** 2 for value in row_reference)
89
+ row_candidate_squared = sum(float(value) ** 2 for value in row_candidate)
90
+ row_error_squared = sum(
91
+ (float(actual) - float(expected)) ** 2
92
+ for expected, actual in zip(row_reference, row_candidate)
93
+ )
94
+ row_cosine = row_dot / max(
95
+ math.sqrt(row_reference_squared) * math.sqrt(row_candidate_squared),
96
+ sys.float_info.min,
97
+ )
98
+ row_relative_l2 = math.sqrt(row_error_squared) / max(
99
+ math.sqrt(row_reference_squared), sys.float_info.min
100
+ )
101
+ if row_cosine < minimum_cosine:
102
+ minimum_cosine = row_cosine
103
+ minimum_cosine_row = row
104
+ if row_relative_l2 > maximum_relative_l2:
105
+ maximum_relative_l2 = row_relative_l2
106
+ maximum_relative_l2_row = row
107
+ row_metrics = {
108
+ "row_size": args.row_size,
109
+ "rows": len(reference) // args.row_size,
110
+ "minimum_cosine": minimum_cosine,
111
+ "minimum_cosine_row": minimum_cosine_row,
112
+ "maximum_relative_l2": maximum_relative_l2,
113
+ "maximum_relative_l2_row": maximum_relative_l2_row,
114
+ }
115
+ checks = {
116
+ "max_relative_l2": (
117
+ None
118
+ if args.max_relative_l2 is None
119
+ else relative_l2 <= args.max_relative_l2
120
+ ),
121
+ "min_cosine": (
122
+ None if args.min_cosine is None else cosine >= args.min_cosine
123
+ ),
124
+ "min_row_cosine": (
125
+ None
126
+ if args.min_row_cosine is None
127
+ else row_metrics is not None
128
+ and row_metrics["minimum_cosine"] >= args.min_row_cosine
129
+ ),
130
+ }
131
+ passed = all(value is not False for value in checks.values())
132
+ record = {
133
+ "schema_version": 1,
134
+ "reference": str(args.reference).replace("\\", "/"),
135
+ "reference_sha256": sha256(args.reference),
136
+ "candidate": str(args.candidate).replace("\\", "/"),
137
+ "candidate_sha256": sha256(args.candidate),
138
+ "elements": len(reference),
139
+ "relative_l2": relative_l2,
140
+ "cosine": cosine,
141
+ "mean_absolute_error": absolute_error / len(reference),
142
+ "max_absolute_error": max_absolute_error,
143
+ "max_absolute_index": max_absolute_index,
144
+ "thresholds": {
145
+ "max_relative_l2": args.max_relative_l2,
146
+ "min_cosine": args.min_cosine,
147
+ "min_row_cosine": args.min_row_cosine,
148
+ },
149
+ "row_metrics": row_metrics,
150
+ "checks": checks,
151
+ "passed": passed,
152
+ }
153
+ args.output.parent.mkdir(parents=True, exist_ok=True)
154
+ args.output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
155
+ row_text = (
156
+ ""
157
+ if row_metrics is None
158
+ else f", minimum row cosine {row_metrics['minimum_cosine']:.9f}"
159
+ )
160
+ print(
161
+ f"relative L2 {relative_l2:.6g}, cosine {cosine:.9f}{row_text}, "
162
+ f"max abs {max_absolute_error:.6g} at {max_absolute_index}: "
163
+ f"{'PASS' if passed else 'FAIL'}"
164
+ )
165
+ if not passed:
166
+ raise SystemExit(1)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
environment/training-source/source-snapshot/tools/convert_decoder.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Convert DinoVision's runtime decoder.bin into named SafeTensors."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from safetensors.numpy import load_file, save_file
13
+
14
+
15
+ STAGES = (256, 128, 64, 32)
16
+ BLEND_STAGES = 2
17
+
18
+
19
+ def sha256(path: Path) -> str:
20
+ hasher = hashlib.sha256()
21
+ with path.open("rb") as source:
22
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
23
+ hasher.update(chunk)
24
+ return hasher.hexdigest()
25
+
26
+
27
+ def main() -> None:
28
+ parser = argparse.ArgumentParser()
29
+ parser.add_argument("--training", required=True, type=Path)
30
+ parser.add_argument("--input", type=Path)
31
+ parser.add_argument("--output", required=True, type=Path)
32
+ args = parser.parse_args()
33
+
34
+ training = json.loads(args.training.read_text(encoding="utf-8"))
35
+ source = args.input or args.training.parent / "decoder.bin"
36
+ source_hash = sha256(source)
37
+ if source_hash != training["decoder_sha256"]:
38
+ raise SystemExit(
39
+ f"{source} has SHA-256 {source_hash}; training record expects "
40
+ f"{training['decoder_sha256']}"
41
+ )
42
+
43
+ flat = np.fromfile(source, dtype="<f4")
44
+ offset = 0
45
+ tensors: dict[str, np.ndarray] = {}
46
+
47
+ def take(name: str, shape: tuple[int, ...]) -> None:
48
+ nonlocal offset
49
+ count = int(np.prod(shape))
50
+ end = offset + count
51
+ if end > len(flat):
52
+ raise SystemExit(f"{source} ends inside tensor {name}")
53
+ tensors[name] = flat[offset:end].reshape(shape).copy()
54
+ offset = end
55
+
56
+ def block(name: str, in_channels: int, out_channels: int) -> None:
57
+ take(f"{name}.weight", (out_channels, in_channels, 3, 3))
58
+ take(f"{name}.bias", (out_channels,))
59
+ take(f"{name}.norm.weight", (out_channels,))
60
+ take(f"{name}.norm.bias", (out_channels,))
61
+
62
+ in_channels = 384
63
+ for index, out_channels in enumerate(STAGES):
64
+ block(f"dec.{index}", in_channels, out_channels)
65
+ if index < BLEND_STAGES:
66
+ block(f"dec.{index}b", out_channels, out_channels)
67
+ in_channels = out_channels
68
+ take("dec.out.weight", (3, in_channels, 3, 3))
69
+ take("dec.out.bias", (3,))
70
+ if offset != len(flat):
71
+ raise SystemExit(f"{source} has {len(flat) - offset} trailing f32 values")
72
+
73
+ metadata = {
74
+ "format": "dinovision-decoder-v1",
75
+ "source_decoder_sha256": source_hash,
76
+ "model_sha256": training["model_sha256"],
77
+ "dataset_manifest_sha256": training["dataset_manifest_sha256"],
78
+ "encoder_layers": str(training["encoder_layers"]),
79
+ "image_size": str(training["image_size"]),
80
+ "seed": str(training["seed"]),
81
+ "objective": training["objective"],
82
+ }
83
+ args.output.parent.mkdir(parents=True, exist_ok=True)
84
+ save_file(tensors, args.output, metadata=metadata)
85
+ loaded = load_file(args.output)
86
+ for name, expected in tensors.items():
87
+ if name not in loaded or not np.array_equal(expected, loaded[name]):
88
+ raise SystemExit(f"SafeTensors roundtrip changed tensor {name}")
89
+ print(
90
+ f"wrote {args.output}: {len(tensors)} named tensors, "
91
+ f"SHA-256 {sha256(args.output)}"
92
+ )
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
environment/training-source/source-snapshot/tools/dump_reference.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Dump a DINOv3 reference forward pass for `cargo run --example verify`.
3
+
4
+ Writes three files into the output directory:
5
+
6
+ pixel_values.bin f32 [3, S, S] the preprocessed input tensor
7
+ features.bin f32 [tokens, 384] expected last_hidden_state
8
+ model.safetensors weights, in the graph's naming
9
+
10
+ Dumping the *preprocessed* pixel tensor rather than an image is deliberate:
11
+ it keeps resize and normalization differences out of the comparison, so a
12
+ mismatch in `verify` is a mismatch in the graph, not in the resampling
13
+ filter.
14
+
15
+ Saving the state dict here rather than downloading it in Rust also means
16
+ `verify` needs no Hub access and no license acceptance at run time.
17
+
18
+ pip install torch transformers safetensors pillow numpy
19
+ huggingface-cli login # facebook/... is license-gated
20
+ python tools/dump_reference.py --out ref/
21
+
22
+ The weights are gated. Accept the DINOv3 license on the canonical model page
23
+ and authenticate with Hugging Face before running this script.
24
+ """
25
+
26
+ import argparse
27
+ import hashlib
28
+ import json
29
+ import pathlib
30
+
31
+ import numpy as np
32
+ import torch
33
+ import transformers
34
+ from safetensors.torch import save_file
35
+ from transformers import AutoModel
36
+ from transformers.models.dinov3_vit.modeling_dinov3_vit import (
37
+ apply_rotary_pos_emb,
38
+ eager_attention_forward,
39
+ )
40
+
41
+ DEFAULT_MODEL = "facebook/dinov3-vits16-pretrain-lvd1689m"
42
+ IMAGE_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
43
+ IMAGE_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
44
+
45
+
46
+ def build_input(size: int, image: str | None) -> np.ndarray:
47
+ """Return a normalized f32 CHW tensor."""
48
+ if image is not None:
49
+ from PIL import Image
50
+
51
+ rgb = Image.open(image).convert("RGB").resize((size, size), Image.BILINEAR)
52
+ hwc = np.asarray(rgb, dtype=np.float32) / 255.0
53
+ else:
54
+ # A smooth, deterministic pattern. Smooth matters: white noise makes
55
+ # every patch statistically identical, which would hide a bug in the
56
+ # patch ordering or in the position encoding.
57
+ ys, xs = np.mgrid[0:size, 0:size].astype(np.float32)
58
+ u, v = xs / size, ys / size
59
+ hwc = np.stack(
60
+ [
61
+ 0.5 + 0.5 * np.sin(6.0 * u + 2.0 * v),
62
+ 0.5 + 0.5 * np.sin(4.0 * v - 3.0 * u * v),
63
+ 0.5 + 0.5 * np.cos(5.0 * u * v + u),
64
+ ],
65
+ axis=-1,
66
+ ).astype(np.float32)
67
+
68
+ chw = ((hwc - IMAGE_MEAN) / IMAGE_STD).transpose(2, 0, 1)
69
+ return np.ascontiguousarray(chw, dtype=np.float32)
70
+
71
+
72
+ def main() -> None:
73
+ ap = argparse.ArgumentParser()
74
+ ap.add_argument("--out", default="ref", type=pathlib.Path)
75
+ ap.add_argument("--model", default=DEFAULT_MODEL)
76
+ ap.add_argument("--size", type=int, default=224)
77
+ ap.add_argument(
78
+ "--layers",
79
+ type=int,
80
+ default=12,
81
+ help="number of leading encoder layers to execute before the final norm",
82
+ )
83
+ ap.add_argument("--image", default=None, help="optional real photo instead of a test pattern")
84
+ args = ap.parse_args()
85
+
86
+ if args.size % 16:
87
+ raise SystemExit(f"--size {args.size} is not a multiple of the patch size (16)")
88
+
89
+ args.out.mkdir(parents=True, exist_ok=True)
90
+
91
+ model = AutoModel.from_pretrained(args.model, dtype=torch.float32).eval()
92
+ cfg = model.config
93
+ print(
94
+ f"{args.model}: {cfg.num_hidden_layers} layers, hidden {cfg.hidden_size}, "
95
+ f"{cfg.num_register_tokens} register tokens, gated_mlp={cfg.use_gated_mlp}"
96
+ )
97
+ if cfg.use_gated_mlp:
98
+ raise SystemExit("the Rust graph implements the plain MLP only (ViT-S/B)")
99
+ if not 0 <= args.layers <= cfg.num_hidden_layers:
100
+ raise SystemExit(
101
+ f"--layers must be in [0, {cfg.num_hidden_layers}], got {args.layers}"
102
+ )
103
+
104
+ chw = build_input(args.size, args.image)
105
+ pixels = torch.from_numpy(chw)[None]
106
+ with torch.no_grad():
107
+ hidden_states = model.embeddings(pixels)
108
+ embeddings = hidden_states[0].numpy().astype(np.float32)
109
+ position_embeddings = model.rope_embeddings(pixels)
110
+ first_layer = model.model.layer[0]
111
+ first_norm1 = first_layer.norm1(hidden_states)
112
+ first_q = first_layer.attention.q_proj(first_norm1)
113
+ first_k = first_layer.attention.k_proj(first_norm1)
114
+ first_v = first_layer.attention.v_proj(first_norm1)
115
+ tokens = first_q.shape[1]
116
+ heads = cfg.num_attention_heads
117
+ head_dim = cfg.hidden_size // heads
118
+ q_heads = first_q.view(1, tokens, heads, head_dim).transpose(1, 2)
119
+ k_heads = first_k.view(1, tokens, heads, head_dim).transpose(1, 2)
120
+ v_heads = first_v.view(1, tokens, heads, head_dim).transpose(1, 2)
121
+ q_rope, k_rope = apply_rotary_pos_emb(
122
+ q_heads, k_heads, *position_embeddings
123
+ )
124
+ first_attention, _ = eager_attention_forward(
125
+ first_layer.attention,
126
+ q_rope,
127
+ k_rope,
128
+ v_heads,
129
+ None,
130
+ scaling=first_layer.attention.scaling,
131
+ )
132
+ first_attention = first_attention.reshape(1, tokens, cfg.hidden_size)
133
+ first_attention_projected = first_layer.attention.o_proj(first_attention)
134
+ first_attention_scaled = first_layer.layer_scale1(
135
+ first_attention_projected
136
+ )
137
+ first_residual = hidden_states + first_attention_scaled
138
+ first_norm2 = first_layer.norm2(first_residual)
139
+ first_mlp_up = first_layer.mlp.up_proj(first_norm2)
140
+ first_mlp_activated = first_layer.mlp.act_fn(first_mlp_up)
141
+ first_mlp_down = first_layer.mlp.down_proj(first_mlp_activated)
142
+ first_mlp_scaled = first_layer.layer_scale2(first_mlp_down)
143
+ first_output = first_residual + first_mlp_scaled
144
+ first_final_norm = model.norm(first_output)
145
+ for layer in model.model.layer[: args.layers]:
146
+ hidden_states = layer(
147
+ hidden_states, position_embeddings=position_embeddings
148
+ )
149
+ features = model.norm(hidden_states)[0].numpy().astype(np.float32)
150
+
151
+ grid = args.size // cfg.patch_size
152
+ expected_tokens = 1 + cfg.num_register_tokens + grid * grid
153
+ assert features.shape == (expected_tokens, cfg.hidden_size), features.shape
154
+
155
+ (args.out / "pixel_values.bin").write_bytes(chw.tobytes())
156
+ (args.out / "embeddings.bin").write_bytes(
157
+ np.ascontiguousarray(embeddings).tobytes()
158
+ )
159
+ for name, tensor in {
160
+ "first-norm1.bin": first_norm1,
161
+ "first-q.bin": first_q,
162
+ "first-k.bin": first_k,
163
+ "first-v.bin": first_v,
164
+ "first-q-rope.bin": q_rope.transpose(1, 2).reshape(
165
+ 1, tokens, cfg.hidden_size
166
+ ),
167
+ "first-k-rope.bin": k_rope.transpose(1, 2).reshape(
168
+ 1, tokens, cfg.hidden_size
169
+ ),
170
+ "first-attention.bin": first_attention,
171
+ "first-attention-projected.bin": first_attention_projected,
172
+ "first-attention-scaled.bin": first_attention_scaled,
173
+ "first-residual.bin": first_residual,
174
+ "first-norm2.bin": first_norm2,
175
+ "first-mlp-up.bin": first_mlp_up,
176
+ "first-mlp-activated.bin": first_mlp_activated,
177
+ "first-mlp-down.bin": first_mlp_down,
178
+ "first-mlp-scaled.bin": first_mlp_scaled,
179
+ "first-output.bin": first_output,
180
+ "first-final-norm.bin": first_final_norm,
181
+ }.items():
182
+ (args.out / name).write_bytes(
183
+ np.ascontiguousarray(tensor[0].numpy().astype(np.float32)).tobytes()
184
+ )
185
+ (args.out / "features.bin").write_bytes(np.ascontiguousarray(features).tobytes())
186
+
187
+ # `save_file` rejects shared storage, which `state_dict()` can contain.
188
+ state = {k: v.contiguous().clone() for k, v in model.state_dict().items()}
189
+ save_file(state, str(args.out / "model.safetensors"))
190
+
191
+ def sha256(path: pathlib.Path) -> str:
192
+ return hashlib.sha256(path.read_bytes()).hexdigest()
193
+
194
+ local_model = pathlib.Path(args.model)
195
+ reference = {
196
+ "schema_version": 1,
197
+ "base_model": DEFAULT_MODEL if local_model.is_dir() else args.model,
198
+ "model_source": str(args.model),
199
+ "image_size": args.size,
200
+ "encoder_layers": args.layers,
201
+ "tokens": expected_tokens,
202
+ "hidden_size": cfg.hidden_size,
203
+ "torch_version": torch.__version__,
204
+ "transformers_version": transformers.__version__,
205
+ "numpy_version": np.__version__,
206
+ "pixel_values_sha256": sha256(args.out / "pixel_values.bin"),
207
+ "embeddings_sha256": sha256(args.out / "embeddings.bin"),
208
+ "features_sha256": sha256(args.out / "features.bin"),
209
+ "exported_model_sha256": sha256(args.out / "model.safetensors"),
210
+ }
211
+ if local_model.is_dir() and (local_model / "model.safetensors").is_file():
212
+ reference["source_model_sha256"] = sha256(
213
+ local_model / "model.safetensors"
214
+ )
215
+ (args.out / "reference.json").write_text(
216
+ json.dumps(reference, indent=2) + "\n", encoding="utf-8"
217
+ )
218
+
219
+ print(
220
+ f"wrote {args.out}/ — {args.layers} layers, {args.size}x{args.size}, "
221
+ f"{grid}x{grid} grid, {expected_tokens} tokens"
222
+ )
223
+ print(f"features: mean {features.mean():+.4f} std {features.std():.4f}")
224
+ print(f"\nnow run: cargo run --release --example verify -- {args.out}")
225
+
226
+
227
+ if __name__ == "__main__":
228
+ main()
environment/training-source/source-snapshot/tools/extract_json_records.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Extract prefixed JSON records from benchmark or logcat text files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser()
13
+ parser.add_argument("--input", action="append", required=True, type=Path)
14
+ parser.add_argument("--marker", required=True)
15
+ parser.add_argument("--output", required=True, type=Path)
16
+ args = parser.parse_args()
17
+
18
+ records: list[dict[str, object]] = []
19
+ decoder = json.JSONDecoder()
20
+ for path in args.input:
21
+ for line_number, line in enumerate(
22
+ path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1
23
+ ):
24
+ marker_at = line.find(args.marker)
25
+ if marker_at < 0:
26
+ continue
27
+ payload = line[marker_at + len(args.marker) :].strip()
28
+ try:
29
+ record, consumed = decoder.raw_decode(payload)
30
+ except json.JSONDecodeError as error:
31
+ raise SystemExit(f"{path}:{line_number}: malformed record: {error}") from error
32
+ if payload[consumed:].strip():
33
+ raise SystemExit(f"{path}:{line_number}: trailing text after JSON record")
34
+ if not isinstance(record, dict):
35
+ raise SystemExit(f"{path}:{line_number}: record is not a JSON object")
36
+ record["artifact_source"] = str(path).replace("\\", "/")
37
+ record["artifact_line"] = line_number
38
+ records.append(record)
39
+
40
+ if not records:
41
+ raise SystemExit(f"no {args.marker!r} records found")
42
+ output = {"schema_version": 1, "marker": args.marker, "records": records}
43
+ args.output.parent.mkdir(parents=True, exist_ok=True)
44
+ args.output.write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8")
45
+ print(f"wrote {args.output}: {len(records)} records")
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
environment/training-source/source-snapshot/tools/make_correctness_frame.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate the deterministic RGB8 frame used for host/device parity."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ from pathlib import Path
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser()
13
+ parser.add_argument("--output", required=True, type=Path)
14
+ parser.add_argument("--size", type=int, default=224)
15
+ args = parser.parse_args()
16
+ if args.size <= 1 or args.size % 16:
17
+ raise SystemExit("--size must be greater than one and divisible by 16")
18
+
19
+ pixels = bytearray()
20
+ center = (args.size - 1) / 2.0
21
+ radius_squared = (args.size * 0.28) ** 2
22
+ for y in range(args.size):
23
+ for x in range(args.size):
24
+ red = round(255 * x / (args.size - 1))
25
+ green = round(255 * y / (args.size - 1))
26
+ checker = 48 if ((x // 16) ^ (y // 16)) & 1 else 208
27
+ in_circle = (x - center) ** 2 + (y - center) ** 2 < radius_squared
28
+ blue = 255 - checker if in_circle else checker
29
+ pixels.extend((red, green, blue))
30
+
31
+ args.output.parent.mkdir(parents=True, exist_ok=True)
32
+ args.output.write_bytes(pixels)
33
+ digest = hashlib.sha256(pixels).hexdigest()
34
+ print(f"wrote {args.output}: {len(pixels)} bytes, SHA-256 {digest}")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
environment/training-source/source-snapshot/tools/make_dataset_manifest.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build a deterministic, leakage-checked DinoVision dataset manifest.
3
+
4
+ Each --entry supplies SPLIT SOURCE GROUP PATH. PATH is resolved below --root.
5
+ All files in one capture session must share a GROUP, and a group may occur in
6
+ only one split. Identical bytes are rejected when they cross a split boundary.
7
+ Files from sibling directories are interleaved, so a limited training prefix
8
+ does not consist entirely of the lexicographically first class.
9
+
10
+ Example:
11
+ python tools/make_dataset_manifest.py \
12
+ --root data --output experiments/dataset.json \
13
+ --entry train photo imagenette-train imagenette/train \
14
+ --entry train capture room-a captures/room-a \
15
+ --entry test capture room-b captures/room-b
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import os
24
+ from pathlib import Path
25
+
26
+
27
+ EXTENSIONS = {".jpeg", ".jpg", ".png", ".rgb"}
28
+
29
+
30
+ def digest(path: Path) -> str:
31
+ h = hashlib.sha256()
32
+ with path.open("rb") as f:
33
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
34
+ h.update(chunk)
35
+ return h.hexdigest()
36
+
37
+
38
+ def interleaved_images(directory: Path) -> list[Path]:
39
+ """Return a deterministic round-robin over leaf directories."""
40
+ groups: dict[Path, list[Path]] = {}
41
+ for path in directory.rglob("*"):
42
+ if path.is_file() and path.suffix.lower() in EXTENSIONS:
43
+ groups.setdefault(path.parent, []).append(path)
44
+ ordered_groups = [sorted(groups[parent]) for parent in sorted(groups)]
45
+ result: list[Path] = []
46
+ for index in range(max((len(group) for group in ordered_groups), default=0)):
47
+ result.extend(group[index] for group in ordered_groups if index < len(group))
48
+ return result
49
+
50
+
51
+ def main() -> None:
52
+ ap = argparse.ArgumentParser()
53
+ ap.add_argument("--root", required=True, type=Path)
54
+ ap.add_argument("--output", required=True, type=Path)
55
+ ap.add_argument("--name", default="dinovision")
56
+ ap.add_argument(
57
+ "--entry",
58
+ nargs=4,
59
+ action="append",
60
+ metavar=("SPLIT", "SOURCE", "GROUP", "PATH"),
61
+ required=True,
62
+ )
63
+ ap.add_argument(
64
+ "--provenance",
65
+ nargs=3,
66
+ action="append",
67
+ default=[],
68
+ metavar=("NAME", "URL", "SHA256"),
69
+ help="record an immutable source archive without downloading it",
70
+ )
71
+ args = ap.parse_args()
72
+
73
+ root = args.root.resolve()
74
+ output = args.output.resolve()
75
+ for name, _, sha256 in args.provenance:
76
+ if len(sha256) != 64 or any(c not in "0123456789abcdef" for c in sha256.lower()):
77
+ raise SystemExit(f"provenance {name} has an invalid SHA-256: {sha256}")
78
+ images: list[dict[str, object]] = []
79
+ group_splits: dict[tuple[str, str], str] = {}
80
+ hash_entries: dict[str, dict[str, object]] = {}
81
+
82
+ for split, source, group, relative in sorted(args.entry):
83
+ key = (source, group)
84
+ previous = group_splits.setdefault(key, split)
85
+ if previous != split:
86
+ raise SystemExit(
87
+ f"group {source}/{group} occurs in both {previous} and {split}"
88
+ )
89
+
90
+ directory = (root / relative).resolve()
91
+ try:
92
+ directory.relative_to(root)
93
+ except ValueError:
94
+ raise SystemExit(f"entry escapes --root: {directory}") from None
95
+ if not directory.is_dir():
96
+ raise SystemExit(f"not a directory: {directory}")
97
+ paths = interleaved_images(directory)
98
+ if not paths:
99
+ raise SystemExit(f"no supported images below {directory}")
100
+
101
+ for path in paths:
102
+ sha = digest(path)
103
+ old = hash_entries.get(sha)
104
+ if old is not None:
105
+ raise SystemExit(
106
+ "identical file occurs more than once: "
107
+ f"{old['path']} ({old['split']}) and {path} ({split})"
108
+ )
109
+ item = {
110
+ "path": path.relative_to(root).as_posix(),
111
+ "split": split,
112
+ "source": source,
113
+ "group": group,
114
+ "sha256": sha,
115
+ "bytes": path.stat().st_size,
116
+ }
117
+ images.append(item)
118
+ hash_entries.setdefault(sha, item)
119
+
120
+ try:
121
+ stored_root = Path(os.path.relpath(root, output.parent)).as_posix()
122
+ except ValueError:
123
+ stored_root = str(root)
124
+ manifest = {
125
+ "schema_version": 1,
126
+ "name": args.name,
127
+ "root": stored_root,
128
+ "provenance": [
129
+ {"name": name, "url": url, "sha256": sha256}
130
+ for name, url, sha256 in args.provenance
131
+ ],
132
+ "images": images,
133
+ }
134
+ output.parent.mkdir(parents=True, exist_ok=True)
135
+ output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
136
+
137
+ counts: dict[str, int] = {}
138
+ for image in images:
139
+ counts[image["split"]] = counts.get(image["split"], 0) + 1
140
+ print(f"wrote {output}: {len(images)} images, splits {counts}")
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
environment/training-source/source-snapshot/tools/pull_quest_artifacts.ps1 ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [string]$OutputDir
4
+ )
5
+
6
+ $ErrorActionPreference = "Stop"
7
+
8
+ $adbCandidates = @()
9
+ if ($env:LOCALAPPDATA) {
10
+ $adbCandidates += Join-Path $env:LOCALAPPDATA "Android\Sdk\platform-tools\adb.exe"
11
+ }
12
+ if ($env:ANDROID_HOME) {
13
+ $adbCandidates += Join-Path $env:ANDROID_HOME "platform-tools\adb.exe"
14
+ }
15
+ $adbCandidates = @($adbCandidates | Where-Object { Test-Path -LiteralPath $_ })
16
+
17
+ if ($adbCandidates.Count -eq 0) {
18
+ throw "adb.exe was not found under LOCALAPPDATA or ANDROID_HOME"
19
+ }
20
+ $adb = $adbCandidates[0]
21
+
22
+ $devices = @(& $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match "\tdevice$" })
23
+ if ($devices.Count -ne 1) {
24
+ throw "Expected exactly one authorized device; found $($devices.Count). Check 'adb devices'."
25
+ }
26
+
27
+ $destination = [System.IO.Path]::GetFullPath($OutputDir)
28
+ if (Test-Path -LiteralPath $destination) {
29
+ if (@(Get-ChildItem -LiteralPath $destination -Force).Count -ne 0) {
30
+ throw "Refusing to merge a recovery pull into non-empty directory: $destination"
31
+ }
32
+ } else {
33
+ New-Item -ItemType Directory -Path $destination | Out-Null
34
+ }
35
+
36
+ $runtimeDir = Join-Path $destination "runtime"
37
+ $captureDir = Join-Path $destination "captures"
38
+ New-Item -ItemType Directory -Path $runtimeDir | Out-Null
39
+ New-Item -ItemType Directory -Path $captureDir | Out-Null
40
+
41
+ $runtimeRemote = "/data/local/tmp/dinovision"
42
+ $capturesRemote = "/sdcard/Android/data/rust.dinovision_xr/files/captures"
43
+
44
+ # adb pull is read-only on the headset. Pull the whole runtime directory first
45
+ # so unknown historical files are preserved rather than guessed at by name.
46
+ $runtimePresent = $false
47
+ & $adb shell "test -d $runtimeRemote"
48
+ if ($LASTEXITCODE -eq 0) {
49
+ $runtimePresent = $true
50
+ & $adb pull "$runtimeRemote/." $runtimeDir
51
+ if ($LASTEXITCODE -ne 0) {
52
+ throw "Failed to pull $runtimeRemote"
53
+ }
54
+ }
55
+
56
+ $capturesPresent = $false
57
+ & $adb shell "test -d $capturesRemote"
58
+ if ($LASTEXITCODE -eq 0) {
59
+ $capturesPresent = $true
60
+ & $adb pull "$capturesRemote/." $captureDir
61
+ if ($LASTEXITCODE -ne 0) {
62
+ throw "Failed to pull $capturesRemote"
63
+ }
64
+ }
65
+
66
+ $metadata = [ordered]@{
67
+ schema_version = 1
68
+ pulled_utc = [DateTime]::UtcNow.ToString("o")
69
+ device = (& $adb get-serialno).Trim()
70
+ product = (& $adb shell getprop ro.product.name).Trim()
71
+ model = (& $adb shell getprop ro.product.model).Trim()
72
+ build_fingerprint = (& $adb shell getprop ro.build.fingerprint).Trim()
73
+ runtime_directory_present = $runtimePresent
74
+ capture_directory_present = $capturesPresent
75
+ files = @(
76
+ Get-ChildItem -LiteralPath $destination -File -Recurse | ForEach-Object {
77
+ [ordered]@{
78
+ path = [System.IO.Path]::GetRelativePath($destination, $_.FullName).Replace("\", "/")
79
+ bytes = $_.Length
80
+ sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
81
+ }
82
+ }
83
+ )
84
+ }
85
+
86
+ $manifestPath = Join-Path $destination "recovery_manifest.json"
87
+ $metadata | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding utf8
88
+ Write-Host "Recovered $($metadata.files.Count) files to $destination"
89
+ Write-Host "Manifest: $manifestPath"
environment/training-source/source-snapshot/tools/run_cross_device_correctness.ps1 ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [string]$OutputDir,
4
+ [Parameter(Mandatory = $true)]
5
+ [string]$RecoveryManifest,
6
+ [Parameter(Mandatory = $true)]
7
+ [string]$Binary,
8
+ [Parameter(Mandatory = $true)]
9
+ [string]$CorrectnessRoot,
10
+ [Parameter(Mandatory = $true)]
11
+ [string]$HostSampleDir,
12
+ [Parameter(Mandatory = $true)]
13
+ [string]$Model,
14
+ [Parameter(Mandatory = $true)]
15
+ [string]$Decoder,
16
+ [int]$Layers = 3,
17
+ [int]$Size = 224
18
+ )
19
+
20
+ $ErrorActionPreference = 'Stop'
21
+ $repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
22
+ $binaryPath = (Resolve-Path $Binary).Path
23
+ $rootPath = (Resolve-Path $CorrectnessRoot).Path
24
+ $hostSamples = (Resolve-Path $HostSampleDir).Path
25
+ $modelPath = (Resolve-Path $Model).Path
26
+ $decoderPath = (Resolve-Path $Decoder).Path
27
+ $recoveryPath = (Resolve-Path $RecoveryManifest).Path
28
+ $manifestPath = (Resolve-Path (Join-Path $rootPath 'manifests\correctness-224.json')).Path
29
+ $framePath = (Resolve-Path (Join-Path $rootPath 'input\frame.rgb')).Path
30
+ $destination = [System.IO.Path]::GetFullPath($OutputDir)
31
+ if (Test-Path -LiteralPath $destination) {
32
+ if (@(Get-ChildItem -LiteralPath $destination -Force).Count -ne 0) {
33
+ throw "Refusing to merge correctness output into non-empty directory: $destination"
34
+ }
35
+ } else {
36
+ New-Item -ItemType Directory -Path $destination | Out-Null
37
+ }
38
+
39
+ $adbCandidates = @()
40
+ if ($env:LOCALAPPDATA) {
41
+ $adbCandidates += Join-Path $env:LOCALAPPDATA 'Android\Sdk\platform-tools\adb.exe'
42
+ }
43
+ if ($env:ANDROID_HOME) {
44
+ $adbCandidates += Join-Path $env:ANDROID_HOME 'platform-tools\adb.exe'
45
+ }
46
+ $adbCandidates = @($adbCandidates | Where-Object { Test-Path -LiteralPath $_ })
47
+ if ($adbCandidates.Count -eq 0) { throw 'adb.exe was not found' }
48
+ $adb = $adbCandidates[0]
49
+ $devices = @(& $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match "`tdevice$" })
50
+ if ($devices.Count -ne 1) { throw "Expected one authorized device; found $($devices.Count)" }
51
+ $serial = (& $adb get-serialno).Trim()
52
+ $recovery = Get-Content -Raw -LiteralPath $recoveryPath | ConvertFrom-Json
53
+ if ($recovery.device -ne $serial) {
54
+ throw "Recovery manifest is for $($recovery.device), connected device is $serial"
55
+ }
56
+
57
+ function Hash([string]$Path) {
58
+ (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
59
+ }
60
+
61
+ $binaryHash = Hash $binaryPath
62
+ $modelHash = Hash $modelPath
63
+ $decoderHash = Hash $decoderPath
64
+ $stamp = [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ')
65
+ $remoteRoot = "/data/local/tmp/dinovision-correctness/$stamp-$($decoderHash.Substring(0, 12))"
66
+ foreach ($subdirectory in @('', '/manifests', '/input', '/output')) {
67
+ & $adb shell "mkdir -p '$remoteRoot$subdirectory'"
68
+ if ($LASTEXITCODE -ne 0) { throw "could not create $remoteRoot$subdirectory" }
69
+ }
70
+
71
+ $remoteFiles = [ordered]@{
72
+ "$remoteRoot/evaluate_decoder" = $binaryPath
73
+ "$remoteRoot/model.safetensors" = $modelPath
74
+ "$remoteRoot/decoder.bin" = $decoderPath
75
+ "$remoteRoot/manifests/correctness-224.json" = $manifestPath
76
+ "$remoteRoot/input/frame.rgb" = $framePath
77
+ }
78
+ foreach ($entry in $remoteFiles.GetEnumerator()) {
79
+ & $adb push $entry.Value $entry.Key | Out-Null
80
+ if ($LASTEXITCODE -ne 0) { throw "could not push $($entry.Value)" }
81
+ $remoteHash = ((& $adb shell "sha256sum '$($entry.Key)'").Trim().Split(' ')[0])
82
+ if ((Hash $entry.Value) -ne $remoteHash) { throw "hash mismatch for $($entry.Key)" }
83
+ }
84
+ & $adb shell "chmod 755 '$remoteRoot/evaluate_decoder'"
85
+ if ($LASTEXITCODE -ne 0) { throw 'could not mark evaluator executable' }
86
+
87
+ & $adb shell dumpsys thermalservice | Set-Content -Encoding utf8 (Join-Path $destination 'before-thermal.txt')
88
+ & $adb shell dumpsys battery | Set-Content -Encoding utf8 (Join-Path $destination 'before-battery.txt')
89
+ & $adb shell dumpsys power | Set-Content -Encoding utf8 (Join-Path $destination 'before-power.txt')
90
+ $command = "cd '$remoteRoot' && RUST_LOG=warn ./evaluate_decoder " +
91
+ "--manifest manifests/correctness-224.json --model model.safetensors " +
92
+ "--decoder decoder.bin --split test --layers $Layers --size $Size " +
93
+ "--output output/quality.json --samples 1"
94
+ $oldErrorPreference = $ErrorActionPreference
95
+ try {
96
+ $ErrorActionPreference = 'Continue'
97
+ & $adb shell $command `
98
+ 1> (Join-Path $destination 'device.stdout.log') `
99
+ 2> (Join-Path $destination 'device.stderr.log')
100
+ $exitCode = $LASTEXITCODE
101
+ } finally {
102
+ $ErrorActionPreference = $oldErrorPreference
103
+ }
104
+ if ($exitCode -ne 0) { throw "device evaluator exited with code $exitCode" }
105
+
106
+ $deviceOutput = Join-Path $destination 'device-output'
107
+ New-Item -ItemType Directory -Path $deviceOutput | Out-Null
108
+ & $adb pull "$remoteRoot/output/." $deviceOutput | Out-Null
109
+ if ($LASTEXITCODE -ne 0) { throw 'could not pull device correctness output' }
110
+ & $adb shell dumpsys thermalservice | Set-Content -Encoding utf8 (Join-Path $destination 'after-thermal.txt')
111
+ & $adb shell dumpsys battery | Set-Content -Encoding utf8 (Join-Path $destination 'after-battery.txt')
112
+ & $adb shell dumpsys power | Set-Content -Encoding utf8 (Join-Path $destination 'after-power.txt')
113
+
114
+ $deviceSamples = Join-Path $deviceOutput 'quality_samples'
115
+ $comparisons = Join-Path $destination 'comparisons'
116
+ New-Item -ItemType Directory -Path $comparisons | Out-Null
117
+ function Compare(
118
+ [string]$Name,
119
+ [double]$MaxRelativeL2,
120
+ [double]$MinCosine,
121
+ [int]$RowSize = 0,
122
+ [double]$MinRowCosine = 0.0
123
+ ) {
124
+ $arguments = @(
125
+ (Join-Path $hostSamples "0000-$Name.f32"),
126
+ (Join-Path $deviceSamples "0000-$Name.f32"),
127
+ '--output', (Join-Path $comparisons "$Name.json"),
128
+ '--max-relative-l2', "$MaxRelativeL2",
129
+ '--min-cosine', "$MinCosine"
130
+ )
131
+ if ($RowSize -gt 0) {
132
+ $arguments += @('--row-size', "$RowSize", '--min-row-cosine', "$MinRowCosine")
133
+ }
134
+ & python (Join-Path $repo 'tools\compare_f32.py') @arguments
135
+ if ($LASTEXITCODE -ne 0) { throw "$Name comparison failed" }
136
+ }
137
+
138
+ Compare 'patches' 0.0 0.999999999
139
+ Compare 'encoder' 0.01 0.999 384 0.999
140
+ Compare 'features' 0.01 0.999
141
+ Compare 'reconstruction' 0.02 0.999
142
+
143
+ $metadata = [ordered]@{
144
+ schema_version = 1
145
+ completed_utc = [DateTime]::UtcNow.ToString('o')
146
+ device_serial = $serial
147
+ device_model = (& $adb shell getprop ro.product.model).Trim()
148
+ build_fingerprint = (& $adb shell getprop ro.build.fingerprint).Trim()
149
+ remote_root = $remoteRoot
150
+ encoder_layers = $Layers
151
+ image_size = $Size
152
+ binary_sha256 = $binaryHash
153
+ model_sha256 = $modelHash
154
+ decoder_sha256 = $decoderHash
155
+ manifest_sha256 = Hash $manifestPath
156
+ frame_sha256 = Hash $framePath
157
+ recovery_manifest_sha256 = Hash $recoveryPath
158
+ }
159
+ $metadata | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 (Join-Path $destination 'correctness-metadata.json')
160
+ Write-Output "cross-device correctness passed; device files remain at $remoteRoot"
environment/training-source/source-snapshot/tools/run_decoder_matrix.ps1 ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [string]$Manifest,
4
+ [Parameter(Mandatory = $true)]
5
+ [string]$Model,
6
+ [Parameter(Mandatory = $true)]
7
+ [string]$OutputRoot,
8
+ [string[]]$Seeds = @('0', '1', '2'),
9
+ [int]$Steps = 12000,
10
+ [int]$Images = 2500,
11
+ [int]$Layers = 3,
12
+ [int]$Size = 224,
13
+ [string]$EvaluationSplit = "validation"
14
+ )
15
+
16
+ $ErrorActionPreference = "Stop"
17
+ $repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
18
+ $manifestPath = (Resolve-Path $Manifest).Path
19
+ $modelPath = (Resolve-Path $Model).Path
20
+ $manifestHash = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant()
21
+ $modelHash = (Get-FileHash -LiteralPath $modelPath -Algorithm SHA256).Hash.ToLowerInvariant()
22
+ $outputPath = [System.IO.Path]::GetFullPath($OutputRoot)
23
+ New-Item -ItemType Directory -Force -Path $outputPath | Out-Null
24
+ $cargo = (Get-Command cargo).Source
25
+ $seedValues = @(
26
+ $Seeds | ForEach-Object { $_ -split ',' } | ForEach-Object {
27
+ $value = 0L
28
+ if (-not [long]::TryParse($_.Trim(), [ref]$value) -or $value -lt 0) {
29
+ throw "invalid seed: $_"
30
+ }
31
+ $value
32
+ }
33
+ )
34
+ if ($seedValues.Count -eq 0 -or @($seedValues | Select-Object -Unique).Count -ne $seedValues.Count) {
35
+ throw 'provide at least one seed, without duplicates'
36
+ }
37
+
38
+ function Run-Cargo(
39
+ [string[]]$Arguments,
40
+ [string]$Stdout,
41
+ [string]$Stderr,
42
+ [string]$LogLevel
43
+ ) {
44
+ $env:RUST_LOG = $LogLevel
45
+ Push-Location $repo
46
+ $oldErrorPreference = $ErrorActionPreference
47
+ try {
48
+ # Windows PowerShell may wrap native stderr as ErrorRecord objects;
49
+ # Continue preserves the native exit code while the two streams are
50
+ # retained independently.
51
+ $ErrorActionPreference = "Continue"
52
+ & $cargo @Arguments 1> $Stdout 2> $Stderr
53
+ $exitCode = $LASTEXITCODE
54
+ } finally {
55
+ $ErrorActionPreference = $oldErrorPreference
56
+ Pop-Location
57
+ }
58
+ if ($exitCode -ne 0) {
59
+ throw "cargo exited with code $exitCode; see $Stdout and $Stderr"
60
+ }
61
+ }
62
+
63
+ foreach ($seed in $seedValues) {
64
+ $runDir = Join-Path $outputPath "${Layers}l${Size}-seed${seed}"
65
+ New-Item -ItemType Directory -Force -Path $runDir | Out-Null
66
+ $decoder = Join-Path $runDir "decoder.bin"
67
+ $training = Join-Path $runDir "training.json"
68
+ $quality = Join-Path $runDir "quality-imagenette-$EvaluationSplit.json"
69
+
70
+ if (-not ((Test-Path -LiteralPath $training) -and (Test-Path -LiteralPath $decoder))) {
71
+ Write-Output "training seed $seed at $(Get-Date -Format o)"
72
+ Run-Cargo `
73
+ -Arguments @(
74
+ "run", "--release", "--example", "train_decoder", "--",
75
+ $manifestPath, $modelPath, "$Steps", "$Images", "$Layers", "$Size", "$seed", $runDir
76
+ ) `
77
+ -Stdout (Join-Path $runDir "training.stdout.log") `
78
+ -Stderr (Join-Path $runDir "training.stderr.log") `
79
+ -LogLevel "info"
80
+ } else {
81
+ Write-Output "seed $seed training outputs already exist; preserving them"
82
+ }
83
+
84
+ $record = Get-Content -Raw -LiteralPath $training | ConvertFrom-Json
85
+ if (
86
+ $record.seed -ne $seed -or
87
+ $record.steps -ne $Steps -or
88
+ $record.requested_images -ne $Images -or
89
+ $record.encoder_layers -ne $Layers -or
90
+ $record.image_size -ne $Size -or
91
+ $record.dataset_manifest_sha256 -ne $manifestHash -or
92
+ $record.model_sha256 -ne $modelHash
93
+ ) {
94
+ throw "existing training record does not match requested cell: $training"
95
+ }
96
+
97
+ if (-not (Test-Path -LiteralPath $quality)) {
98
+ Write-Output "evaluating seed $seed at $(Get-Date -Format o)"
99
+ Run-Cargo `
100
+ -Arguments @(
101
+ "run", "--release", "--example", "evaluate_decoder", "--",
102
+ "--manifest", $manifestPath,
103
+ "--model", $modelPath,
104
+ "--decoder", $decoder,
105
+ "--split", $EvaluationSplit,
106
+ "--layers", "$Layers",
107
+ "--size", "$Size",
108
+ "--output", $quality,
109
+ "--samples", "12"
110
+ ) `
111
+ -Stdout (Join-Path $runDir "evaluation.stdout.log") `
112
+ -Stderr (Join-Path $runDir "evaluation.stderr.log") `
113
+ -LogLevel "warn"
114
+ } else {
115
+ Write-Output "seed $seed quality output already exists; preserving it"
116
+ }
117
+
118
+ $qualityRecord = Get-Content -Raw -LiteralPath $quality | ConvertFrom-Json
119
+ if (
120
+ $qualityRecord.split -ne $EvaluationSplit -or
121
+ $qualityRecord.encoder_layers -ne $Layers -or
122
+ $qualityRecord.image_size -ne $Size -or
123
+ $qualityRecord.manifest_sha256 -ne $manifestHash -or
124
+ $qualityRecord.model_sha256 -ne $modelHash -or
125
+ $qualityRecord.decoder_sha256 -ne $record.decoder_sha256
126
+ ) {
127
+ throw "quality record does not match its training cell: $quality"
128
+ }
129
+
130
+ $safeTensors = Join-Path $runDir "decoder.safetensors"
131
+ if (-not (Test-Path -LiteralPath $safeTensors)) {
132
+ & python (Join-Path $repo "tools\convert_decoder.py") `
133
+ --training $training `
134
+ --input $decoder `
135
+ --output $safeTensors
136
+ if ($LASTEXITCODE -ne 0) {
137
+ throw "decoder SafeTensors conversion failed for seed $seed"
138
+ }
139
+ }
140
+ }
141
+
142
+ Write-Output "decoder matrix complete at $(Get-Date -Format o)"
environment/training-source/source-snapshot/tools/run_quest_bench.ps1 ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [string]$OutputDir,
4
+ [Parameter(Mandatory = $true)]
5
+ [string]$RecoveryManifest,
6
+ [string]$Binary = "target\aarch64-linux-android\release\examples\bench",
7
+ [ValidateSet("live-worn", "awake-unworn")]
8
+ [string]$Condition = "live-worn",
9
+ [int]$Processes = 3,
10
+ [int]$Iterations = 20,
11
+ [ValidateSet("full", "shapes")]
12
+ [string]$Suite = "full"
13
+ )
14
+
15
+ $ErrorActionPreference = "Stop"
16
+ if ($Processes -lt 3) { throw "paper protocol requires at least three processes" }
17
+ if ($Iterations -lt 20) { throw "paper protocol requires at least 20 retained samples" }
18
+ $repo = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
19
+ $binaryPath = (Resolve-Path (Join-Path $repo $Binary)).Path
20
+ $recoveryPath = (Resolve-Path $RecoveryManifest).Path
21
+ $destination = [System.IO.Path]::GetFullPath($OutputDir)
22
+ if (Test-Path -LiteralPath $destination) {
23
+ if (@(Get-ChildItem -LiteralPath $destination -Force).Count -ne 0) {
24
+ throw "Refusing to merge benchmark output into non-empty directory: $destination"
25
+ }
26
+ } else {
27
+ New-Item -ItemType Directory -Path $destination | Out-Null
28
+ }
29
+
30
+ $adbCandidates = @()
31
+ if ($env:LOCALAPPDATA) {
32
+ $adbCandidates += Join-Path $env:LOCALAPPDATA "Android\Sdk\platform-tools\adb.exe"
33
+ }
34
+ if ($env:ANDROID_HOME) {
35
+ $adbCandidates += Join-Path $env:ANDROID_HOME "platform-tools\adb.exe"
36
+ }
37
+ $adbCandidates = @($adbCandidates | Where-Object { Test-Path -LiteralPath $_ })
38
+ if ($adbCandidates.Count -eq 0) {
39
+ throw "adb.exe was not found"
40
+ }
41
+ $adb = $adbCandidates[0]
42
+ $devices = @(& $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match "\tdevice$" })
43
+ if ($devices.Count -ne 1) {
44
+ throw "Expected exactly one authorized device; found $($devices.Count)"
45
+ }
46
+ $serial = (& $adb get-serialno).Trim()
47
+ $recovery = Get-Content -Raw -LiteralPath $recoveryPath | ConvertFrom-Json
48
+ if ($recovery.device -ne $serial) {
49
+ throw "Recovery manifest is for $($recovery.device), connected device is $serial"
50
+ }
51
+
52
+ $binaryHash = (Get-FileHash -LiteralPath $binaryPath -Algorithm SHA256).Hash.ToLowerInvariant()
53
+ $remoteDir = "/data/local/tmp/dinovision"
54
+ $remoteBinary = "$remoteDir/bench-paper-$($binaryHash.Substring(0, 12))"
55
+ & $adb shell "mkdir -p $remoteDir"
56
+ if ($LASTEXITCODE -ne 0) { throw "could not create $remoteDir" }
57
+ & $adb push $binaryPath $remoteBinary
58
+ if ($LASTEXITCODE -ne 0) { throw "could not push benchmark binary" }
59
+ & $adb shell "chmod 755 $remoteBinary"
60
+ if ($LASTEXITCODE -ne 0) { throw "could not mark benchmark executable" }
61
+
62
+ $remoteHash = (& $adb shell "sha256sum $remoteBinary").Trim().Split(" ")[0]
63
+ if ($binaryHash -ne $remoteHash) {
64
+ throw "pushed binary hash mismatch: host $binaryHash, device $remoteHash"
65
+ }
66
+
67
+ $suiteEnvironment = if ($Suite -eq "shapes") { "DINOVISION_BENCH=shapes " } else { "" }
68
+ $remoteName = [System.IO.Path]::GetFileName($remoteBinary)
69
+ $command = "cd $remoteDir && RUST_LOG=info DINOVISION_WARMUPS=5 ${suiteEnvironment}./$remoteName $Iterations"
70
+
71
+ function Capture-State([string]$Prefix) {
72
+ & $adb shell dumpsys thermalservice | Set-Content -Encoding utf8 (Join-Path $destination "$Prefix-thermal.txt")
73
+ & $adb shell dumpsys battery | Set-Content -Encoding utf8 (Join-Path $destination "$Prefix-battery.txt")
74
+ & $adb shell dumpsys power | Set-Content -Encoding utf8 (Join-Path $destination "$Prefix-power.txt")
75
+ & $adb shell dumpsys activity activities | Set-Content -Encoding utf8 (Join-Path $destination "$Prefix-activity.txt")
76
+ & $adb shell dumpsys SurfaceFlinger | Set-Content -Encoding utf8 (Join-Path $destination "$Prefix-surfaceflinger.txt")
77
+ }
78
+
79
+ function Assert-Condition([string]$When) {
80
+ $power = (& $adb shell dumpsys power) -join "`n"
81
+ if ($power -notmatch 'mWakefulness=Awake') {
82
+ throw "Headset is not awake $When; requested condition is $Condition"
83
+ }
84
+ if ($Condition -eq 'live-worn') {
85
+ $activity = (& $adb shell dumpsys activity activities) -join "`n"
86
+ if ($activity -match 'SensorLockActivity') {
87
+ throw "Headset is not being worn $When (SensorLockActivity is resumed)"
88
+ }
89
+ }
90
+ }
91
+
92
+ function Run-Process([string]$Name, [string]$RunCommand) {
93
+ Capture-State "$Name-before"
94
+ $process = Start-Process `
95
+ -FilePath $adb `
96
+ -ArgumentList @("shell", $RunCommand) `
97
+ -NoNewWindow `
98
+ -Wait `
99
+ -PassThru `
100
+ -RedirectStandardOutput (Join-Path $destination "$Name.stdout.log") `
101
+ -RedirectStandardError (Join-Path $destination "$Name.stderr.log")
102
+ Capture-State "$Name-after"
103
+ if ($process.ExitCode -ne 0) {
104
+ throw "device benchmark $Name exited with code $($process.ExitCode)"
105
+ }
106
+ }
107
+
108
+ # This process is retained as a stabilization diagnostic but excluded from
109
+ # aggregate results. It prevents the first retained process from receiving
110
+ # the entire device clock-ramp penalty.
111
+ Assert-Condition "before stabilization"
112
+ $warmupCommand = "cd $remoteDir && RUST_LOG=info DINOVISION_WARMUPS=5 ${suiteEnvironment}./$remoteName 5"
113
+ Run-Process "stabilization" $warmupCommand
114
+ for ($index = 0; $index -lt $Processes; $index++) {
115
+ Assert-Condition "before process $index"
116
+ Run-Process ("process-{0:d2}" -f $index) $command
117
+ }
118
+ Assert-Condition "after retained processes"
119
+
120
+ $recordArguments = @(
121
+ (Join-Path $repo 'tools\extract_json_records.py'),
122
+ '--marker', 'DINOVISION_BENCH_JSON',
123
+ '--output', (Join-Path $destination 'benchmark-records.json')
124
+ )
125
+ for ($index = 0; $index -lt $Processes; $index++) {
126
+ $recordArguments += @('--input', (Join-Path $destination ("process-{0:d2}.stdout.log" -f $index)))
127
+ }
128
+ & python @recordArguments
129
+ if ($LASTEXITCODE -ne 0) { throw 'could not extract benchmark JSON records' }
130
+
131
+ $metadata = [ordered]@{
132
+ schema_version = 1
133
+ completed_utc = [DateTime]::UtcNow.ToString("o")
134
+ condition = $Condition
135
+ suite = $Suite
136
+ processes = $Processes
137
+ warmups_per_process = 5
138
+ retained_samples_per_workload = $Iterations
139
+ command = $command
140
+ binary = $binaryPath
141
+ binary_sha256 = $binaryHash
142
+ device_serial = $serial
143
+ device_model = (& $adb shell getprop ro.product.model).Trim()
144
+ build_fingerprint = (& $adb shell getprop ro.build.fingerprint).Trim()
145
+ recovery_manifest = $recoveryPath
146
+ recovery_manifest_sha256 = (Get-FileHash -LiteralPath $recoveryPath -Algorithm SHA256).Hash.ToLowerInvariant()
147
+ }
148
+ $metadata | ConvertTo-Json -Depth 4 | Set-Content -Encoding utf8 (Join-Path $destination "benchmark-metadata.json")
149
+ Write-Output "wrote Quest benchmark artifacts to $destination"
environment/training-source/source-snapshot/tools/run_xr_sweep.ps1 ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [string]$OutputDir,
4
+ [Parameter(Mandatory = $true)]
5
+ [string]$RecoveryManifest,
6
+ [Parameter(Mandatory = $true)]
7
+ [string]$Apk,
8
+ [Parameter(Mandatory = $true)]
9
+ [string]$Model,
10
+ [Parameter(Mandatory = $true)]
11
+ [string]$Decoder,
12
+ # Fixed before device data: a deterministic permutation avoids making
13
+ # chunk count monotonic with startup/thermal drift. Summaries sort cells.
14
+ [int[]]$Chunks = @(4, 16, 1, 12, 2, 8),
15
+ [int]$InferenceIntervalMs = 0,
16
+ [int]$DurationSeconds = 90,
17
+ [string]$Package = 'rust.dinovision_xr',
18
+ [string]$Activity = 'android.app.NativeActivity'
19
+ )
20
+
21
+ $ErrorActionPreference = 'Stop'
22
+ if ($DurationSeconds -lt 60) {
23
+ throw 'paper protocol requires at least 60 seconds per chunk setting'
24
+ }
25
+ if ($Chunks.Count -lt 2 -or @($Chunks | Where-Object { $_ -lt 1 }).Count -gt 0) {
26
+ throw 'provide at least two positive chunk settings'
27
+ }
28
+ if (@($Chunks | Select-Object -Unique).Count -ne $Chunks.Count) {
29
+ throw 'chunk settings must be unique because each setting owns one output cell'
30
+ }
31
+
32
+ $repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
33
+ $apkPath = (Resolve-Path $Apk).Path
34
+ $modelPath = (Resolve-Path $Model).Path
35
+ $decoderPath = (Resolve-Path $Decoder).Path
36
+ $recoveryPath = (Resolve-Path $RecoveryManifest).Path
37
+ $destination = [System.IO.Path]::GetFullPath($OutputDir)
38
+ if (Test-Path -LiteralPath $destination) {
39
+ if (@(Get-ChildItem -LiteralPath $destination -Force).Count -ne 0) {
40
+ throw "Refusing to merge XR output into non-empty directory: $destination"
41
+ }
42
+ } else {
43
+ New-Item -ItemType Directory -Path $destination | Out-Null
44
+ }
45
+
46
+ $adbCandidates = @()
47
+ if ($env:LOCALAPPDATA) {
48
+ $adbCandidates += Join-Path $env:LOCALAPPDATA 'Android\Sdk\platform-tools\adb.exe'
49
+ }
50
+ if ($env:ANDROID_HOME) {
51
+ $adbCandidates += Join-Path $env:ANDROID_HOME 'platform-tools\adb.exe'
52
+ }
53
+ $adbCandidates = @($adbCandidates | Where-Object { Test-Path -LiteralPath $_ })
54
+ if ($adbCandidates.Count -eq 0) { throw 'adb.exe was not found' }
55
+ $adb = $adbCandidates[0]
56
+ $devices = @(& $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match "`tdevice$" })
57
+ if ($devices.Count -ne 1) { throw "Expected one authorized device; found $($devices.Count)" }
58
+ $serial = (& $adb get-serialno).Trim()
59
+ $recovery = Get-Content -Raw -LiteralPath $recoveryPath | ConvertFrom-Json
60
+ if ($recovery.device -ne $serial) {
61
+ throw "Recovery manifest is for $($recovery.device), connected device is $serial"
62
+ }
63
+
64
+ $remoteDir = '/data/local/tmp/dinovision'
65
+ $modelRemote = "$remoteDir/model.safetensors"
66
+ $decoderRemote = "$remoteDir/decoder.bin"
67
+ $toggleNames = @(
68
+ 'raw_camera', 'mono', 'camera_half_fov_deg',
69
+ 'inference_interval_ms', 'submission_chunks', 'capture'
70
+ )
71
+
72
+ function Shell-Quote([string]$Value) {
73
+ if ($Value.Contains("'")) { throw 'toggle values may not contain a single quote' }
74
+ return "'$Value'"
75
+ }
76
+
77
+ function Assert-LiveWorn([string]$When) {
78
+ $power = (& $adb shell dumpsys power) -join "`n"
79
+ if ($power -notmatch 'mWakefulness=Awake') {
80
+ throw "Headset is not awake $When"
81
+ }
82
+ $activityState = (& $adb shell dumpsys activity activities) -join "`n"
83
+ if ($activityState -match 'SensorLockActivity') {
84
+ throw "Headset is not being worn $When (SensorLockActivity is resumed)"
85
+ }
86
+ }
87
+
88
+ function Capture-State([string]$Prefix) {
89
+ & $adb shell dumpsys thermalservice | Set-Content -Encoding utf8 (Join-Path $destination "$Prefix-thermal.txt")
90
+ & $adb shell dumpsys battery | Set-Content -Encoding utf8 (Join-Path $destination "$Prefix-battery.txt")
91
+ & $adb shell dumpsys power | Set-Content -Encoding utf8 (Join-Path $destination "$Prefix-power.txt")
92
+ & $adb shell dumpsys activity activities | Set-Content -Encoding utf8 (Join-Path $destination "$Prefix-activity.txt")
93
+ & $adb shell dumpsys SurfaceFlinger | Set-Content -Encoding utf8 (Join-Path $destination "$Prefix-surfaceflinger.txt")
94
+ }
95
+
96
+ function Read-Toggle([string]$Name) {
97
+ $path = "$remoteDir/$Name"
98
+ $present = ((& $adb shell "if [ -e '$path' ]; then echo yes; else echo no; fi").Trim() -eq 'yes')
99
+ [ordered]@{
100
+ present = $present
101
+ value = if ($present) { ((& $adb shell "cat '$path' 2>/dev/null") -join "`n").TrimEnd() } else { $null }
102
+ }
103
+ }
104
+
105
+ function Write-Toggle([string]$Name, [string]$Value) {
106
+ $path = "$remoteDir/$Name"
107
+ $quoted = Shell-Quote $Value
108
+ & $adb shell "printf '%s' $quoted > '$path'"
109
+ if ($LASTEXITCODE -ne 0) { throw "could not write toggle $Name" }
110
+ }
111
+
112
+ function Remove-Toggle([string]$Name) {
113
+ & $adb shell "rm -f -- '$remoteDir/$Name'"
114
+ if ($LASTEXITCODE -ne 0) { throw "could not remove toggle $Name" }
115
+ }
116
+
117
+ function Remote-File-Present([string]$Path) {
118
+ return ((& $adb shell "if [ -f '$Path' ]; then echo yes; else echo no; fi").Trim() -eq 'yes')
119
+ }
120
+
121
+ & $adb shell "mkdir -p '$remoteDir'"
122
+ if ($LASTEXITCODE -ne 0) { throw "could not create $remoteDir" }
123
+ $originalToggles = [ordered]@{}
124
+ foreach ($name in $toggleNames) { $originalToggles[$name] = Read-Toggle $name }
125
+ $originalToggles | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 (Join-Path $destination 'preexisting-toggles.json')
126
+
127
+ $backupDir = Join-Path $destination 'preexisting-runtime'
128
+ New-Item -ItemType Directory -Path $backupDir | Out-Null
129
+ $hadModel = Remote-File-Present $modelRemote
130
+ $hadDecoder = Remote-File-Present $decoderRemote
131
+ if ($hadModel) { & $adb pull $modelRemote (Join-Path $backupDir 'model.safetensors') | Out-Null }
132
+ if ($hadDecoder) { & $adb pull $decoderRemote (Join-Path $backupDir 'decoder.bin') | Out-Null }
133
+
134
+ $completed = $false
135
+ try {
136
+ Assert-LiveWorn 'before installation'
137
+ & $adb install -r $apkPath | Set-Content -Encoding utf8 (Join-Path $destination 'install.log')
138
+ if ($LASTEXITCODE -ne 0) { throw 'APK installation failed' }
139
+ & $adb shell pm grant $Package horizonos.permission.HEADSET_CAMERA
140
+ if ($LASTEXITCODE -ne 0) { throw 'HEADSET_CAMERA permission grant failed' }
141
+
142
+ & $adb push $modelPath $modelRemote | Out-Null
143
+ if ($LASTEXITCODE -ne 0) { throw 'encoder push failed' }
144
+ & $adb push $decoderPath $decoderRemote | Out-Null
145
+ if ($LASTEXITCODE -ne 0) { throw 'decoder push failed' }
146
+ $modelHash = (Get-FileHash -LiteralPath $modelPath -Algorithm SHA256).Hash.ToLowerInvariant()
147
+ $decoderHash = (Get-FileHash -LiteralPath $decoderPath -Algorithm SHA256).Hash.ToLowerInvariant()
148
+ $remoteModelHash = ((& $adb shell "sha256sum '$modelRemote'").Trim().Split(' ')[0])
149
+ $remoteDecoderHash = ((& $adb shell "sha256sum '$decoderRemote'").Trim().Split(' ')[0])
150
+ if ($modelHash -ne $remoteModelHash -or $decoderHash -ne $remoteDecoderHash) {
151
+ throw 'pushed weight hash mismatch'
152
+ }
153
+
154
+ # Stereo corrected-model runs only. Capture is disabled so this protocol
155
+ # never writes private camera frames.
156
+ foreach ($name in @('raw_camera', 'mono', 'capture')) { Remove-Toggle $name }
157
+ Write-Toggle 'inference_interval_ms' "$InferenceIntervalMs"
158
+
159
+ foreach ($chunk in $Chunks) {
160
+ $name = "chunks-{0:d2}" -f $chunk
161
+ Write-Toggle 'submission_chunks' "$chunk"
162
+ Assert-LiveWorn "before $name"
163
+ Capture-State "$name-before"
164
+ & $adb shell am force-stop $Package
165
+ # logcat streams the existing ring buffer unless it is cleared. A stale
166
+ # DinoVision window from an earlier setting would otherwise be accepted
167
+ # as part of this cell and silently bias the aggregate.
168
+ & $adb logcat -c
169
+ if ($LASTEXITCODE -ne 0) { throw "could not clear logcat before $name" }
170
+ $stdout = Join-Path $destination "$name.logcat.log"
171
+ $stderr = Join-Path $destination "$name.logcat.stderr.log"
172
+ $logcat = Start-Process `
173
+ -FilePath $adb `
174
+ -ArgumentList @('logcat', '-v', 'epoch', 'dinovision:I', 'RustStdoutStderr:I', '*:S') `
175
+ -WindowStyle Hidden `
176
+ -PassThru `
177
+ -RedirectStandardOutput $stdout `
178
+ -RedirectStandardError $stderr
179
+ try {
180
+ Start-Sleep -Seconds 1
181
+ & $adb shell am start -W -a android.intent.action.MAIN -c com.oculus.intent.category.VR -n "$Package/$Activity" |
182
+ Set-Content -Encoding utf8 (Join-Path $destination "$name-launch.log")
183
+ if ($LASTEXITCODE -ne 0) { throw "could not launch $name" }
184
+ Start-Sleep -Seconds $DurationSeconds
185
+ & $adb shell am force-stop $Package
186
+ Start-Sleep -Seconds 2
187
+ } finally {
188
+ Stop-Process -Id $logcat.Id -Force -ErrorAction SilentlyContinue
189
+ $logcat.WaitForExit()
190
+ }
191
+ Capture-State "$name-after"
192
+ Assert-LiveWorn "after $name"
193
+ & python (Join-Path $repo 'tools\extract_json_records.py') `
194
+ --input $stdout `
195
+ --marker DINOVISION_APP_JSON `
196
+ --output (Join-Path $destination "$name-records.json")
197
+ if ($LASTEXITCODE -ne 0) { throw "no valid application records for $name" }
198
+ }
199
+
200
+ $metadata = [ordered]@{
201
+ schema_version = 1
202
+ completed_utc = [DateTime]::UtcNow.ToString('o')
203
+ condition = 'live-worn'
204
+ device_serial = $serial
205
+ device_model = (& $adb shell getprop ro.product.model).Trim()
206
+ build_fingerprint = (& $adb shell getprop ro.build.fingerprint).Trim()
207
+ package = $Package
208
+ activity = $Activity
209
+ chunks = $Chunks
210
+ order_policy = 'predeclared deterministic permutation before corrected device data'
211
+ inference_interval_ms = $InferenceIntervalMs
212
+ duration_seconds_per_setting = $DurationSeconds
213
+ apk_sha256 = (Get-FileHash -LiteralPath $apkPath -Algorithm SHA256).Hash.ToLowerInvariant()
214
+ model_sha256 = $modelHash
215
+ decoder_sha256 = $decoderHash
216
+ recovery_manifest = $recoveryPath
217
+ recovery_manifest_sha256 = (Get-FileHash -LiteralPath $recoveryPath -Algorithm SHA256).Hash.ToLowerInvariant()
218
+ }
219
+ $metadata | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 (Join-Path $destination 'sweep-metadata.json')
220
+ $completed = $true
221
+ } finally {
222
+ & $adb shell am force-stop $Package 2>$null
223
+ foreach ($name in $toggleNames) {
224
+ $state = $originalToggles[$name]
225
+ if ($state.present) { Write-Toggle $name ([string]$state.value) } else { Remove-Toggle $name }
226
+ }
227
+ if ($hadModel) {
228
+ & $adb push (Join-Path $backupDir 'model.safetensors') $modelRemote | Out-Null
229
+ } else {
230
+ & $adb shell "rm -f -- '$modelRemote'"
231
+ }
232
+ if ($hadDecoder) {
233
+ & $adb push (Join-Path $backupDir 'decoder.bin') $decoderRemote | Out-Null
234
+ } else {
235
+ & $adb shell "rm -f -- '$decoderRemote'"
236
+ }
237
+ }
238
+
239
+ if ($completed) { Write-Output "wrote live XR sweep to $destination" }
environment/training-source/source-snapshot/tools/stage_huggingface_artifact.ps1 ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [string]$OutputDir,
4
+ [Parameter(Mandatory = $true)]
5
+ [string]$RunsRoot,
6
+ [Parameter(Mandatory = $true)]
7
+ [string]$DatasetManifest,
8
+ [Parameter(Mandatory = $true)]
9
+ [string]$CorrectnessRoot,
10
+ [Parameter(Mandatory = $true)]
11
+ [string[]]$ReferenceRoots,
12
+ [Parameter(Mandatory = $true)]
13
+ [string]$EnvironmentDir,
14
+ [string]$CrossDeviceDir,
15
+ [string]$BenchmarkDir,
16
+ [string]$XrDir,
17
+ [int[]]$Seeds = @(0, 1, 2),
18
+ [int]$SelectedSeed = 0,
19
+ [int]$Layers = 3,
20
+ [int]$Size = 224,
21
+ [string]$ExpectedDatasetManifestSha256 = '2071f89b2a7b077d7729641fc858e1225d057d11a797d16198c0d195d989bb89',
22
+ [string]$ExpectedCorrectnessManifestSha256 = '94b10d1640c3e09b3eecec8abea64ec0cdd128032ad60a0e31d246ac932b178f',
23
+ [string]$ExpectedCorrectnessFrameSha256 = 'b89c2000b8877bce4649610e490579eef1ada5b29867f7a773d88450549ad1a8'
24
+ )
25
+
26
+ $ErrorActionPreference = 'Stop'
27
+ $repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
28
+ $runs = (Resolve-Path $RunsRoot).Path
29
+ $manifest = (Resolve-Path $DatasetManifest).Path
30
+ $correctness = (Resolve-Path $CorrectnessRoot).Path
31
+ $environment = (Resolve-Path $EnvironmentDir).Path
32
+ $references = @($ReferenceRoots | ForEach-Object { (Resolve-Path $_).Path })
33
+
34
+ function File-Hash([string]$Path) {
35
+ (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
36
+ }
37
+
38
+ if ((File-Hash $manifest) -ne $ExpectedDatasetManifestSha256) {
39
+ throw 'dataset manifest is not the frozen Imagenette manifest'
40
+ }
41
+ $correctnessManifest = Join-Path $correctness 'manifests\correctness-224.json'
42
+ $correctnessFrame = Join-Path $correctness 'input\frame.rgb'
43
+ if ((File-Hash $correctnessManifest) -ne $ExpectedCorrectnessManifestSha256) {
44
+ throw 'correctness manifest is not the frozen public manifest'
45
+ }
46
+ if ((File-Hash $correctnessFrame) -ne $ExpectedCorrectnessFrameSha256) {
47
+ throw 'correctness frame is not the frozen synthetic public frame'
48
+ }
49
+
50
+ if ($Seeds.Count -lt 3 -or $Seeds -notcontains $SelectedSeed) {
51
+ throw 'publish at least three seeds and include the preselected seed'
52
+ }
53
+ $destination = [System.IO.Path]::GetFullPath($OutputDir)
54
+ if (Test-Path -LiteralPath $destination) {
55
+ if (@(Get-ChildItem -LiteralPath $destination -Force).Count -ne 0) {
56
+ throw "Refusing to merge a publication into non-empty directory: $destination"
57
+ }
58
+ } else {
59
+ New-Item -ItemType Directory -Path $destination | Out-Null
60
+ }
61
+
62
+ function Copy-PublicFile([string]$Source, [string]$RelativeDestination) {
63
+ if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) {
64
+ throw "required publication input is missing: $Source"
65
+ }
66
+ $target = Join-Path $destination $RelativeDestination
67
+ $parent = Split-Path -Parent $target
68
+ New-Item -ItemType Directory -Force -Path $parent | Out-Null
69
+ Copy-Item -LiteralPath $Source -Destination $target
70
+ }
71
+
72
+ function Copy-DirectoryFiles(
73
+ [string]$Source,
74
+ [string]$RelativeDestination,
75
+ [string[]]$Patterns
76
+ ) {
77
+ foreach ($pattern in $Patterns) {
78
+ Get-ChildItem -LiteralPath $Source -File -Filter $pattern | ForEach-Object {
79
+ Copy-PublicFile $_.FullName (Join-Path $RelativeDestination $_.Name)
80
+ }
81
+ }
82
+ }
83
+
84
+ function Copy-PublicTree([string]$Source, [string]$RelativeDestination) {
85
+ if (-not (Test-Path -LiteralPath $Source -PathType Container)) {
86
+ throw "required publication directory is missing: $Source"
87
+ }
88
+ Get-ChildItem -LiteralPath $Source -Recurse -File | ForEach-Object {
89
+ $relative = [System.IO.Path]::GetRelativePath($Source, $_.FullName)
90
+ Copy-PublicFile $_.FullName (Join-Path $RelativeDestination $relative)
91
+ }
92
+ }
93
+
94
+ function Write-SanitizedJson(
95
+ [string]$Source,
96
+ [string]$RelativeDestination,
97
+ [string[]]$RemoveProperties
98
+ ) {
99
+ if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) {
100
+ throw "required publication input is missing: $Source"
101
+ }
102
+ $record = Get-Content -Raw -LiteralPath $Source | ConvertFrom-Json
103
+ foreach ($property in $RemoveProperties) {
104
+ $record.PSObject.Properties.Remove($property)
105
+ }
106
+ $target = Join-Path $destination $RelativeDestination
107
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null
108
+ $record | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $target -Encoding utf8
109
+ }
110
+
111
+ # Repository-owned documentation is the only recursively copied directory.
112
+ # Every experimental artifact below is selected by exact filename or suffix;
113
+ # in particular, no dataset image or private headset capture directory can be
114
+ # reached through this script.
115
+ Get-ChildItem -LiteralPath (Join-Path $repo 'huggingface') -File | ForEach-Object {
116
+ Copy-PublicFile $_.FullName $_.Name
117
+ }
118
+
119
+ $selectedDir = Join-Path $runs ("${Layers}l${Size}-seed${SelectedSeed}")
120
+ foreach ($name in @('decoder.bin', 'decoder.safetensors', 'training.json')) {
121
+ Copy-PublicFile (Join-Path $selectedDir $name) $name
122
+ }
123
+
124
+ foreach ($seed in $Seeds) {
125
+ $runName = "${Layers}l${Size}-seed${seed}"
126
+ $runDir = Join-Path $runs $runName
127
+ foreach ($name in @(
128
+ 'decoder.bin', 'decoder.safetensors', 'training.json',
129
+ 'run-notes.json',
130
+ 'training.stdout.log', 'training.stderr.log',
131
+ 'evaluation.stdout.log', 'evaluation.stderr.log'
132
+ )) {
133
+ Copy-PublicFile (Join-Path $runDir $name) (Join-Path "replicates\$runName" $name)
134
+ }
135
+ Copy-PublicFile `
136
+ (Join-Path $runDir 'quality-imagenette-validation.json') `
137
+ (Join-Path 'quality' "$runName.json")
138
+ }
139
+ Copy-PublicFile (Join-Path $runs 'quality-summary.json') 'quality\summary.json'
140
+ Copy-PublicFile (Join-Path $runs 'quality-summary.md') 'quality\summary.md'
141
+ Copy-PublicFile (Join-Path $runs 'dataset-summary.json') 'manifests\dataset-summary.json'
142
+ Copy-PublicFile (Join-Path $runs 'dataset-summary.md') 'manifests\dataset-summary.md'
143
+
144
+ Copy-PublicFile $manifest 'manifests\imagenette2-320.json'
145
+ Copy-PublicFile `
146
+ $correctnessManifest `
147
+ 'manifests\correctness-224.json'
148
+ Copy-PublicFile $correctnessFrame 'correctness\frame.rgb'
149
+
150
+ foreach ($reference in $references) {
151
+ $name = Split-Path -Leaf $reference
152
+ # Reference tensors use .bin; the gated exported model has a distinct
153
+ # .safetensors suffix and is deliberately not selected.
154
+ Copy-DirectoryFiles $reference (Join-Path 'correctness\reference' $name) @('*.json', '*.bin')
155
+ }
156
+
157
+ Copy-DirectoryFiles $environment 'environment\training-source' @(
158
+ 'metadata.json', 'Cargo.lock', 'cargo-tree.txt', 'dinovision-working-tree.patch'
159
+ )
160
+ Copy-PublicTree `
161
+ (Join-Path $environment 'dinovision-source-snapshot') `
162
+ 'environment\training-source\source-snapshot'
163
+
164
+ if ($CrossDeviceDir) {
165
+ $cross = (Resolve-Path $CrossDeviceDir).Path
166
+ Write-SanitizedJson `
167
+ (Join-Path $cross 'correctness-metadata.json') `
168
+ 'correctness\host-quest\metadata.json' `
169
+ @('device_serial', 'remote_root', 'recovery_manifest_sha256')
170
+ Copy-DirectoryFiles (Join-Path $cross 'comparisons') 'correctness\host-quest\comparisons' @('*.json')
171
+ Copy-DirectoryFiles (Join-Path $cross 'device-output') 'correctness\host-quest\device-output' @('*.json')
172
+ Copy-DirectoryFiles `
173
+ (Join-Path $cross 'device-output\quality_samples') `
174
+ 'correctness\host-quest\device-output\quality_samples' `
175
+ @('*.f32')
176
+ }
177
+
178
+ if ($BenchmarkDir) {
179
+ $bench = (Resolve-Path $BenchmarkDir).Path
180
+ Write-SanitizedJson `
181
+ (Join-Path $bench 'benchmark-metadata.json') `
182
+ 'benchmarks\isolated\metadata.json' `
183
+ @('device_serial', 'binary', 'recovery_manifest', 'recovery_manifest_sha256')
184
+ foreach ($name in @('benchmark-records.json', 'benchmark-summary.json', 'benchmark-summary.md')) {
185
+ Copy-PublicFile (Join-Path $bench $name) (Join-Path 'benchmarks\isolated' $name)
186
+ }
187
+ }
188
+
189
+ if ($XrDir) {
190
+ $xr = (Resolve-Path $XrDir).Path
191
+ Write-SanitizedJson `
192
+ (Join-Path $xr 'sweep-metadata.json') `
193
+ 'benchmarks\live-xr\metadata.json' `
194
+ @('device_serial', 'recovery_manifest', 'recovery_manifest_sha256')
195
+ Copy-DirectoryFiles $xr 'benchmarks\live-xr\records' @('chunks-*-records.json')
196
+ foreach ($name in @('xr-summary.json', 'xr-summary.md')) {
197
+ Copy-PublicFile (Join-Path $xr $name) (Join-Path 'benchmarks\live-xr' $name)
198
+ }
199
+ }
200
+
201
+ $files = @(
202
+ Get-ChildItem -LiteralPath $destination -Recurse -File |
203
+ Sort-Object FullName |
204
+ ForEach-Object {
205
+ [ordered]@{
206
+ path = [System.IO.Path]::GetRelativePath($destination, $_.FullName).Replace('\', '/')
207
+ bytes = $_.Length
208
+ sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
209
+ }
210
+ }
211
+ )
212
+ $encoderCheckpointSha256 = '4610ad75edef83e75afdebf162d148dc628045ea6cbb83d67d4708c709c4f91d'
213
+ if (@($files | Where-Object { $_.sha256 -eq $encoderCheckpointSha256 }).Count -ne 0) {
214
+ throw 'the gated DINOv3 checkpoint must not be staged'
215
+ }
216
+ $imagePayloads = @($files | Where-Object { $_.path -match '\.(jpe?g|png)$' })
217
+ if ($imagePayloads.Count -ne 0) {
218
+ throw "dataset or reconstruction images must not be staged: $($imagePayloads.path -join ', ')"
219
+ }
220
+ $manifestRecord = [ordered]@{
221
+ schema_version = 1
222
+ created_utc = [DateTime]::UtcNow.ToString('o')
223
+ selected_seed = $SelectedSeed
224
+ encoder_layers = $Layers
225
+ image_size = $Size
226
+ files = $files
227
+ }
228
+ $manifestRecord | ConvertTo-Json -Depth 6 |
229
+ Set-Content -LiteralPath (Join-Path $destination 'ARTIFACT_MANIFEST.json') -Encoding utf8
230
+ Write-Output "staged $($files.Count) explicitly selected files in $destination"
environment/training-source/source-snapshot/tools/summarize_bench.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Aggregate raw benchmark samples across fresh device processes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import statistics
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+ from typing import Iterable
14
+
15
+
16
+ def distribution(values: Iterable[float]) -> dict[str, float]:
17
+ ordered = sorted(float(value) for value in values)
18
+ if not ordered:
19
+ raise SystemExit("cannot summarize an empty distribution")
20
+ if any(not math.isfinite(value) or value <= 0 for value in ordered):
21
+ raise SystemExit("timing samples must be finite and positive")
22
+
23
+ def quantile(q: float) -> float:
24
+ at = q * (len(ordered) - 1)
25
+ lower = int(at)
26
+ upper = min(lower + 1, len(ordered) - 1)
27
+ return ordered[lower] + (ordered[upper] - ordered[lower]) * (at - lower)
28
+
29
+ return {
30
+ "mean": statistics.fmean(ordered),
31
+ "median": quantile(0.5),
32
+ "p25": quantile(0.25),
33
+ "p75": quantile(0.75),
34
+ "min": ordered[0],
35
+ "max": ordered[-1],
36
+ }
37
+
38
+
39
+ def main() -> None:
40
+ parser = argparse.ArgumentParser()
41
+ parser.add_argument("--records", required=True, type=Path)
42
+ parser.add_argument("--metadata", required=True, type=Path)
43
+ parser.add_argument("--output-json", required=True, type=Path)
44
+ parser.add_argument("--output-markdown", required=True, type=Path)
45
+ args = parser.parse_args()
46
+
47
+ artifact = json.loads(args.records.read_text(encoding="utf-8-sig"))
48
+ metadata = json.loads(args.metadata.read_text(encoding="utf-8-sig"))
49
+ if int(metadata["processes"]) < 3:
50
+ raise SystemExit("paper protocol requires at least three processes")
51
+ expected_samples = int(metadata["retained_samples_per_workload"])
52
+ if expected_samples < 20:
53
+ raise SystemExit("paper protocol requires at least 20 samples per process")
54
+
55
+ grouped: dict[str, list[dict[str, object]]] = defaultdict(list)
56
+ for record in artifact.get("records", []):
57
+ if record.get("kind") != "dinovision_benchmark":
58
+ raise SystemExit("unexpected benchmark record kind")
59
+ samples = record.get("samples_ms", [])
60
+ if len(samples) != expected_samples:
61
+ raise SystemExit(
62
+ f"{record.get('label')} has {len(samples)} samples, expected {expected_samples}"
63
+ )
64
+ computed = distribution(float(value) for value in samples)
65
+ for field, key in (
66
+ ("median_ms", "median"),
67
+ ("p25_ms", "p25"),
68
+ ("p75_ms", "p75"),
69
+ ("min_ms", "min"),
70
+ ("max_ms", "max"),
71
+ ("mean_ms", "mean"),
72
+ ):
73
+ if not math.isclose(
74
+ float(record[field]), computed[key], rel_tol=0.0, abs_tol=1e-4
75
+ ):
76
+ raise SystemExit(
77
+ f"{record.get('label')} has inconsistent {field}: "
78
+ f"{record[field]} vs {computed[key]} from raw samples"
79
+ )
80
+ grouped[str(record["label"])].append(record)
81
+
82
+ workloads: list[dict[str, object]] = []
83
+ for label, records in sorted(grouped.items()):
84
+ if len(records) != int(metadata["processes"]):
85
+ raise SystemExit(
86
+ f"{label!r} occurs in {len(records)} processes, expected {metadata['processes']}"
87
+ )
88
+ sources = {str(record.get("artifact_source")) for record in records}
89
+ if len(sources) != len(records):
90
+ raise SystemExit(f"{label!r} does not have one record per distinct process log")
91
+ macs = {int(record["macs"]) for record in records}
92
+ if len(macs) != 1:
93
+ raise SystemExit(f"{label!r} has inconsistent MAC counts")
94
+ mac_count = next(iter(macs))
95
+ samples = [float(value) for record in records for value in record["samples_ms"]]
96
+ timings = distribution(samples)
97
+ process_medians = distribution(float(record["median_ms"]) for record in records)
98
+ median_ms = timings["median"]
99
+ workloads.append(
100
+ {
101
+ "label": label,
102
+ "macs": mac_count,
103
+ "processes": len(records),
104
+ "samples": len(samples),
105
+ "timing_ms": timings,
106
+ "process_median_ms": process_medians,
107
+ "median_gflops": (2.0 * mac_count) / (median_ms / 1000.0) / 1e9,
108
+ }
109
+ )
110
+
111
+ result = {
112
+ "schema_version": 1,
113
+ "benchmark_metadata": metadata,
114
+ "records_sha256": hashlib.sha256(args.records.read_bytes()).hexdigest(),
115
+ "workloads": workloads,
116
+ }
117
+ args.output_json.parent.mkdir(parents=True, exist_ok=True)
118
+ args.output_markdown.parent.mkdir(parents=True, exist_ok=True)
119
+ args.output_json.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
120
+
121
+ lines = [
122
+ "# Isolated Quest benchmark",
123
+ "",
124
+ f"Condition: `{metadata['condition']}`. Each workload has {metadata['processes']} processes x {expected_samples} retained samples after {metadata['warmups_per_process']} warmups/process.",
125
+ "",
126
+ "| Workload | Pooled median [IQR] (ms) | Process medians: median [min, max] (ms) | Pooled min-max (ms) | Median GFLOP/s |",
127
+ "|---|---:|---:|---:|---:|",
128
+ ]
129
+ for workload in workloads:
130
+ timing = workload["timing_ms"]
131
+ lines.append(
132
+ f"| {workload['label']} "
133
+ f"| {timing['median']:.2f} [{timing['p25']:.2f}, {timing['p75']:.2f}] "
134
+ f"| {workload['process_median_ms']['median']:.2f} "
135
+ f"[{workload['process_median_ms']['min']:.2f}, {workload['process_median_ms']['max']:.2f}] "
136
+ f"| {timing['min']:.2f}-{timing['max']:.2f} "
137
+ f"| {workload['median_gflops']:.1f} |"
138
+ )
139
+ args.output_markdown.write_text("\n".join(lines) + "\n", encoding="utf-8")
140
+ print(f"wrote {args.output_json} and {args.output_markdown}")
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
environment/training-source/source-snapshot/tools/summarize_dataset.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Summarize an immutable dataset manifest and its selected training prefix."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ from collections import Counter, defaultdict
10
+ from pathlib import Path
11
+
12
+
13
+ def main() -> None:
14
+ parser = argparse.ArgumentParser()
15
+ parser.add_argument("--manifest", required=True, type=Path)
16
+ parser.add_argument("--training-prefix", type=int, default=2500)
17
+ parser.add_argument(
18
+ "--class-component",
19
+ type=int,
20
+ default=2,
21
+ help="zero-based path component containing a class ID",
22
+ )
23
+ parser.add_argument("--output-json", required=True, type=Path)
24
+ parser.add_argument("--output-markdown", required=True, type=Path)
25
+ args = parser.parse_args()
26
+ if args.training_prefix < 1 or args.class_component < 0:
27
+ raise SystemExit("prefix must be positive and class component non-negative")
28
+
29
+ manifest_bytes = args.manifest.read_bytes()
30
+ manifest = json.loads(manifest_bytes)
31
+ if manifest.get("schema_version") != 1 or not manifest.get("images"):
32
+ raise SystemExit("unsupported or empty dataset manifest")
33
+
34
+ split_counts: Counter[str] = Counter()
35
+ source_counts: dict[str, Counter[str]] = defaultdict(Counter)
36
+ group_splits: dict[tuple[str, str], str] = {}
37
+ hashes: set[str] = set()
38
+ paths: set[str] = set()
39
+ class_counts: dict[str, Counter[str]] = defaultdict(Counter)
40
+ train_entries: list[dict[str, object]] = []
41
+ for image in manifest["images"]:
42
+ split = str(image["split"])
43
+ source = str(image["source"])
44
+ group = str(image["group"])
45
+ path = str(image["path"])
46
+ digest = str(image["sha256"])
47
+ if path in paths or digest in hashes:
48
+ raise SystemExit(f"duplicate path or content in manifest: {path}")
49
+ paths.add(path)
50
+ hashes.add(digest)
51
+ key = (source, group)
52
+ previous = group_splits.setdefault(key, split)
53
+ if previous != split:
54
+ raise SystemExit(f"group {source}/{group} crosses {previous} and {split}")
55
+ parts = Path(path).parts
56
+ if len(parts) <= args.class_component:
57
+ raise SystemExit(f"{path} has no class component {args.class_component}")
58
+ class_id = parts[args.class_component]
59
+ split_counts[split] += 1
60
+ source_counts[split][source] += 1
61
+ class_counts[split][class_id] += 1
62
+ if split == "train":
63
+ train_entries.append(image)
64
+
65
+ if len(train_entries) < args.training_prefix:
66
+ raise SystemExit(
67
+ f"training split has {len(train_entries)} entries, fewer than prefix "
68
+ f"{args.training_prefix}"
69
+ )
70
+ prefix_classes = Counter(
71
+ Path(str(image["path"])).parts[args.class_component]
72
+ for image in train_entries[: args.training_prefix]
73
+ )
74
+ artifact = {
75
+ "schema_version": 1,
76
+ "dataset_name": manifest.get("name"),
77
+ "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
78
+ "provenance": manifest.get("provenance", []),
79
+ "images": len(manifest["images"]),
80
+ "unique_content_hashes": len(hashes),
81
+ "groups": len(group_splits),
82
+ "split_counts": dict(sorted(split_counts.items())),
83
+ "source_counts_by_split": {
84
+ split: dict(sorted(counts.items()))
85
+ for split, counts in sorted(source_counts.items())
86
+ },
87
+ "class_counts_by_split": {
88
+ split: dict(sorted(counts.items()))
89
+ for split, counts in sorted(class_counts.items())
90
+ },
91
+ "selected_training_prefix": args.training_prefix,
92
+ "selected_training_prefix_class_counts": dict(sorted(prefix_classes.items())),
93
+ }
94
+ args.output_json.parent.mkdir(parents=True, exist_ok=True)
95
+ args.output_markdown.parent.mkdir(parents=True, exist_ok=True)
96
+ args.output_json.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
97
+
98
+ lines = [
99
+ "# Dataset summary",
100
+ "",
101
+ f"Manifest SHA-256: `{artifact['manifest_sha256']}`.",
102
+ "",
103
+ "| Split | Images | Classes | Sources |",
104
+ "|---|---:|---:|---|",
105
+ ]
106
+ for split, count in sorted(split_counts.items()):
107
+ lines.append(
108
+ f"| {split} | {count} | {len(class_counts[split])} "
109
+ f"| {', '.join(f'{source}: {n}' for source, n in sorted(source_counts[split].items()))} |"
110
+ )
111
+ lines.extend(
112
+ [
113
+ "",
114
+ f"The selected training prefix contains {args.training_prefix} images:",
115
+ "",
116
+ "| Class ID | Images |",
117
+ "|---|---:|",
118
+ ]
119
+ )
120
+ lines.extend(f"| {name} | {count} |" for name, count in sorted(prefix_classes.items()))
121
+ args.output_markdown.write_text("\n".join(lines) + "\n", encoding="utf-8")
122
+ print(f"wrote {args.output_json} and {args.output_markdown}")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
environment/training-source/source-snapshot/tools/summarize_quality.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Aggregate decoder training/evaluation JSON without manual transcription."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import statistics
10
+ from collections import defaultdict
11
+ from pathlib import Path
12
+
13
+
14
+ def mean_sd(values: list[float]) -> dict[str, float | None]:
15
+ if not values:
16
+ return {"mean": None, "sample_stddev": None}
17
+ return {
18
+ "mean": statistics.fmean(values),
19
+ "sample_stddev": statistics.stdev(values) if len(values) > 1 else None,
20
+ }
21
+
22
+
23
+ def display(value: dict[str, float | None], digits: int) -> str:
24
+ mean = value["mean"]
25
+ stddev = value["sample_stddev"]
26
+ assert mean is not None
27
+ return (
28
+ f"{mean:.{digits}f}"
29
+ if stddev is None
30
+ else f"{mean:.{digits}f} ± {stddev:.{digits}f}"
31
+ )
32
+
33
+
34
+ def main() -> None:
35
+ parser = argparse.ArgumentParser()
36
+ parser.add_argument("--run-dir", action="append", required=True, type=Path)
37
+ parser.add_argument("--quality-name", default="quality-imagenette-validation.json")
38
+ parser.add_argument("--output-json", required=True, type=Path)
39
+ parser.add_argument("--output-markdown", required=True, type=Path)
40
+ parser.add_argument("--minimum-runs", type=int, default=3)
41
+ args = parser.parse_args()
42
+ if args.minimum_runs < 1:
43
+ raise SystemExit("--minimum-runs must be positive")
44
+
45
+ runs: list[dict[str, object]] = []
46
+ grouped: dict[tuple[object, ...], list[dict[str, object]]] = defaultdict(list)
47
+ for directory in args.run_dir:
48
+ training = json.loads((directory / "training.json").read_text(encoding="utf-8"))
49
+ quality = json.loads((directory / args.quality_name).read_text(encoding="utf-8"))
50
+ notes_path = directory / "run-notes.json"
51
+ notes = json.loads(notes_path.read_text(encoding="utf-8")) if notes_path.exists() else {}
52
+ checks = {
53
+ "decoder_hash": training["decoder_sha256"] == quality["decoder_sha256"],
54
+ "model_hash": training["model_sha256"] == quality["model_sha256"],
55
+ "manifest_hash": training["dataset_manifest_sha256"]
56
+ == quality["manifest_sha256"],
57
+ "layers": training["encoder_layers"] == quality["encoder_layers"],
58
+ "image_size": training["image_size"] == quality["image_size"],
59
+ }
60
+ if not all(checks.values()):
61
+ raise SystemExit(f"inconsistent run {directory}: {checks}")
62
+ run = {
63
+ "directory": str(directory).replace("\\", "/"),
64
+ "seed": training["seed"],
65
+ "encoder_layers": training["encoder_layers"],
66
+ "image_size": training["image_size"],
67
+ "training_images": training["training_images"],
68
+ "evaluation_images": quality["summary"]["count"],
69
+ "dataset": quality["dataset_name"],
70
+ "split": quality["split"],
71
+ "manifest_sha256": quality["manifest_sha256"],
72
+ "model_sha256": quality["model_sha256"],
73
+ "decoder_sha256": quality["decoder_sha256"],
74
+ "training_record_sha256": hashlib.sha256(
75
+ (directory / "training.json").read_bytes()
76
+ ).hexdigest(),
77
+ "quality_record_sha256": hashlib.sha256(
78
+ (directory / args.quality_name).read_bytes()
79
+ ).hexdigest(),
80
+ "training_seconds": training["training_seconds"],
81
+ # Interactive development runs are valid training/quality records,
82
+ # but their wall time is not a performance result. Timing is
83
+ # admitted only when the run notes opt in after documenting a
84
+ # controlled host state; absence of notes must fail closed.
85
+ "training_timing_valid": notes.get("training_timing_valid", False),
86
+ "final_training_l1": training["final_l1"],
87
+ "global_psnr_db": quality["summary"]["global_psnr_db"],
88
+ "median_image_psnr_db": quality["summary"]["psnr_db"]["median"],
89
+ "median_image_ssim": quality["summary"]["ssim"]["median"],
90
+ "median_image_mae": quality["summary"]["mae"]["median"],
91
+ }
92
+ runs.append(run)
93
+ key = (
94
+ run["encoder_layers"],
95
+ run["image_size"],
96
+ run["dataset"],
97
+ run["split"],
98
+ run["manifest_sha256"],
99
+ run["model_sha256"],
100
+ )
101
+ grouped[key].append(run)
102
+
103
+ aggregates: list[dict[str, object]] = []
104
+ for (layers, size, dataset, split, manifest_sha, model_sha), cell_runs in sorted(
105
+ grouped.items()
106
+ ):
107
+ cell_runs.sort(key=lambda run: int(run["seed"]))
108
+ seeds = [int(run["seed"]) for run in cell_runs]
109
+ if len(cell_runs) < args.minimum_runs:
110
+ raise SystemExit(
111
+ f"{layers}L/{size}/{dataset}/{split} has {len(cell_runs)} runs; "
112
+ f"require at least {args.minimum_runs}"
113
+ )
114
+ if len(set(seeds)) != len(seeds):
115
+ raise SystemExit(f"duplicate seed in {layers}L/{size}/{dataset}/{split}: {seeds}")
116
+ training_counts = {int(run["training_images"]) for run in cell_runs}
117
+ evaluation_counts = {int(run["evaluation_images"]) for run in cell_runs}
118
+ if len(training_counts) != 1 or len(evaluation_counts) != 1:
119
+ raise SystemExit(
120
+ f"inconsistent sample counts in {layers}L/{size}/{dataset}/{split}"
121
+ )
122
+ valid_training_times = [
123
+ float(run["training_seconds"])
124
+ for run in cell_runs
125
+ if run["training_timing_valid"]
126
+ ]
127
+ aggregate = {
128
+ "encoder_layers": layers,
129
+ "image_size": size,
130
+ "dataset": dataset,
131
+ "split": split,
132
+ "manifest_sha256": manifest_sha,
133
+ "model_sha256": model_sha,
134
+ "seeds": seeds,
135
+ "runs": len(cell_runs),
136
+ "training_images_per_run": cell_runs[0]["training_images"],
137
+ "evaluation_images_per_run": cell_runs[0]["evaluation_images"],
138
+ "global_psnr_db": mean_sd(
139
+ [float(run["global_psnr_db"]) for run in cell_runs]
140
+ ),
141
+ "median_image_psnr_db": mean_sd(
142
+ [float(run["median_image_psnr_db"]) for run in cell_runs]
143
+ ),
144
+ "median_image_ssim": mean_sd(
145
+ [float(run["median_image_ssim"]) for run in cell_runs]
146
+ ),
147
+ "median_image_mae": mean_sd(
148
+ [float(run["median_image_mae"]) for run in cell_runs]
149
+ ),
150
+ "valid_training_timing_runs": len(valid_training_times),
151
+ "training_seconds": mean_sd(valid_training_times),
152
+ }
153
+ aggregates.append(aggregate)
154
+
155
+ artifact = {"schema_version": 1, "runs": runs, "aggregates": aggregates}
156
+ args.output_json.parent.mkdir(parents=True, exist_ok=True)
157
+ args.output_markdown.parent.mkdir(parents=True, exist_ok=True)
158
+ args.output_json.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
159
+
160
+ lines = [
161
+ "# Decoder quality summary",
162
+ "",
163
+ "Generated from immutable training and per-image evaluation JSON.",
164
+ "",
165
+ "| Encoder | Size | Seeds | Validation images/run | Global PSNR (dB) | Median-image PSNR (dB) | Median RGB SSIM | Median MAE |",
166
+ "|---:|---:|---:|---:|---:|---:|---:|---:|",
167
+ ]
168
+ for cell in aggregates:
169
+ lines.append(
170
+ f"| {cell['encoder_layers']}L | {cell['image_size']} | {cell['runs']} "
171
+ f"| {cell['evaluation_images_per_run']} "
172
+ f"| {display(cell['global_psnr_db'], 2)} "
173
+ f"| {display(cell['median_image_psnr_db'], 2)} "
174
+ f"| {display(cell['median_image_ssim'], 4)} "
175
+ f"| {display(cell['median_image_mae'], 4)} |"
176
+ )
177
+ lines.extend(
178
+ [
179
+ "",
180
+ "| Seed | Decoder SHA-256 | Training (s) | Final-batch train L1 | Global PSNR (dB) | Median RGB SSIM |",
181
+ "|---:|---|---:|---:|---:|---:|",
182
+ ]
183
+ )
184
+ for run in sorted(runs, key=lambda item: (item["encoder_layers"], item["image_size"], item["seed"])):
185
+ training_seconds = (
186
+ f"{float(run['training_seconds']):.1f}"
187
+ if run["training_timing_valid"]
188
+ else "excluded"
189
+ )
190
+ lines.append(
191
+ f"| {run['seed']} | `{run['decoder_sha256']}` "
192
+ f"| {training_seconds} "
193
+ f"| {float(run['final_training_l1']):.5f} "
194
+ f"| {float(run['global_psnr_db']):.2f} "
195
+ f"| {float(run['median_image_ssim']):.4f} |"
196
+ )
197
+ args.output_markdown.write_text("\n".join(lines) + "\n", encoding="utf-8")
198
+ print(f"wrote {args.output_json} and {args.output_markdown}")
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()