SharpMeow commited on
Commit
a2130ce
·
verified ·
1 Parent(s): 7312066

Scroll Lens 1.0.0

Browse files
Files changed (6) hide show
  1. LICENSE +201 -0
  2. NOTICE +28 -0
  3. README.md +120 -6
  4. app.py +745 -0
  5. lens.py +932 -0
  6. requirements.txt +17 -0
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
NOTICE ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Scroll Lens
2
+ Copyright 2026 Chase Hendrick and Scrolls contributors
3
+
4
+ The Scroll Lens code (app.py, lens.py and the tools/ scripts) is licensed under the Apache License, Version 2.0.
5
+ See LICENSE.
6
+
7
+ Scroll Lens copies no third-party code. When it starts, it downloads the following at pinned versions and uses them
8
+ under their own licenses, which still apply:
9
+
10
+ - dinovol, https://github.com/ScrollPrize/dinovol at commit a72fda2b1e02389a8711b3dc6a26215237f35eca:
11
+ MIT License, Copyright (c) 2024 Vesuvius Challenge. Parts of it are derived from DINOv2 (Apache License 2.0,
12
+ Meta Platforms, Inc. and affiliates) and DINOv3 (DINOv3 License Agreement, Meta Platforms, Inc. and affiliates);
13
+ see that repository's THIRD_PARTY_NOTICES.md and LICENSES/.
14
+ - villa's vesuvius package, https://github.com/ScrollPrize/villa at commit
15
+ b29f310957c7b29e7d332f9876e0e487819cfaa3: MIT License, Copyright (c) Vesuvius Challenge.
16
+ - Model weights from the Hugging Face Hub, each MIT licensed by the Vesuvius Challenge (scrollprize):
17
+ scrollprize/dinovol_v2_ps8_with_paris4_352500, scrollprize/ink_3d_dino_guided (including its
18
+ avg_ref_embedding.npy) and scrollprize/surface_recto_3dunet. The three models are described in Angelotti et al.
19
+ 2026, https://arxiv.org/abs/2606.29085.
20
+
21
+ Scan data: Scroll Lens reads CT scans from the Vesuvius Challenge open data, licensed CC BY-NC 4.0
22
+ (https://creativecommons.org/licenses/by-nc/4.0/). Giorgio Angelotti, Stephen Parsons, Sean Johnson, Elian Rafael
23
+ Dal Prà, Johannes Rudolph, Paul Tafforeau, Alessandro Mirone, Paul Henderson, Hendrik Schilling, Forrest McDonald,
24
+ David Josey, Youssef Nader, C. Seth Parker, W. Brent Seales. Vesuvius Challenge: CT Scans of Herculaneum Papyri.
25
+ Vesuvius Challenge. https://scrollprize.org/data
26
+
27
+ The gallery cubes in gallery/ are model outputs computed from that data, so they are derived data under
28
+ CC BY-NC 4.0 as well. They hold no scan voxels: the page reads the scan again from the open-data bucket.
README.md CHANGED
@@ -1,13 +1,127 @@
1
  ---
2
  title: Scroll Lens
3
- emoji: 🔥
4
- colorFrom: pink
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.30.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Scroll Lens
3
+ emoji: 🔎
4
+ colorFrom: yellow
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 6.28.0
8
+ python_version: "3.12"
9
  app_file: app.py
10
  pinned: false
11
+ license: apache-2.0
12
+ suggested_hardware: zero-a10g
13
+ startup_duration_timeout: 1h
14
+ short_description: See scroll CT through 3D DINO, ink and surface models
15
+ models:
16
+ - scrollprize/dinovol_v2_ps8_with_paris4_352500
17
+ - scrollprize/ink_3d_dino_guided
18
+ - scrollprize/surface_recto_3dunet
19
+ tags:
20
+ - vesuvius-challenge
21
+ - herculaneum
22
+ - ct
23
+ - 3d
24
+ - dino
25
+ - self-supervised
26
+ - zerogpu
27
  ---
28
 
29
+ # Scroll Lens
30
+
31
+ Look inside a carbonized Herculaneum scroll with the Vesuvius Challenge's newest 3D models, and click to search.
32
+
33
+ Scroll Lens reads a small cube (128³ or 256³ voxels, 0.3 or 0.6 mm a side) of a public X-ray CT scan straight from the
34
+ [Vesuvius Challenge open data](https://scrollprize.org/data) and runs three models on it, all released by the
35
+ Vesuvius Challenge team with their PHerc. Paris 4 work ([Angelotti et al. 2026](https://arxiv.org/abs/2606.29085)):
36
+
37
+ | Layer | Model | What it shows |
38
+ | --- | --- | --- |
39
+ | 3D DINO embeddings | [`scrollprize/dinovol_v2_ps8_with_paris4_352500`](https://huggingface.co/scrollprize/dinovol_v2_ps8_with_paris4_352500) | A self-supervised 3D ViT: 864 numbers for every 8 × 8 × 8 voxel patch. Alike patches get alike numbers. Shown as *DINO colours* (the three main directions as red, green, blue) and used by click search. |
40
+ | 3D ink | [`scrollprize/ink_3d_dino_guided`](https://huggingface.co/scrollprize/ink_3d_dino_guided) | Ink voxels marked directly in the scan, with no flattened surface. |
41
+ | Recto surface | [`scrollprize/surface_recto_3dunet`](https://huggingface.co/scrollprize/surface_recto_3dunet) | The written side of each papyrus sheet. |
42
+
43
+ **Click to search.** Click a patch with *More like this* and every patch whose embedding is close to it lights up;
44
+ *Not this* subtracts a patch. This is the organisers' own recipe: their ink reference (shown in amber) is the mean
45
+ embedding of 256 expert clicks on ink. Your concept follows you to other cubes and scans, and downloads as
46
+ `concept.npy`, so you can click ink in one scroll and look for its like in another.
47
+
48
+ **Everything shown is model output, not a reading.** A cube is smaller than one letter.
49
+
50
+ ## Using it
51
+
52
+ 1. **Gallery** (no GPU): pick a ready-made cube. The page opens on the first one.
53
+ 2. **Your own point**: pick a scan, type a point (level-0 voxels, z y x) or press *Surprise me*, then *Run the
54
+ models*. A 256³ cube asks ZeroGPU for about half a minute. When your daily ZeroGPU time is used up, or no GPU is
55
+ free, the models run on the Space's CPU on the middle 128³ of the cube instead (slower, about a minute).
56
+ 3. **Look**: move the z, y and x sliders; tick the layers. White dotted lines show where the other views cut.
57
+ 4. **Search**: choose a click mode and click a view. *Top % shown* sets how many of the most similar patches light up.
58
+ 5. **3D view**: ink and surface as shells, similar patches as points, rotatable.
59
+ 6. **Save files**: the model outputs (`.npz`), a receipt (`.json`) and, after clicks, `concept.npy` and `clicks.json`.
60
+
61
+ Links: `?v=PHerc0139&z=…&y=…&x=…&size=256` opens the page on a point (it runs only when you press the button);
62
+ `?cube=ID` opens a gallery cube.
63
+
64
+ ## The scans
65
+
66
+ Only these twelve scans, all at 2.2 to 2.4 µm, the scale the models were made for. None is on the First Letters or
67
+ Grand Prize eligible lists. PHerc. Paris 4's title is an open prize, so Scroll Lens itself only ever picks the eight
68
+ Paris 4 points the organisers listed for training the 3D ink model; you can type any point.
69
+
70
+ | Id | Scroll | 3D DINO | 3D ink | Recto surface |
71
+ | --- | --- | --- | --- | --- |
72
+ | PHercParis4 | PHerc. Paris 4 | trained on this scan | made for this scroll | not trained on it |
73
+ | PHerc0139 | PHerc. 139 | trained on this scan | its teacher was | trained on it |
74
+ | PHerc1667 | PHerc. 1667 (Scroll 4) | **never saw it** | its teacher was | trained on it |
75
+ | PHerc0500P2 | PHerc. 500P2 | trained on this scan | its teacher was | trained on it |
76
+ | PHerc0343P | PHerc. 343P | trained on this scan | not trained on it | trained on it |
77
+ | PHerc0814 | PHerc. 814 | trained on this scan | its teacher was | not trained on it |
78
+ | PHerc0009B | PHerc. 9B | trained on this scan | its teacher was | not trained on it |
79
+ | PHercMAN5 | PHerc. MAN 5 | trained on this scan | its teacher was | not trained on it |
80
+ | PHerc1299 | PHerc. 1299 | trained on this scan | not trained on it | not trained on it |
81
+ | PHerc0332 | PHerc. 332 | trained on this scan | not trained on it | not trained on it |
82
+ | PHerc1451 | PHerc. 1451 | trained on this scan | not trained on it | not trained on it |
83
+ | PHercMANB | PHerc. MAN B | trained on this scan | not trained on it | trained on it (MAN Bp) |
84
+
85
+ "Its teacher was": the 3D ink model was distilled from a stage-1 teacher trained on that scroll's ink labels. From
86
+ the model cards and the checkpoints' own training configs, read 16 October 2026. The page shows this per cube.
87
+
88
+ ## Checks
89
+
90
+ - Each cube shows how well the organisers' ink reference picks out the patches the 3D ink model marks as ink (AUC),
91
+ beside a control that must fail: the same ink mask moved by half the cube, which should sit near 0.5. Your own
92
+ concept gets the same pair. This is agreement between two models, not ground truth.
93
+ - The 3D DINO code is the organisers' own (dinovol, at a pinned commit) and reproduces their napari inspector's patch
94
+ embeddings to a cosine of 0.9999 on a Paris 4 cube.
95
+ - Weights are downloaded at pinned revisions and refused unless their SHA-256 matches; the model code is downloaded
96
+ at pinned commits. The receipt records all of it, plus the device, timings and the cube's place in the scan.
97
+
98
+ ## For scripts and agents
99
+
100
+ The `look` endpoint returns the same `.npz` for any listed point:
101
+
102
+ ```python
103
+ from gradio_client import Client
104
+ c = Client("SharpMeow/scroll-lens", token="hf_...") # your token: GPU time counts against your quota
105
+ path = c.predict("PHerc1667", 15108, 10100, 5532, # scan, z, y, x
106
+ 256, "dino,ink,surface", # cube side, models
107
+ False, False, False, # mirror averaging, compact, allow the CPU fallback
108
+ api_name="/look")
109
+ ```
110
+
111
+ Read it with `lens.load_npz(path)` from this Space's `lens.py`: `grid` is (Z/8, Y/8, X/8, 864) float16 unit
112
+ embeddings (or `grid_reduced` and `grid_basis` when compact), `ink` and `surface` are (Z, Y, X) uint8 probabilities
113
+ times 255, and `receipt` is the JSON receipt. The scan itself is not included; read it from the bucket with
114
+ `lens.read_cube(volume, receipt["cube_origin_zyx"], receipt["cube_size"])`.
115
+
116
+ `lens.py` also runs on any computer: `python3 lens.py look PHerc0139 47316 9452 12796 --size 256` (CUDA if present).
117
+
118
+ ## Credits and licences
119
+
120
+ Code: Apache 2.0 (see `LICENSE` and `NOTICE`), by Chase Hendrick. Models and their code: MIT, by the Vesuvius
121
+ Challenge (dinovol includes parts under the DINOv2 Apache 2.0 licence and the DINOv3 License Agreement). Scan data:
122
+ CC BY-NC 4.0, Giorgio Angelotti, Stephen Parsons, Sean Johnson, Elian Rafael Dal Prà, Johannes Rudolph, Paul
123
+ Tafforeau, Alessandro Mirone, Paul Henderson, Hendrik Schilling, Forrest McDonald, David Josey, Youssef Nader,
124
+ C. Seth Parker, W. Brent Seales, *Vesuvius Challenge: CT Scans of Herculaneum Papyri*. Gallery cubes are model output
125
+ derived from that data (CC BY-NC 4.0) and hold no scan voxels.
126
+
127
+ Source and tests: [github.com/ChaseHendrick/Scrolls](https://github.com/ChaseHendrick/Scrolls), `spaces/scroll-lens/`.
app.py ADDED
@@ -0,0 +1,745 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scroll Lens: look inside a Herculaneum scroll scan with the Vesuvius Challenge's 3D models.
2
+
3
+ The web page. Everything that is not the page (reading the scans, the models, clicks and scores) is in lens.py.
4
+ On a ZeroGPU Space the GPU is attached only to `gpu_run`; when the visitor's GPU quota is used up, or no GPU is free,
5
+ the same models run on the Space's CPU on a smaller cube instead. Gallery cubes need no GPU at all.
6
+
7
+ python3 app.py serve the page (on a Space, or on any computer: it then runs on CUDA if present, else CPU)
8
+ """
9
+
10
+ import functools
11
+ import json
12
+ import os
13
+ import sys
14
+ import tempfile
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+
19
+ try:
20
+ import spaces # first, before torch: on a ZeroGPU Space it attaches the GPU to spaces.GPU functions
21
+ except ImportError:
22
+ spaces = None
23
+
24
+ HERE = Path(__file__).resolve().parent
25
+ if str(HERE) not in sys.path:
26
+ sys.path.insert(0, str(HERE))
27
+ import lens # noqa: E402
28
+ from lens import LensError # noqa: E402
29
+
30
+ ON_ZERO_GPU = bool(spaces) and os.environ.get("SPACES_ZERO_GPU", "").lower() in ("1", "true")
31
+ OUT = Path(tempfile.gettempdir()) / "scroll-lens-out"
32
+ CPU_SIZE = 128 # the cube side the CPU fallback runs
33
+ STATE_TTL_S = 30 * 60 # a visitor's cube is dropped from memory after this long without use
34
+ ENGINE = None
35
+ ENGINE_ERROR = None
36
+ READY = threading.Event()
37
+ REFERENCE = None # the organisers' reference ink embedding, loaded before the models
38
+ GALLERY = []
39
+
40
+ OVERLAYS = ("Ink (3D ink model)", "Recto surface", "Your clicks", "Organisers' ink reference", "DINO colours")
41
+ CLICK_MODES = ("More like this", "Not this", "Move the slices here")
42
+ VIEW_NAMES = ("Top view (z)", "Front view (y)", "Side view (x)")
43
+ COLOURS = {"ink": (235, 64, 52), "surface": (64, 200, 96), "clicks": (40, 220, 235), "reference": (250, 180, 30)}
44
+ MARKS = {"pos": (255, 255, 255), "neg": (255, 70, 200)} # crosses where you clicked: More like this, Not this
45
+
46
+
47
+ def log(message):
48
+ lens.log(message)
49
+
50
+
51
+ # ---------------------------------------------------------------- start-up
52
+
53
+ def _load_engine():
54
+ """Load the models in the background, so the page and the gallery work while they download."""
55
+ global ENGINE, ENGINE_ERROR
56
+ try:
57
+ ENGINE = lens.Engine()
58
+ log(f"models ready in {ENGINE.startup_s} s")
59
+ except Exception as exc: # noqa: BLE001 (shown to visitors who try to run a model)
60
+ ENGINE_ERROR = f"{type(exc).__name__}: {exc}"
61
+ log(f"models failed to load: {ENGINE_ERROR}")
62
+ finally:
63
+ READY.set()
64
+
65
+
66
+ def startup():
67
+ global REFERENCE, GALLERY
68
+ import numpy as np
69
+ REFERENCE = lens.unit(np.load(lens.ensure_weights(names=("ink_reference",))["ink_reference"],
70
+ allow_pickle=False).astype(np.float32))
71
+ GALLERY = lens.gallery()
72
+ log(f"{len(GALLERY)} gallery cubes; ZeroGPU {'on' if ON_ZERO_GPU else 'off'}")
73
+ threading.Thread(target=_load_engine, name="load-models", daemon=True).start()
74
+
75
+
76
+ def engine(wait_s=5.0):
77
+ if not READY.wait(wait_s):
78
+ raise LensError("the models are still loading (about a minute after the Space wakes up); try again shortly. "
79
+ "The gallery works meanwhile.")
80
+ if ENGINE is None:
81
+ raise LensError(f"the models failed to load: {ENGINE_ERROR}")
82
+ return ENGINE
83
+
84
+
85
+ # ---------------------------------------------------------------- the one GPU call
86
+
87
+ def _gpu_duration(cube, layers, tta):
88
+ return lens.gpu_seconds(int(cube.shape[0]), layers, tta)
89
+
90
+
91
+ def _on_gpu(fn):
92
+ return spaces.GPU(duration=_gpu_duration)(fn) if ON_ZERO_GPU else fn
93
+
94
+
95
+ @_on_gpu
96
+ def gpu_run(cube, layers, tta):
97
+ """The chosen models on one cube, on the GPU ZeroGPU attaches for this call."""
98
+ return ENGINE.run(cube, layers, device="cuda", tta=tta)
99
+
100
+
101
+ def _is_gpu_refusal(exc):
102
+ text = f"{type(exc).__name__} {exc} {getattr(exc, 'title', '')}".lower()
103
+ return any(k in text for k in ("quota", "zerogpu", "no gpu", "gpu task aborted", "gpu is not available"))
104
+
105
+
106
+ def run_models(cube, layers, tta, allow_cpu=True):
107
+ """Run on the GPU when there is one; on ZeroGPU fall back to the CPU when the GPU is refused. Returns the result
108
+ and a note for the visitor (empty when it ran as asked)."""
109
+ import torch
110
+ eng = engine()
111
+ if ON_ZERO_GPU:
112
+ try:
113
+ return gpu_run(cube, tuple(layers), bool(tta)), ""
114
+ except Exception as exc: # noqa: BLE001
115
+ if not (allow_cpu and _is_gpu_refusal(exc)):
116
+ raise
117
+ log(f"GPU refused ({exc}); running on the CPU")
118
+ note = (f"Ran on the Space's CPU, because ZeroGPU said: {str(exc).strip()[:240]} "
119
+ "Your GPU minutes come back each day; signing in to Hugging Face gives more.")
120
+ return eng.run(_centre(cube, CPU_SIZE), layers, device="cpu", tta=False), note
121
+ device = "cuda" if torch.cuda.is_available() else "cpu"
122
+ if device == "cpu" and cube.shape[0] > CPU_SIZE:
123
+ return (eng.run(_centre(cube, CPU_SIZE), layers, device="cpu", tta=False),
124
+ f"No GPU here, so the models ran on the CPU on the middle {CPU_SIZE}³ voxels.")
125
+ return eng.run(cube, layers, device=device, tta=tta), ""
126
+
127
+
128
+ def _centre(cube, size):
129
+ if cube.shape[0] <= size:
130
+ return cube
131
+ o = (cube.shape[0] - size) // 2
132
+ return cube[o:o + size, o:o + size, o:o + size]
133
+
134
+
135
+ # ---------------------------------------------------------------- one cube in memory
136
+
137
+ def new_view(vid, origin, size, point, cube, result, source, note=""):
138
+ """Everything the page needs about the cube on screen. Arrays only; it lives in the visitor's session."""
139
+ import numpy as np
140
+ if cube.shape[0] != size: # the CPU fallback ran a smaller cube at the centre
141
+ o = (size - cube.shape[0]) // 2
142
+ origin = tuple(int(v + o) for v in origin)
143
+ size = int(cube.shape[0])
144
+ nz = cube[cube > 0]
145
+ lo, hi = (np.percentile(nz, (0.5, 99.5)) if nz.size else (0, 255))
146
+ view = {"volume": vid, "origin": tuple(int(v) for v in origin), "size": int(size),
147
+ "point": tuple(int(v) for v in point), "cube": cube, "lo": float(lo), "hi": float(max(hi, lo + 1)),
148
+ "source": source, "note": note, "result": result, "pca": None, "ref_sim": None,
149
+ "foreground": lens.foreground(cube)}
150
+ if "grid" in result or "grid_reduced" in result:
151
+ view["grid"] = lens.grid_of(result).astype(np.float16)
152
+ view["ref_sim"] = lens.similarity(view["grid"].astype(np.float32), REFERENCE)
153
+ view["slices"] = [int(min(max(p - o, 0), size - 1)) for p, o in zip(point, view["origin"])]
154
+ view["receipt"] = lens.receipt(vid, view["origin"], size, point, result,
155
+ ENGINE.describe() if ENGINE else None,
156
+ {"source": source, "foreground": round(view["foreground"], 4)})
157
+ if "ink" in result and view.get("ref_sim") is not None:
158
+ view["receipt"]["agreement_with_ink_model"] = lens.agreement(view["ref_sim"], result["ink"])
159
+ return view
160
+
161
+
162
+ def empty_state():
163
+ return {"view": None, "pos": [], "neg": []}
164
+
165
+
166
+ def click_concept(state):
167
+ """The visitor's concept: unit mean of the positive clicks' embeddings, and of the negative ones (or None)."""
168
+ import numpy as np
169
+ pos = [c["vec"] for c in state["pos"]]
170
+ neg = [c["vec"] for c in state["neg"]]
171
+ p = lens.unit(np.mean(pos, 0)) if pos else None
172
+ n = lens.unit(np.mean(neg, 0)) if neg else None
173
+ return p, n
174
+
175
+
176
+ def click_sim(state):
177
+ view = state["view"]
178
+ p, n = click_concept(state)
179
+ if view is None or p is None or "grid" not in view:
180
+ return None
181
+ import numpy as np
182
+ return lens.similarity(view["grid"].astype(np.float32), p, n)
183
+
184
+
185
+ # ---------------------------------------------------------------- drawing
186
+
187
+ def _take(a, axis, i):
188
+ return a[i] if axis == 0 else (a[:, i] if axis == 1 else a[:, :, i])
189
+
190
+
191
+ def _top_alpha(sim2d, sim3d, top_pct):
192
+ """Opacity of each pixel of a similarity slice: 0 below the cube's top `top_pct` percent, rising to 1 at its best."""
193
+ import numpy as np
194
+ thr = float(np.percentile(sim3d, 100 - top_pct))
195
+ best = float(sim3d.max())
196
+ return np.clip((sim2d - thr) / max(best - thr, 1e-6), 0, 1) * (sim2d >= thr)
197
+
198
+
199
+ def draw_slice(state, axis, overlays, top_pct, opacity):
200
+ """One slice through the cube as an RGB image, scaled up so a 128 or 256 cube shows at 512 px."""
201
+ import numpy as np
202
+ view = state["view"]
203
+ if view is None:
204
+ return None
205
+ size, cube, idx = view["size"], view["cube"], view["slices"][axis]
206
+ ct = _take(cube, axis, idx).astype(np.float32)
207
+ g = np.clip((ct - view["lo"]) / (view["hi"] - view["lo"]), 0, 1)
208
+ rgb = np.repeat(g[..., None], 3, axis=2)
209
+ res = view["result"]
210
+ tok_i = idx // lens.PATCH
211
+
212
+ def up(t2):
213
+ return np.repeat(np.repeat(t2, lens.PATCH, 0), lens.PATCH, 1)
214
+
215
+ def blend(colour, alpha):
216
+ a = (np.asarray(alpha, np.float32) * opacity)[..., None]
217
+ rgb[:] = rgb * (1 - a) + (np.asarray(colour, np.float32) / 255.0) * a
218
+
219
+ if "DINO colours" in overlays and "grid" in view:
220
+ if view["pca"] is None:
221
+ view["pca"] = lens.pca_colours(view["grid"].astype(np.float32))
222
+ pc = up(_take(view["pca"], axis, tok_i)).astype(np.float32) / 255.0
223
+ rgb[:] = rgb * (1 - 0.6 * opacity) + pc * (0.6 * opacity)
224
+ if "Recto surface" in overlays and "surface" in res:
225
+ blend(COLOURS["surface"], _take(res["surface"], axis, idx) / 255.0)
226
+ if "Ink (3D ink model)" in overlays and "ink" in res:
227
+ blend(COLOURS["ink"], _take(res["ink"], axis, idx) / 255.0)
228
+ if "Organisers' ink reference" in overlays and view.get("ref_sim") is not None:
229
+ s3 = view["ref_sim"]
230
+ blend(COLOURS["reference"], up(_top_alpha(_take(s3, axis, tok_i), s3, top_pct)))
231
+ if "Your clicks" in overlays:
232
+ s3 = click_sim(state)
233
+ if s3 is not None:
234
+ blend(COLOURS["clicks"], up(_top_alpha(_take(s3, axis, tok_i), s3, top_pct)))
235
+ img = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
236
+ # where the other two slices cut this one: thin guide lines
237
+ others = [a for a in range(3) if a != axis]
238
+ r, c = view["slices"][others[0]], view["slices"][others[1]]
239
+ f = scale_of(size)
240
+ img = np.repeat(np.repeat(img, f, 0), f, 1)
241
+ guide = np.array([255, 255, 255], np.uint8)
242
+ img[r * f + f // 2, :: 3] = guide
243
+ img[:: 3, c * f + f // 2] = guide
244
+ # the clicks made in this cube that lie in this slice
245
+ for kind, colour in (("pos", MARKS["pos"]), ("neg", MARKS["neg"])):
246
+ for ck in state[kind]:
247
+ if ck.get("cube") != cube_key(view):
248
+ continue
249
+ v = ck["voxel"]
250
+ if v[axis] // lens.PATCH != tok_i:
251
+ continue
252
+ rr, cc = v[others[0]] * f, v[others[1]] * f
253
+ _mark(img, rr + f // 2, cc + f // 2, colour, 5 * f // 2 + 3)
254
+ return img
255
+
256
+
257
+ def _mark(img, r, c, colour, half):
258
+ """A cross two pixels thick, drawn in place."""
259
+ h, w = img.shape[:2]
260
+ for d in range(-half, half + 1):
261
+ for t in (0, 1):
262
+ for (y, x) in ((r + d, c + t), (r + t, c + d)):
263
+ if 0 <= y < h and 0 <= x < w:
264
+ img[y, x] = colour
265
+
266
+
267
+ def scale_of(size):
268
+ return max(1, 512 // int(size))
269
+
270
+
271
+ def cube_key(view):
272
+ return f"{view['volume']}:{','.join(map(str, view['origin']))}:{view['size']}"
273
+
274
+
275
+ def numbers(state, top_pct):
276
+ """The text beside the images: what this cube is, what ran, and the checks."""
277
+ import numpy as np
278
+ view = state["view"]
279
+ if view is None:
280
+ return ("Pick a ready-made cube from the gallery, or a point in a scan, then press **Run the models**. "
281
+ "Click the images to find similar places.")
282
+ v = lens.volume(view["volume"])
283
+ r = view["receipt"]
284
+ um = v["voxel_um"] * view["size"]
285
+ lines = [f"**{v['scroll']}**, cube of {view['size']}³ voxels ({um / 1000:.2f} mm a side) at "
286
+ f"z {view['origin'][0]}, y {view['origin'][1]}, x {view['origin'][2]}. "
287
+ f"{view['foreground']:.0%} of it lies inside the scan's scroll mask."]
288
+ src = {"gallery": "Gallery cube: computed earlier, no GPU used now.", "gpu": "Ran now on the GPU.",
289
+ "cpu": "Ran now on the CPU.", "file": "Loaded from your file."}[view["source"]]
290
+ secs = ", ".join(f"{k} {s:.1f} s" for k, s in r.get("seconds", {}).items())
291
+ lines.append(f"{src} {('Times: ' + secs + '.') if secs else ''} {view['note']}".strip())
292
+ lines.append("**What each model saw of this scroll in training.** " + " ".join(
293
+ f"{lens.LAYER_TITLES[k]}: {v['role'][k]}" for k in lens.LAYERS))
294
+ ag = r.get("agreement_with_ink_model")
295
+ if ag and ag["auc"] is not None:
296
+ lines.append(f"**Check.** The organisers' ink reference picks out the patches the 3D ink model marks as ink "
297
+ f"with AUC {ag['auc']:.2f}; the control (ink mask moved half a cube) gives "
298
+ f"{ag['auc_control_shifted']:.2f} and should sit near 0.5. Ink covers "
299
+ f"{ag['ink_patch_fraction']:.1%} of patches. Agreement between two models, not ground truth.")
300
+ if view.get("ref_sim") is not None:
301
+ lines.append(f"Patches at or above the organisers' threshold (cosine {lens.TAU}) to their reference: "
302
+ f"{float((view['ref_sim'] >= lens.TAU).mean()):.1%}.")
303
+ s = click_sim(state)
304
+ if s is not None:
305
+ extra = ""
306
+ if "ink" in view["result"]:
307
+ a = lens.agreement(s, view["result"]["ink"])
308
+ if a["auc"] is not None:
309
+ extra = (f" Against the 3D ink model your concept scores AUC {a['auc']:.2f} "
310
+ f"(control {a['auc_control_shifted']:.2f}).")
311
+ lines.append(f"**Your concept:** {len(state['pos'])} positive and {len(state['neg'])} negative clicks"
312
+ f" from {len({c['cube'] for c in state['pos'] + state['neg']})} cube(s). Showing its top "
313
+ f"{top_pct:g}% of patches.{extra}")
314
+ lines.append("_Model output, not a reading. A cube is smaller than one letter._")
315
+ return "\n\n".join(lines)
316
+
317
+
318
+ def figure_3d(state, overlays, top_pct):
319
+ """Ink and surface as see-through shells and the similar patches as points, in a rotatable 3D plot."""
320
+ import numpy as np
321
+ import plotly.graph_objects as go
322
+ view = state["view"]
323
+ fig = go.Figure()
324
+ if view is None:
325
+ return fig
326
+ from skimage.measure import marching_cubes
327
+ res, size = view["result"], view["size"]
328
+ step = 2 if size <= 128 else 4
329
+ for key, name, colour, show in (("surface", "Recto surface", COLOURS["surface"], "Recto surface" in overlays),
330
+ ("ink", "Ink", COLOURS["ink"], "Ink (3D ink model)" in overlays)):
331
+ if not show or key not in res or res[key].max() < 128:
332
+ continue
333
+ verts, faces, _, _ = marching_cubes(res[key].astype(np.float32), level=127.5, step_size=step)
334
+ fig.add_trace(go.Mesh3d(x=verts[:, 2], y=verts[:, 1], z=verts[:, 0], i=faces[:, 0], j=faces[:, 1],
335
+ k=faces[:, 2], color=f"rgb{colour}", opacity=0.35 if key == "surface" else 0.8,
336
+ name=name, showlegend=True, hoverinfo="skip"))
337
+ for key, name, colour in (("clicks", "Your clicks", COLOURS["clicks"]),
338
+ ("reference", "Organisers' ink reference", COLOURS["reference"])):
339
+ if (key == "clicks" and "Your clicks" not in overlays) or \
340
+ (key == "reference" and "Organisers' ink reference" not in overlays):
341
+ continue
342
+ s3 = click_sim(state) if key == "clicks" else view.get("ref_sim")
343
+ if s3 is None:
344
+ continue
345
+ thr = np.percentile(s3, 100 - top_pct)
346
+ pts = np.argwhere(s3 >= thr) * lens.PATCH + lens.PATCH / 2
347
+ fig.add_trace(go.Scatter3d(x=pts[:, 2], y=pts[:, 1], z=pts[:, 0], mode="markers", name=name,
348
+ marker=dict(size=2.5, color=f"rgb{colour}", opacity=0.7), hoverinfo="skip"))
349
+ ax = dict(range=[0, size], showbackground=False, gridcolor="#3a4654", zerolinecolor="#3a4654",
350
+ color="#c9d4de")
351
+ fig.update_layout(scene=dict(xaxis=dict(ax, title="x"), yaxis=dict(ax, title="y"), zaxis=dict(ax, title="z"),
352
+ aspectmode="cube", bgcolor="#11161c"),
353
+ paper_bgcolor="#11161c", font=dict(color="#c9d4de"), height=460,
354
+ margin=dict(l=0, r=0, t=10, b=0), legend=dict(orientation="h", y=0.02))
355
+ return fig
356
+
357
+
358
+ # ---------------------------------------------------------------- loading cubes
359
+
360
+ def load_point(vid, z, y, x, size, layers, tta):
361
+ import numpy as np
362
+ size = lens.check_size(size)
363
+ layers = lens.check_layers([{"3D DINO embeddings": "dino", "3D ink model": "ink",
364
+ "Recto surface model": "surface"}.get(k, k) for k in layers])
365
+ point = (int(z), int(y), int(x))
366
+ origin = lens.cube_origin(vid, point, size)
367
+ cube = lens.read_cube(vid, origin, size)
368
+ lens.check_cube(cube)
369
+ result, note = run_models(cube, layers, tta)
370
+ src = "gpu" if result.get("device", "").startswith("cuda") else "cpu"
371
+ ran = _centre(cube, ran_size(result)) # the CPU fallback runs the middle of the cube
372
+ return new_view(vid, origin, size, point, np.ascontiguousarray(ran), result, src, note)
373
+
374
+
375
+ def ran_size(result):
376
+ """The side of the cube a result covers, from whichever layers it holds."""
377
+ for k in ("ink", "surface"):
378
+ if k in result:
379
+ return int(result[k].shape[0])
380
+ return int(lens.grid_of(result).shape[0] * lens.PATCH)
381
+
382
+
383
+ def load_gallery(cube_id):
384
+ for it in GALLERY:
385
+ if it["id"] == cube_id:
386
+ meta, arrays = lens.load_npz(HERE / "gallery" / it["file"])
387
+ origin, size = tuple(meta["cube_origin_zyx"]), int(meta["cube_size"])
388
+ cube = lens.read_cube(meta["volume"], origin, size)
389
+ result = dict(arrays, seconds=meta.get("seconds", {}), device=meta.get("device"),
390
+ device_name=meta.get("device_name"), tta=meta.get("tta", False))
391
+ view = new_view(meta["volume"], origin, size, tuple(meta["point_zyx"]), cube, result, "gallery",
392
+ it.get("note", ""))
393
+ view["receipt"]["gallery_id"] = cube_id
394
+ return view
395
+ raise LensError(f"no gallery cube {cube_id!r}")
396
+
397
+
398
+ def surprise(vid, seed=None):
399
+ import numpy as np
400
+ rng = np.random.default_rng(seed)
401
+ if vid == "PHercParis4":
402
+ return lens.PARIS4_TRAINING_POINTS[int(rng.integers(0, len(lens.PARIS4_TRAINING_POINTS)))]
403
+ return lens.random_point(vid, rng)
404
+
405
+
406
+ # ---------------------------------------------------------------- files to take away
407
+
408
+ def save_files(state):
409
+ import numpy as np
410
+ view = state["view"]
411
+ if view is None:
412
+ raise LensError("nothing to save yet")
413
+ OUT.mkdir(parents=True, exist_ok=True)
414
+ d = Path(tempfile.mkdtemp(dir=OUT))
415
+ name = f"scroll-lens-{view['volume']}-{'-'.join(map(str, view['origin']))}-{view['size']}"
416
+ files = [lens.save_npz(d / f"{name}.npz", view["receipt"], view["result"])]
417
+ rec = d / f"{name}.json"
418
+ rec.write_text(json.dumps(view["receipt"], indent=1) + "\n")
419
+ files.append(rec)
420
+ p, n = click_concept(state)
421
+ if p is not None:
422
+ np.save(d / "concept.npy", p.astype(np.float32))
423
+ files.append(d / "concept.npy")
424
+ clicks = {"tool": "Scroll Lens", "version": lens.VERSION, "embedding": lens.MODELS["dino"]["repo"],
425
+ "positive": [{k: c[k] for k in ("volume", "cube", "voxel_zyx")} for c in state["pos"]],
426
+ "negative": [{k: c[k] for k in ("volume", "cube", "voxel_zyx")} for c in state["neg"]],
427
+ "note": "concept.npy is the unit-length mean of the positive clicks' 864-number dinovol embeddings"}
428
+ (d / "clicks.json").write_text(json.dumps(clicks, indent=1) + "\n")
429
+ files.append(d / "clicks.json")
430
+ return [str(f) for f in files]
431
+
432
+
433
+ def api_look(vid, z, y, x, size, layers, tta, compact, allow_cpu):
434
+ """For scripts and agents: run the models on one cube and return its .npz (see lens.save_npz)."""
435
+ import numpy as np
436
+ size = lens.check_size(size)
437
+ layers = lens.check_layers([s.strip() for s in str(layers).split(",") if s.strip()])
438
+ point = (int(z), int(y), int(x))
439
+ origin = lens.cube_origin(vid, point, size)
440
+ cube = lens.read_cube(vid, origin, size)
441
+ lens.check_cube(cube)
442
+ result, note = run_models(cube, layers, bool(tta), allow_cpu=bool(allow_cpu))
443
+ n = ran_size(result)
444
+ if n != size:
445
+ origin = tuple(int(o + (size - n) // 2) for o in origin)
446
+ meta = lens.receipt(vid, origin, n, point, result, ENGINE.describe(), {"source": "api", "note_run": note,
447
+ "foreground": lens.foreground(cube)})
448
+ if "grid" in result and "ink" in result:
449
+ meta["agreement_with_ink_model"] = lens.agreement(
450
+ lens.similarity(np.asarray(result["grid"], np.float32), REFERENCE), result["ink"])
451
+ OUT.mkdir(parents=True, exist_ok=True)
452
+ d = Path(tempfile.mkdtemp(dir=OUT))
453
+ return str(lens.save_npz(d / f"{vid}-{'-'.join(map(str, origin))}-{n}.npz", meta, result, compact=bool(compact)))
454
+
455
+
456
+ # ---------------------------------------------------------------- the page
457
+
458
+ INTRO = """\
459
+ **Scroll Lens** shows what three of the Vesuvius Challenge's newest 3D models see inside a carbonized Herculaneum scroll.
460
+ It reads a small cube of a public X-ray CT scan straight from the open-data bucket and runs, on that cube:
461
+
462
+ - **3D DINO embeddings** ([dinovol](https://huggingface.co/scrollprize/dinovol_v2_ps8_with_paris4_352500)): a
463
+ self-supervised model that turns every 8 × 8 × 8 voxel patch into 864 numbers. Patches the model finds alike get
464
+ alike numbers. *DINO colours* paints the three main directions as red, green and blue.
465
+ - **3D ink model** ([ink_3d_dino_guided](https://huggingface.co/scrollprize/ink_3d_dino_guided)): marks ink voxels
466
+ directly in the scan, with no flattened surface.
467
+ - **Recto surface model** ([surface_recto_3dunet](https://huggingface.co/scrollprize/surface_recto_3dunet)): marks
468
+ the written side of each papyrus sheet.
469
+
470
+ **Click to search.** With *More like this*, a click takes the embedding of the patch you clicked; every patch with a
471
+ similar embedding lights up in cyan. *Not this* subtracts a patch you do not want. This is how the organisers built
472
+ their ink reference (*Organisers' ink reference*, in amber): experts clicked ink 256 times and averaged the
473
+ embeddings. Your concept follows you to other cubes and scans, so you can click ink in one scroll and look for its
474
+ like in another, and you can download it as `concept.npy`.
475
+
476
+ All three models come from the PHerc. Paris 4 work of the Vesuvius Challenge team
477
+ ([Angelotti et al. 2026](https://arxiv.org/abs/2606.29085)). Scroll Lens reads only the twelve 2.2 to 2.4 µm scans
478
+ those models were made for, none of them eligible for the First Letters or Grand Prize. **Everything shown is model
479
+ output, not a reading:** a cube is at most 0.6 mm across, smaller than one letter.
480
+ """
481
+
482
+ HOW_TO = """\
483
+ 1. **Gallery** (no GPU needed): pick a ready-made cube and it loads at once.
484
+ 2. **Your own point**: pick a scan, type a point in voxels (z, y, x) or press **Surprise me**, then **Run the models**.
485
+ A 256³ cube is the models' own working size and uses about half a minute of GPU time; 128³ about a quarter.
486
+ When your daily ZeroGPU time runs out, Scroll Lens runs a 128³ cube on the CPU instead (slower, about a minute).
487
+ 3. **Look**: drag the three sliders to move through the cube; tick the layers to show. The white dotted lines are
488
+ where the other two views cut this one.
489
+ 4. **Search**: choose a click mode, then click a view. Raise or lower *Top % shown* to see more or fewer matches.
490
+ 5. **Take it away**: **Save files** gives the model outputs (.npz), the receipt (.json) and, after clicks, your
491
+ `concept.npy`. A link with `?v=SCAN&z=…&y=…&x=…` opens the page on a point; `?cube=ID` on a gallery cube.
492
+
493
+ Scripts can call the `look` endpoint (`gradio_client`, see the README) to get the same .npz for any listed point.
494
+ """
495
+
496
+ CSS = """
497
+ #sl-views img { image-rendering: pixelated; cursor: crosshair; }
498
+ .sl-head { display:flex; gap:14px; align-items:baseline; flex-wrap:wrap; margin: 4px 0 2px; }
499
+ .sl-head h1 { margin:0; font-size: 1.6rem; letter-spacing: .01em; }
500
+ .sl-head span { color: var(--body-text-color-subdued); }
501
+ .sl-legend { display:flex; flex-wrap:wrap; gap:10px 16px; font-size:.86rem; margin: 2px 0 4px; }
502
+ .sl-legend i { display:inline-block; width:11px; height:11px; border-radius:2px; margin-right:5px; vertical-align:-1px; }
503
+ @media (max-width: 720px) { #sl-viewer { order: -1; } } /* phones: the images first, the controls below */
504
+ footer.sl-credit { text-align:center; color: var(--body-text-color-subdued); font-size:.85rem; margin-top: 8px; }
505
+ """
506
+
507
+ LEGEND = ('<div class="sl-legend">' + "".join(
508
+ f'<span><i style="background:rgb{c}"></i>{t}</span>' for t, c in (
509
+ ("Ink, 3D ink model", COLOURS["ink"]), ("Recto surface", COLOURS["surface"]),
510
+ ("Like your clicks", COLOURS["clicks"]), ("Like the organisers' ink reference", COLOURS["reference"]),
511
+ ("Your clicks: white +, Not this: magenta +", MARKS["neg"]))) + "</div>")
512
+
513
+
514
+ def theme():
515
+ import gradio as gr
516
+ return gr.themes.Base(primary_hue="amber", neutral_hue="slate", radius_size="sm",
517
+ font=("system-ui", "sans-serif"), font_mono=("ui-monospace", "monospace"))
518
+
519
+
520
+ def build_demo():
521
+ import gradio as gr
522
+
523
+ vol_choices = [(f"{v['scroll']} ({vid})", vid) for vid, v in lens.VOLUMES.items()]
524
+ gal_choices = [(it["title"], it["id"]) for it in GALLERY]
525
+ layer_names = [lens.LAYER_TITLES[k] for k in lens.LAYERS]
526
+
527
+ def volume_text(vid):
528
+ v = lens.volume(vid)
529
+ z, y, x = v["shape"]
530
+ return (f"{v['note']} Scan: {v['voxel_um']} µm voxels, {z:,} × {y:,} × {x:,} (z, y, x). "
531
+ f"[Open data]({lens.BUCKET}/{vid}/volumes/{v['scan']}/)").strip()
532
+
533
+ def show(state, overlays, top_pct, opacity):
534
+ return ([draw_slice(state, a, overlays, top_pct, opacity) for a in range(3)]
535
+ + [numbers(state, top_pct)])
536
+
537
+ def slider_updates(state):
538
+ view = state["view"]
539
+ if view is None:
540
+ return [gr.update()] * 3
541
+ return [gr.update(maximum=view["size"] - 1, value=view["slices"][a]) for a in range(3)]
542
+
543
+ def guard(fn):
544
+ @functools.wraps(fn) # keeps fn's signature, so gradio still passes SelectData and Request
545
+ def wrapped(*args, **kwargs):
546
+ try:
547
+ return fn(*args, **kwargs)
548
+ except LensError as exc:
549
+ raise gr.Error(str(exc)) from None
550
+ return wrapped
551
+
552
+ @guard
553
+ def on_run(vid, z, y, x, size, layers, tta, state, overlays, top_pct, opacity):
554
+ state = dict(state)
555
+ state["view"] = load_point(vid, z, y, x, size, layers, tta)
556
+ return [state] + slider_updates(state) + show(state, overlays, top_pct, opacity)
557
+
558
+ @guard
559
+ def on_gallery(cube_id, state, overlays, top_pct, opacity):
560
+ if not cube_id:
561
+ raise LensError("pick a gallery cube")
562
+ state = dict(state)
563
+ state["view"] = load_gallery(cube_id)
564
+ p = state["view"]["point"]
565
+ return ([state] + slider_updates(state) + show(state, overlays, top_pct, opacity)
566
+ + [state["view"]["volume"], p[0], p[1], p[2]])
567
+
568
+ @guard
569
+ def on_surprise(vid):
570
+ z, y, x = surprise(vid)
571
+ return z, y, x
572
+
573
+ @guard
574
+ def on_slices(sz, sy, sx, state, overlays, top_pct, opacity):
575
+ if state["view"] is not None:
576
+ state["view"]["slices"] = [int(sz), int(sy), int(sx)]
577
+ return show(state, overlays, top_pct, opacity)
578
+
579
+ def on_select(axis):
580
+ @guard
581
+ def handler(mode, state, overlays, top_pct, opacity, evt: gr.SelectData):
582
+ view = state["view"]
583
+ if view is None or evt.index is None:
584
+ return [state] + slider_updates(state) + show(state, overlays, top_pct, opacity) + [overlays]
585
+ f = scale_of(view["size"])
586
+ col, row = int(evt.index[0]) // f, int(evt.index[1]) // f
587
+ others = [a for a in range(3) if a != axis]
588
+ vox = [0, 0, 0]
589
+ vox[axis] = view["slices"][axis]
590
+ vox[others[0]], vox[others[1]] = row, col
591
+ vox = [int(min(max(v, 0), view["size"] - 1)) for v in vox]
592
+ if mode == CLICK_MODES[2]:
593
+ view["slices"] = vox
594
+ return [state] + slider_updates(state) + show(state, overlays, top_pct, opacity) + [overlays]
595
+ if "grid" not in view:
596
+ raise LensError("this cube has no DINO embeddings; run it with the 3D DINO model to search by "
597
+ "clicking")
598
+ tok = lens.token_of(vox, view["size"])
599
+ vec = view["grid"][tok].astype("float32")
600
+ kind = "pos" if mode == CLICK_MODES[0] else "neg"
601
+ state[kind] = state[kind] + [{"volume": view["volume"], "cube": cube_key(view), "voxel": vox,
602
+ "voxel_zyx": [int(v + o) for v, o in zip(vox, view["origin"])],
603
+ "vec": vec}]
604
+ if "Your clicks" not in overlays:
605
+ overlays = list(overlays) + ["Your clicks"]
606
+ return [state] + slider_updates(state) + show(state, overlays, top_pct, opacity) + [overlays]
607
+ return handler
608
+
609
+ @guard
610
+ def on_clear(state, overlays, top_pct, opacity):
611
+ state = dict(state, pos=[], neg=[])
612
+ return [state] + show(state, overlays, top_pct, opacity)
613
+
614
+ @guard
615
+ def on_concept(path, state, overlays, top_pct, opacity):
616
+ if not path:
617
+ return [state] + show(state, overlays, top_pct, opacity) + [overlays]
618
+ vec = lens.load_concept(path)
619
+ state = dict(state, pos=[{"volume": "file", "cube": "file", "voxel": [-1, -1, -1], "voxel_zyx": None,
620
+ "vec": vec}], neg=[])
621
+ if "Your clicks" not in overlays:
622
+ overlays = list(overlays) + ["Your clicks"]
623
+ return [state] + show(state, overlays, top_pct, opacity) + [overlays]
624
+
625
+ @guard
626
+ def on_save(state):
627
+ return save_files(state), (state["view"] or {}).get("receipt")
628
+
629
+ def on_load(state, overlays, top_pct, opacity, request: gr.Request):
630
+ q = dict(request.query_params) if request else {}
631
+ fields = [gr.update()] * 4
632
+ try:
633
+ if q.get("v") in lens.VOLUMES:
634
+ fields = [q["v"]] + [int(q[k]) if str(q.get(k, "")).isdigit() else gr.update() for k in "zyx"]
635
+ cube_id = q.get("cube") or ((not q.get("v")) and GALLERY and GALLERY[0]["id"])
636
+ if cube_id:
637
+ state = dict(state)
638
+ state["view"] = load_gallery(cube_id)
639
+ if not q.get("v"):
640
+ p = state["view"]["point"]
641
+ fields = [state["view"]["volume"], p[0], p[1], p[2]]
642
+ except (LensError, ValueError, OSError) as exc:
643
+ log(f"link not loaded: {exc}")
644
+ size = gr.update(value=int(q["size"])) if q.get("size") in ("128", "256") else gr.update()
645
+ return ([state] + slider_updates(state) + show(state, overlays, top_pct, opacity) + fields + [size]
646
+ + [gr.update(value=state["view"] and state["view"].get("receipt", {}).get("gallery_id"))])
647
+
648
+ with gr.Blocks(title="Scroll Lens", delete_cache=(1800, 3600)) as demo:
649
+ state = gr.State(empty_state(), time_to_live=STATE_TTL_S)
650
+ gr.HTML('<div class="sl-head"><h1>Scroll Lens</h1><span>Look inside a Herculaneum scroll with the '
651
+ 'Vesuvius Challenge’s 3D models: embeddings, ink and surfaces, and click to search.</span></div>')
652
+ with gr.Row(equal_height=False):
653
+ with gr.Column(scale=1, min_width=300, elem_id="sl-controls"):
654
+ with gr.Tab("Gallery"):
655
+ gpick = gr.Dropdown(gal_choices, value=gal_choices[0][1] if gal_choices else None,
656
+ label="Ready-made cube (no GPU needed)", filterable=False)
657
+ gbtn = gr.Button("Load cube", variant="primary")
658
+ if not gal_choices:
659
+ gr.Markdown("The gallery is empty on this copy. Pick a point instead.")
660
+ with gr.Tab("Your own point"):
661
+ vid = gr.Dropdown(vol_choices, value="PHercParis4", label="Scan", filterable=False)
662
+ vinfo = gr.Markdown(volume_text("PHercParis4"))
663
+ with gr.Row():
664
+ z = gr.Number(37628, label="z", precision=0, minimum=0)
665
+ y = gr.Number(8793, label="y", precision=0, minimum=0)
666
+ x = gr.Number(22167, label="x", precision=0, minimum=0)
667
+ size = gr.Radio([128, 256], value=256, label="Cube side, voxels")
668
+ layers = gr.CheckboxGroup(layer_names, value=layer_names, label="Models to run")
669
+ tta = gr.Checkbox(False, label="Average eight mirrorings for ink and surface (slower, smoother)")
670
+ with gr.Row():
671
+ sbtn = gr.Button("Surprise me")
672
+ rbtn = gr.Button("Run the models", variant="primary")
673
+ with gr.Accordion("Show and search", open=True):
674
+ overlays = gr.CheckboxGroup(list(OVERLAYS), value=["Ink (3D ink model)", "Your clicks",
675
+ "Organisers' ink reference"],
676
+ label="Layers to show")
677
+ mode = gr.Radio(list(CLICK_MODES), value=CLICK_MODES[0], label="Clicking a view does")
678
+ top_pct = gr.Slider(1, 40, value=8, step=1, label="Top % of similar patches shown")
679
+ opacity = gr.Slider(0, 1, value=0.7, step=0.05, label="Layer opacity")
680
+ with gr.Row():
681
+ clear = gr.Button("Clear clicks", size="sm")
682
+ concept_up = gr.File(label="Load a concept.npy (864 numbers)", file_types=[".npy"],
683
+ type="filepath", height=90)
684
+ with gr.Accordion("How to use it", open=False):
685
+ gr.Markdown(HOW_TO)
686
+ with gr.Column(scale=3, min_width=320, elem_id="sl-viewer"):
687
+ gr.HTML(LEGEND)
688
+ with gr.Row(elem_id="sl-views"):
689
+ imgs = [gr.Image(label=VIEW_NAMES[a], interactive=False, buttons=["fullscreen"], type="numpy",
690
+ min_width=220)
691
+ for a in range(3)]
692
+ with gr.Row():
693
+ sliders = [gr.Slider(0, 255, value=128, step=1, label=n.split(" (")[1].rstrip(")") + " slice")
694
+ for n in VIEW_NAMES]
695
+ info = gr.Markdown(numbers(empty_state(), 8))
696
+ with gr.Accordion("3D view", open=False):
697
+ plot = gr.Plot(label="Ink and surface shells, similar patches as points")
698
+ pbtn = gr.Button("Draw in 3D", size="sm")
699
+ with gr.Accordion("Save files", open=False):
700
+ save = gr.Button("Save files", size="sm")
701
+ files = gr.File(label="Model outputs, receipt and your concept", file_count="multiple")
702
+ receipt = gr.JSON(label="Receipt")
703
+ with gr.Accordion("About", open=False):
704
+ gr.Markdown(INTRO)
705
+ gr.HTML('<footer class="sl-credit">Built by Chase Hendrick · models and data by the Vesuvius Challenge · '
706
+ f'Scroll Lens {lens.VERSION}</footer>')
707
+
708
+ # hidden: the API endpoint for scripts and agents
709
+ with gr.Row(visible=False):
710
+ a_vid, a_layers = gr.Textbox(), gr.Textbox(value="dino,ink,surface")
711
+ a_z, a_y, a_x, a_size = gr.Number(precision=0), gr.Number(precision=0), gr.Number(precision=0), \
712
+ gr.Number(256, precision=0)
713
+ a_tta, a_compact, a_cpu = gr.Checkbox(False), gr.Checkbox(False), gr.Checkbox(False)
714
+ a_out, a_btn = gr.File(), gr.Button()
715
+
716
+ view_inputs = [overlays, top_pct, opacity]
717
+ shown = imgs + [info]
718
+ vid.change(volume_text, vid, vinfo, api_visibility="private")
719
+ rbtn.click(on_run, [vid, z, y, x, size, layers, tta, state] + view_inputs, [state] + sliders + shown,
720
+ concurrency_id="models", concurrency_limit=2, api_visibility="private")
721
+ gbtn.click(on_gallery, [gpick, state] + view_inputs, [state] + sliders + shown + [vid, z, y, x],
722
+ api_visibility="private")
723
+ sbtn.click(on_surprise, vid, [z, y, x], api_visibility="private")
724
+ for s in sliders:
725
+ s.release(on_slices, sliders + [state] + view_inputs, shown, api_visibility="private", show_progress="hidden")
726
+ for c in view_inputs:
727
+ c.change(on_slices, sliders + [state] + view_inputs, shown, api_visibility="private", show_progress="hidden")
728
+ for a in range(3):
729
+ imgs[a].select(on_select(a), [mode, state] + view_inputs, [state] + sliders + shown + [overlays],
730
+ api_visibility="private", show_progress="hidden")
731
+ clear.click(on_clear, [state] + view_inputs, [state] + shown, api_visibility="private")
732
+ concept_up.change(on_concept, [concept_up, state] + view_inputs, [state] + shown + [overlays],
733
+ api_visibility="private")
734
+ pbtn.click(lambda s, o, t: figure_3d(s, o, t), [state, overlays, top_pct], plot, api_visibility="private")
735
+ save.click(on_save, state, [files, receipt], api_visibility="private")
736
+ a_btn.click(guard(api_look), [a_vid, a_z, a_y, a_x, a_size, a_layers, a_tta, a_compact, a_cpu], a_out,
737
+ api_name="look", concurrency_id="models", concurrency_limit=2)
738
+ demo.load(on_load, [state] + view_inputs, [state] + sliders + shown + [vid, z, y, x, size, gpick],
739
+ api_visibility="private")
740
+ return demo
741
+
742
+
743
+ if __name__ == "__main__":
744
+ startup()
745
+ build_demo().queue(max_size=24).launch(theme=theme(), css=CSS, footer_links=["api", "gradio"])
lens.py ADDED
@@ -0,0 +1,932 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scroll Lens core: everything except the web page.
2
+
3
+ A visitor picks a point inside one of the released 2.4 um scans listed in VOLUMES. Scroll Lens reads the cube of
4
+ the scan around that point from the Vesuvius Challenge open-data bucket and runs three models the Vesuvius
5
+ Challenge released with the PHerc. Paris 4 paper (Angelotti et al. 2026, arXiv:2606.29085) on it:
6
+
7
+ - dinovol, a 3D DINO model that turns every 8 x 8 x 8 voxel patch into an 864-number embedding;
8
+ - ink_3d_dino_guided, a 3D U-Net that marks ink voxels directly in the scan (made for PHerc. Paris 4);
9
+ - surface_recto_3dunet, a 3D U-Net that marks the written (recto) surface of each papyrus sheet.
10
+
11
+ Clicks then light up every patch whose embedding is close to the clicked ones: the expert-click method the
12
+ organisers used to build the reference ink embedding behind ink_3d_dino_guided. Everything here is model output,
13
+ not a reading; a cube is 0.6 mm across, smaller than one letter.
14
+
15
+ No gradio here, and torch is imported only inside the model code, so the tests run with numpy alone.
16
+
17
+ python3 lens.py volumes the scans this Space may read
18
+ python3 lens.py look PHercParis4 Z Y X [--size 128] [--layers dino,ink,surface] [--out DIR]
19
+ python3 lens.py files the files of the Space, with their SHA-256
20
+ python3 lens.py stage DEST copy them into DEST (absent or empty), laid out as the Space runs
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import concurrent.futures
27
+ import hashlib
28
+ import io
29
+ import json
30
+ import math
31
+ import os
32
+ import shutil
33
+ import subprocess
34
+ import sys
35
+ import tarfile
36
+ import tempfile
37
+ import time
38
+ import urllib.error
39
+ import urllib.request
40
+ from pathlib import Path
41
+
42
+ HERE = Path(__file__).resolve().parent
43
+ VERSION = "1.0.0"
44
+ BUCKET = "https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com"
45
+ CHUNK = 128 # every listed scan is stored as uncompressed uint8 chunks of 128^3 voxels
46
+ SIZES = (128, 256) # cube sides in voxels; 256 is the models' training patch, 128 fits a CPU
47
+ PATCH = 8 # dinovol embeds each 8^3 voxel patch
48
+ EMBED_DIM = 864
49
+ MIN_FOREGROUND = 0.05 # a cube with less papyrus than this (the scans are masked to 0 outside it) is refused
50
+ REDUCED_DIM = 128 # gallery cubes keep this many components of each embedding (see reduce_grid)
51
+ TAU = 0.5 # the threshold the organisers used on cosine similarity to their ink reference
52
+ MAX_CONCEPT_BYTES = 4 << 20
53
+ USER_AGENT = f"scroll-lens/{VERSION}"
54
+
55
+
56
+ class LensError(Exception):
57
+ """A refusal with a message meant for the visitor."""
58
+
59
+
60
+ # ---------------------------------------------------------------- the scans this Space may read
61
+ #
62
+ # Only these volumes, all about 2.4 um scans at full resolution (level 0), the scale dinovol was trained on. None of
63
+ # them is on the First Letters or Grand Prize eligible lists (kit/data/prizes-2026-10-11.json). Anything else is
64
+ # refused before a byte is fetched. "role" says what each model saw of the scroll, from the model cards and the
65
+ # checkpoints' own training configs (read 16 October 2026).
66
+
67
+ INK_TEACHER = ("Not trained on this scroll, but its teacher model (stage 1) was trained on this scroll's "
68
+ "ink labels.")
69
+ VOLUMES = {
70
+ "PHercParis4": dict(
71
+ scroll="PHerc. Paris 4", scan="20260411134726-2.400um-0.2m-78keV-masked.zarr", voxel_um=2.400,
72
+ shape=(75784, 32693, 32693),
73
+ role=dict(dino="Trained on this scan.", ink="Trained on this scroll: the model was made for it.",
74
+ surface="Not in its training list."),
75
+ note="Scroll 1 of the Vesuvius Challenge. Unwrapped and read in Angelotti et al. 2026, where ink is "
76
+ "visible in the scan itself. Its title is still an open prize."),
77
+ "PHerc0139": dict(
78
+ scroll="PHerc. 139", scan="20260102150214-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399,
79
+ shape=(76953, 26511, 26511),
80
+ role=dict(dino="Trained on this scan.", ink=INK_TEACHER, surface="Trained on this scroll."),
81
+ note="The scroll Unroll shows in 3D, with 37 traced windings and published ink detection."),
82
+ "PHerc1667": dict(
83
+ scroll="PHerc. 1667 (Scroll 4)", scan="20251217075048-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399,
84
+ shape=(37076, 15229, 15229),
85
+ role=dict(dino="Not in its training list.", ink=INK_TEACHER, surface="Trained on this scroll."),
86
+ note="The one scan here that dinovol never saw in training."),
87
+ "PHerc0500P2": dict(
88
+ scroll="PHerc. 500P2", scan="20250526151718-2.215um-0.4m-111keV-masked.zarr", voxel_um=2.215,
89
+ shape=(28096, 18209, 18209),
90
+ role=dict(dino="Trained on this scan.", ink=INK_TEACHER, surface="Trained on this scroll."),
91
+ note="Seen in training by all three models."),
92
+ "PHerc0343P": dict(
93
+ scroll="PHerc. 343P", scan="20260304131111-2.215um-0.4m-111keV-masked.zarr", voxel_um=2.215,
94
+ shape=(20714, 13155, 13155),
95
+ role=dict(dino="Trained on this scan.", ink="Not in its training list.",
96
+ surface="Trained on this scroll."),
97
+ note="The First Letters prize for PHerc. 343 was awarded on 8 October 2026."),
98
+ "PHerc0814": dict(
99
+ scroll="PHerc. 814", scan="20260309142202-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399,
100
+ shape=(74568, 23891, 23891),
101
+ role=dict(dino="Trained on this scan.", ink=INK_TEACHER, surface="Not in its training list."),
102
+ note=""),
103
+ "PHerc0009B": dict(
104
+ scroll="PHerc. 9B", scan="20250820154339-2.401um-0.3m-77keV-masked.zarr", voxel_um=2.401,
105
+ shape=(29112, 28259, 28259),
106
+ role=dict(dino="Trained on this scan.", ink=INK_TEACHER, surface="Not in its training list."),
107
+ note=""),
108
+ "PHercMAN5": dict(
109
+ scroll="PHerc. MAN 5", scan="20260311104824-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399,
110
+ shape=(31640, 17495, 17495),
111
+ role=dict(dino="Trained on this scan.", ink=INK_TEACHER, surface="Not in its training list."),
112
+ note=""),
113
+ "PHerc1299": dict(
114
+ scroll="PHerc. 1299", scan="20260309130042-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399,
115
+ shape=(49233, 20579, 20579),
116
+ role=dict(dino="Trained on this scan.", ink="Not in its training list.",
117
+ surface="Not in its training list."),
118
+ note=""),
119
+ "PHerc0332": dict(
120
+ scroll="PHerc. 332", scan="20251211183505-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399,
121
+ shape=(33592, 15761, 15761),
122
+ role=dict(dino="Trained on this scan.", ink="Not in its training list.",
123
+ surface="Not in its training list."),
124
+ note=""),
125
+ "PHerc1451": dict(
126
+ scroll="PHerc. 1451", scan="20260319101107-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399,
127
+ shape=(59944, 20811, 20811),
128
+ role=dict(dino="Trained on this scan.", ink="Not in its training list.",
129
+ surface="Not in its training list."),
130
+ note=""),
131
+ "PHercMANB": dict(
132
+ scroll="PHerc. MAN B", scan="20260323091048-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399,
133
+ shape=(72681, 26723, 26723),
134
+ role=dict(dino="Trained on this scan.", ink="Not in its training list.",
135
+ surface="Listed as MAN Bp on the model card: trained on this scroll."),
136
+ note=""),
137
+ }
138
+
139
+ # PHerc. Paris 4's title is still an open prize, so Scroll Lens never picks Paris 4 points itself: "Surprise me" and the
140
+ # gallery use only these, the eight Paris 4 points the organisers listed for training in the ink checkpoint's own
141
+ # config (extra_patches.coords_xyz of ckpt_78k_fullsup.pth, sampled with 1024 voxels of jitter), here as z y x.
142
+ PARIS4_TRAINING_POINTS = ((37628, 8793, 22167), (35344, 19651, 19735), (70688, 19605, 10649), (41802, 16221, 12261),
143
+ (41652, 10039, 8270), (41652, 8812, 8006), (41652, 10476, 19310), (41778, 17662, 27405))
144
+
145
+ LAYERS = ("dino", "ink", "surface")
146
+ LAYER_TITLES = {"dino": "3D DINO embeddings", "ink": "3D ink model", "surface": "Recto surface model"}
147
+
148
+ # ---------------------------------------------------------------- the models and the code they need
149
+ #
150
+ # Weights come from the Hugging Face Hub at a pinned revision and are refused unless their SHA-256 matches.
151
+ # The model code is the organisers' own: dinovol (MIT, with DINOv2 and DINOv3 parts under their licenses) and
152
+ # villa's vesuvius package (MIT), each downloaded at a pinned commit and put on sys.path. Nothing of theirs is
153
+ # copied into this Space.
154
+
155
+ MODELS = {
156
+ "dino": dict(repo="scrollprize/dinovol_v2_ps8_with_paris4_352500",
157
+ revision="6a8cccbafef191a966da815e22ff5c6eae075aae",
158
+ file="dinovol_v2_ps8_paris4_step352500_teacher_backbone.pt", bytes=863606099,
159
+ sha256="e041ca870dd2570f8a44d1dd26db1197b3f74121f62023bc774fbc9d40e51a59"),
160
+ "ink": dict(repo="scrollprize/ink_3d_dino_guided", revision="73a79525466037432191284dfa237baf830c49ec",
161
+ file="ckpt_78k_fullsup.pth", bytes=1703138075,
162
+ sha256="5a148c2c1bb730bfa683f2b3e3cdfc2000424003605e42300b527ff90118b303"),
163
+ "ink_reference": dict(repo="scrollprize/ink_3d_dino_guided",
164
+ revision="73a79525466037432191284dfa237baf830c49ec",
165
+ file="avg_ref_embedding.npy", bytes=3584,
166
+ sha256="61bdf93bc5e3fd956eebdbed52618985d27264b8a9e5cb043087d0f234507a81"),
167
+ "surface": dict(repo="scrollprize/surface_recto_3dunet", revision="8ce8a4627e5104dda0af2b05aaa49820c0888774",
168
+ file="checkpoint_inference_ready.pth", bytes=1138388181,
169
+ sha256="dd36b3a89b2f4e21a6cafa14fea459f775f846a6ed036f75fb8e6b65aee3137b"),
170
+ }
171
+ CODE = {
172
+ "dinovol": dict(repo="https://github.com/ScrollPrize/dinovol.git",
173
+ tarball="https://codeload.github.com/ScrollPrize/dinovol/tar.gz/{commit}",
174
+ commit="a72fda2b1e02389a8711b3dc6a26215237f35eca", keep="dinovol_2/",
175
+ check="dinovol_2/model/model.py"),
176
+ "villa": dict(repo="https://github.com/ScrollPrize/villa.git",
177
+ tarball="https://codeload.github.com/ScrollPrize/villa/tar.gz/{commit}",
178
+ commit="b29f310957c7b29e7d332f9876e0e487819cfaa3", keep="vesuvius/src/",
179
+ check="vesuvius/src/vesuvius/models/build/build_network_from_config.py"),
180
+ }
181
+ CACHE = Path(os.environ.get("LENS_CACHE") or Path(tempfile.gettempdir()) / "scroll-lens")
182
+
183
+
184
+ def log(message):
185
+ print(f"[lens {time.strftime('%H:%M:%S')}] {message}", flush=True)
186
+
187
+
188
+ # ---------------------------------------------------------------- cubes: where and how to read them
189
+
190
+ def volume(vid):
191
+ """The listed volume `vid`, or a refusal. Nothing else is ever read."""
192
+ if vid not in VOLUMES:
193
+ raise LensError(f"{vid!r} is not one of the scans this Space may read")
194
+ return VOLUMES[vid]
195
+
196
+
197
+ def volume_url(vid, level=0):
198
+ v = volume(vid)
199
+ return f"{BUCKET}/{vid}/volumes/{v['scan']}/{int(level)}"
200
+
201
+
202
+ def cube_origin(vid, center_zyx, size):
203
+ """The corner of the cube of side `size` around `center_zyx` (level-0 voxels, z y x), snapped to the scan's
204
+ 128-voxel chunk grid so a 256 cube is exactly 8 stored chunks, and kept inside the scan. The point lies inside
205
+ the cube, at most 64 voxels from its centre."""
206
+ size = check_size(size)
207
+ shape = volume(vid)["shape"]
208
+ out = []
209
+ for c, n in zip(center_zyx, shape):
210
+ c = int(c)
211
+ if not 0 <= c < n:
212
+ raise LensError(f"point {list(map(int, center_zyx))} is outside the scan, whose shape is {list(shape)}")
213
+ o = int(round((c - size / 2) / CHUNK)) * CHUNK
214
+ o = max(0, min(o, (n - size) // CHUNK * CHUNK))
215
+ out.append(o)
216
+ return tuple(out)
217
+
218
+
219
+ def check_size(size):
220
+ size = int(size)
221
+ if size not in SIZES:
222
+ raise LensError(f"cube side must be one of {SIZES} voxels, not {size}")
223
+ return size
224
+
225
+
226
+ def chunk_keys(origin, size):
227
+ """The stored chunks (z, y, x indices) that a cube at `origin` of side `size` covers."""
228
+ ranges = [range(o // CHUNK, (o + size - 1) // CHUNK + 1) for o in origin]
229
+ return [(a, b, c) for a in ranges[0] for b in ranges[1] for c in ranges[2]]
230
+
231
+
232
+ def _get(url, timeout=60, tries=3):
233
+ """One object from the open-data bucket, anonymously. None for a missing chunk (all papyrus-free there)."""
234
+ last = None
235
+ for attempt in range(tries):
236
+ try:
237
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
238
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
239
+ return resp.read()
240
+ except urllib.error.HTTPError as exc:
241
+ if exc.code in (403, 404):
242
+ return None
243
+ last = exc
244
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
245
+ last = exc
246
+ time.sleep(1.5 * (attempt + 1))
247
+ raise LensError(f"could not read the open-data bucket ({last}); try again in a minute")
248
+
249
+
250
+ def read_cube(vid, origin, size, fetch=_get, workers=8):
251
+ """The uint8 cube of side `size` at `origin` (z y x, level 0). Chunks the bucket does not hold read as 0."""
252
+ import numpy as np
253
+ size = check_size(size)
254
+ shape = volume(vid)["shape"]
255
+ if any(o < 0 or o % CHUNK or o + size > n for o, n in zip(origin, shape)):
256
+ raise LensError(f"cube at {list(origin)} is not on the chunk grid inside the scan")
257
+ base = volume_url(vid)
258
+ keys = chunk_keys(origin, size)
259
+ out = np.zeros((size, size, size), np.uint8)
260
+
261
+ def one(key):
262
+ raw = fetch(f"{base}/{key[0]}/{key[1]}/{key[2]}")
263
+ if raw is None:
264
+ return key, None
265
+ if len(raw) != CHUNK ** 3:
266
+ raise LensError(f"chunk {key} has {len(raw)} bytes, not {CHUNK ** 3}: not an uncompressed uint8 chunk")
267
+ return key, np.frombuffer(raw, np.uint8).reshape(CHUNK, CHUNK, CHUNK)
268
+
269
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
270
+ for key, chunk in pool.map(one, keys):
271
+ if chunk is None:
272
+ continue
273
+ sl = tuple(slice(k * CHUNK - o, k * CHUNK - o + CHUNK) for k, o in zip(key, origin))
274
+ out[sl] = chunk
275
+ return out
276
+
277
+
278
+ def foreground(cube):
279
+ """Fraction of voxels that are papyrus (the listed scans are masked to 0 outside the scroll)."""
280
+ return float((cube > 0).mean())
281
+
282
+
283
+ def check_cube(cube):
284
+ frac = foreground(cube)
285
+ if frac < MIN_FOREGROUND:
286
+ raise LensError(f"only {frac:.1%} of this cube is papyrus; the scan is masked to 0 outside the scroll. "
287
+ "Pick a point on the papyrus, or press Surprise me.")
288
+ return frac
289
+
290
+
291
+ def random_point(vid, rng, fetch=_get, tries=24):
292
+ """A random point on papyrus: read random level-3 chunks (1/8 resolution) and pick a bright voxel in one."""
293
+ import numpy as np
294
+ shape = volume(vid)["shape"]
295
+ lv = 3
296
+ f = 2 ** lv
297
+ small = [math.ceil(n / f) for n in shape]
298
+ nchunks = [max(1, math.ceil(n / CHUNK)) for n in small]
299
+ base = volume_url(vid, lv)
300
+ for _ in range(tries):
301
+ # stay off the top and bottom tenth of the scroll, where the scan often holds no papyrus
302
+ cz = int(rng.integers(int(nchunks[0] * 0.1), max(int(nchunks[0] * 0.9), int(nchunks[0] * 0.1) + 1)))
303
+ cy, cx = (int(rng.integers(0, n)) for n in nchunks[1:])
304
+ raw = fetch(f"{base}/{cz}/{cy}/{cx}")
305
+ if raw is None or len(raw) != CHUNK ** 3:
306
+ continue
307
+ a = np.frombuffer(raw, np.uint8).reshape(CHUNK, CHUNK, CHUNK)
308
+ # papyrus is the brighter part of a masked scan; a 16-voxel margin keeps the cube away from the mask edge
309
+ inner = a[16:-16, 16:-16, 16:-16]
310
+ idx = np.argwhere(inner > max(1, np.percentile(a[a > 0], 60) if (a > 0).any() else 255))
311
+ if len(idx) < 50:
312
+ continue
313
+ for _ in range(16):
314
+ p = idx[int(rng.integers(0, len(idx)))] + 16
315
+ # the 256 cube around the point (16 voxels each way at this level) must lie inside the scroll mask
316
+ win = a[p[0] - 16:p[0] + 16, p[1] - 16:p[1] + 16, p[2] - 16:p[2] + 16]
317
+ if (win > 0).mean() < 0.9:
318
+ continue
319
+ zyx = [int((c * CHUNK + p[i]) * f + f // 2) for i, c in enumerate((cz, cy, cx))]
320
+ if all(0 <= v < n for v, n in zip(zyx, shape)):
321
+ return tuple(zyx)
322
+ raise LensError("could not find papyrus in this scan after several tries; try again")
323
+
324
+
325
+ # ---------------------------------------------------------------- downloads: code and weights
326
+
327
+ def sha256_file(path, block=1 << 22):
328
+ h = hashlib.sha256()
329
+ with open(path, "rb") as fh:
330
+ for part in iter(lambda: fh.read(block), b""):
331
+ h.update(part)
332
+ return h.hexdigest()
333
+
334
+
335
+ def _fetch_code(name, dest):
336
+ """One pinned repository's needed subtree: a sparse, blobless git fetch of the commit, else GitHub's tarball."""
337
+ spec = CODE[name]
338
+ commit = spec["commit"]
339
+ tmp = dest.with_name(dest.name + ".part")
340
+ shutil.rmtree(tmp, ignore_errors=True)
341
+ tmp.mkdir(parents=True)
342
+ try:
343
+ git = ["git", "-C", str(tmp)]
344
+ for cmd in (["init", "-q"], ["remote", "add", "origin", spec["repo"]],
345
+ ["sparse-checkout", "set", spec["keep"].rstrip("/")],
346
+ ["fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", commit],
347
+ ["checkout", "-q", "FETCH_HEAD"]):
348
+ subprocess.run(git + cmd, capture_output=True, text=True, check=True, timeout=900)
349
+ head = subprocess.run(git + ["rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip()
350
+ if head != commit:
351
+ raise RuntimeError(f"git fetched {head}, not {commit}")
352
+ shutil.rmtree(tmp / ".git", ignore_errors=True)
353
+ except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
354
+ log(f"git fetch of {name} failed ({getattr(exc, 'stderr', None) or exc}); trying the tarball")
355
+ shutil.rmtree(tmp, ignore_errors=True)
356
+ tmp.mkdir(parents=True)
357
+ _tarball(spec, tmp)
358
+ if not (tmp / spec["check"]).is_file():
359
+ raise RuntimeError(f"{name} at {commit} has no {spec['check']}")
360
+ (tmp / "COMMIT").write_text(commit + "\n")
361
+ shutil.rmtree(dest, ignore_errors=True)
362
+ os.replace(tmp, dest)
363
+
364
+
365
+ def _tarball(spec, tmp):
366
+ """Stream GitHub's tarball of the pinned commit and keep only the needed subtree."""
367
+ root = tmp.resolve()
368
+ commit = spec["commit"]
369
+ kept = 0
370
+ req = urllib.request.Request(spec["tarball"].format(commit=commit), headers={"User-Agent": USER_AGENT})
371
+ with urllib.request.urlopen(req, timeout=900) as resp, tarfile.open(fileobj=resp, mode="r|gz") as tf:
372
+ for m in tf:
373
+ top, _, rel = m.name.partition("/")
374
+ if commit[:7] not in top:
375
+ raise RuntimeError(f"the tarball for {commit} holds {top!r}")
376
+ if not rel.startswith(spec["keep"]):
377
+ continue
378
+ target = (root / rel).resolve()
379
+ if root not in target.parents or m.issym() or m.islnk():
380
+ continue
381
+ if m.isdir():
382
+ target.mkdir(parents=True, exist_ok=True)
383
+ elif m.isfile():
384
+ target.parent.mkdir(parents=True, exist_ok=True)
385
+ with tf.extractfile(m) as src, open(target, "wb") as out:
386
+ shutil.copyfileobj(src, out)
387
+ kept += 1
388
+ if not kept:
389
+ raise RuntimeError(f"the tarball for {commit} held no {spec['keep']} files")
390
+
391
+
392
+ def ensure_code(cache=CACHE):
393
+ """Put dinovol and villa's vesuvius on sys.path at their pinned commits. LENS_DINOVOL and LENS_VILLA_SRC name
394
+ local checkouts instead (the dinovol repository root, and villa's vesuvius/src)."""
395
+ info = {}
396
+ for name, env, sub in (("dinovol", "LENS_DINOVOL", ""), ("villa", "LENS_VILLA_SRC", "vesuvius/src")):
397
+ if os.environ.get(env):
398
+ path = Path(os.environ[env]).expanduser().resolve()
399
+ info[name] = {"source": env, "path": str(path), "pinned": CODE[name]["commit"]}
400
+ else:
401
+ dest = Path(cache) / f"{name}-{CODE[name]['commit'][:12]}"
402
+ if not (dest / "COMMIT").is_file():
403
+ log(f"fetching {name} {CODE[name]['commit'][:12]}")
404
+ _fetch_code(name, dest)
405
+ path = dest / sub if sub else dest
406
+ info[name] = {"source": "pinned download", "commit": (dest / "COMMIT").read_text().strip()}
407
+ if str(path) not in sys.path:
408
+ sys.path.insert(0, str(path))
409
+ return info
410
+
411
+
412
+ def ensure_weights(cache=CACHE, names=tuple(MODELS)):
413
+ """Each named file from the Hub at its pinned revision, refused unless its SHA-256 matches. LENS_WEIGHTS names
414
+ a folder holding local copies laid out as REPO_NAME/FILE (their SHA-256 is still checked)."""
415
+ paths = {}
416
+ local = os.environ.get("LENS_WEIGHTS")
417
+ for name in names:
418
+ spec = MODELS[name]
419
+ if local:
420
+ path = Path(local).expanduser() / spec["repo"].split("/")[1] / spec["file"]
421
+ else:
422
+ from huggingface_hub import hf_hub_download
423
+ path = Path(hf_hub_download(spec["repo"], spec["file"], revision=spec["revision"],
424
+ cache_dir=str(Path(cache) / "hub")))
425
+ digest = sha256_file(path)
426
+ if digest != spec["sha256"]:
427
+ raise RuntimeError(f"{spec['repo']}/{spec['file']} has SHA-256 {digest}, not {spec['sha256']}")
428
+ paths[name] = path
429
+ return paths
430
+
431
+
432
+ # ---------------------------------------------------------------- the models
433
+
434
+ class Engine:
435
+ """The three models, on the CPU until a call moves them. Built once per process."""
436
+
437
+ def __init__(self, cache=CACHE):
438
+ import numpy as np
439
+ t0 = time.time()
440
+ self.code = ensure_code(cache)
441
+ self.paths = ensure_weights(cache)
442
+ self.reference = np.load(self.paths["ink_reference"], allow_pickle=False).astype(np.float32)
443
+ self.reference /= np.linalg.norm(self.reference)
444
+ self.dino = self._load_dino(self.paths["dino"])
445
+ self.ink, self.ink_norm = self._load_ink(self.paths["ink"])
446
+ self.surface = self._load_surface(self.paths["surface"])
447
+ self.startup_s = round(time.time() - t0, 1)
448
+
449
+ # -- loading
450
+
451
+ @staticmethod
452
+ def _load_dino(path):
453
+ import torch
454
+ from dinovol_2.model.model import DinoVitStudentTeacher, _upgrade_weight_norm_state_dict_keys
455
+ ck = torch.load(path, map_location="cpu", weights_only=False)
456
+ state = {k.replace("backbone.", "", 1): v for k, v in ck["teacher"].items() if k.startswith("backbone.")}
457
+ net = DinoVitStudentTeacher._build_backbone(ck["config"]["model"])
458
+ net.load_state_dict(_upgrade_weight_norm_state_dict_keys(state), strict=True)
459
+ return net.eval()
460
+
461
+ @staticmethod
462
+ def _load_ink(path):
463
+ import torch
464
+ from vesuvius.ink_detection.inference.inference_runtime import TargetModel
465
+ from vesuvius.ink_detection.models.checkpoint import config_from_checkpoint, load_model_state
466
+ from vesuvius.ink_detection.models.model import make_model
467
+ payload = torch.load(path, map_location="cpu", weights_only=False)
468
+ cfg = config_from_checkpoint(payload)
469
+ net = make_model(cfg)
470
+ load_model_state(net, payload["ema_model"]) # the card recommends the EMA weights for inference
471
+ return TargetModel(net.eval()).eval(), cfg.data.normalization
472
+
473
+ @staticmethod
474
+ def _load_surface(path):
475
+ import torch
476
+ from types import SimpleNamespace
477
+ from vesuvius.models.build.build_network_from_config import NetworkFromConfig
478
+ ck = torch.load(path, map_location="cpu", weights_only=False)
479
+ mc = ck["model_config"]
480
+ mgr = SimpleNamespace(targets=mc["targets"], train_patch_size=tuple(mc["train_patch_size"]),
481
+ train_batch_size=mc["train_batch_size"], in_channels=1, autoconfigure=False,
482
+ model_config=mc, op_dims=3, spacing=(1.0, 1.0, 1.0), model_name=mc["model_name"])
483
+ net = NetworkFromConfig(mgr)
484
+ net.load_state_dict(ck["model"], strict=True)
485
+ return net.eval()
486
+
487
+ def describe(self):
488
+ return {"models": {k: {kk: v[kk] for kk in ("repo", "revision", "file", "sha256")}
489
+ for k, v in MODELS.items()},
490
+ "code": self.code, "startup_s": self.startup_s}
491
+
492
+ # -- running
493
+
494
+ def run(self, cube, layers=LAYERS, device="cpu", tta=False):
495
+ """The chosen layers for one cube. Returns numpy arrays: `grid` (Z/8, Y/8, X/8, 864) float16 unit vectors,
496
+ `ink` and `surface` (Z, Y, X) uint8 probabilities times 255, and timings."""
497
+ import numpy as np
498
+ import torch
499
+ layers = check_layers(layers)
500
+ dev = torch.device(device)
501
+ out = {"device": str(dev), "device_name": _device_name(dev), "seconds": {}, "tta": bool(tta)}
502
+ for name in layers:
503
+ t0 = time.time()
504
+ if name == "dino":
505
+ out["grid"] = self._dino_grid(cube, dev)
506
+ elif name == "ink":
507
+ out["ink"] = self._ink(cube, dev, tta)
508
+ else:
509
+ out["surface"] = self._surface(cube, dev, tta)
510
+ if dev.type == "cuda":
511
+ torch.cuda.synchronize()
512
+ out["seconds"][name] = round(time.time() - t0, 3)
513
+ return out
514
+
515
+ def _autocast(self, dev):
516
+ import contextlib
517
+ import torch
518
+ return torch.autocast("cuda", dtype=torch.bfloat16) if dev.type == "cuda" else contextlib.nullcontext()
519
+
520
+ def _dino_grid(self, cube, dev):
521
+ """dinovol's dense patch embeddings, as its own napari inspector computes them: robust normalisation of
522
+ the whole cube, 128^3 windows overlapping by one patch, edge-tapered averaging, unit length."""
523
+ import numpy as np
524
+ import torch
525
+ import torch.nn.functional as F
526
+ from dinovol_2.dataset.normalization import get_normalization
527
+ net = self.dino.to(dev)
528
+ vol = get_normalization("robust", intensityproperties=None).run(cube.astype(np.float32))
529
+ n = cube.shape[0] // PATCH
530
+ win = min(n, 128 // PATCH)
531
+ starts = _window_starts(n, win, 1)
532
+ w1 = _taper(win, 1 if win < n else 0)
533
+ weight = w1[:, None, None] * w1[None, :, None] * w1[None, None, :]
534
+ acc = np.zeros((n, n, n, EMBED_DIM), np.float32)
535
+ wsum = np.zeros((n, n, n), np.float32)
536
+ with torch.inference_mode(), self._autocast(dev):
537
+ for z in starts:
538
+ for y in starts:
539
+ for x in starts:
540
+ tile = vol[z * PATCH:(z + win) * PATCH, y * PATCH:(y + win) * PATCH,
541
+ x * PATCH:(x + win) * PATCH]
542
+ t = torch.from_numpy(np.ascontiguousarray(tile))[None, None].to(dev)
543
+ tok = net.forward_features(t, masks=None, view_kind="global")["x_norm_patchtokens"][0]
544
+ tok = F.normalize(tok.float().reshape(win, win, win, -1), dim=-1).cpu().numpy()
545
+ acc[z:z + win, y:y + win, x:x + win] += tok * weight[..., None]
546
+ wsum[z:z + win, y:y + win, x:x + win] += weight
547
+ grid = acc / np.maximum(wsum[..., None], np.finfo(np.float32).eps)
548
+ grid /= np.maximum(np.linalg.norm(grid, axis=-1, keepdims=True), 1e-12)
549
+ return grid.astype(np.float16)
550
+
551
+ def _ink(self, cube, dev, tta):
552
+ import numpy as np
553
+ import torch
554
+ from vesuvius.ink_detection.data.normalization import normalize_image
555
+ net = self.ink.to(dev)
556
+ x = torch.from_numpy(np.ascontiguousarray(normalize_image(cube.astype(np.float32), self.ink_norm)))
557
+ x = x[None, None].to(dev)
558
+ with torch.inference_mode(), self._autocast(dev):
559
+ prob = _mirrored(lambda t: net(t).float().sigmoid(), x, tta)
560
+ return _to_u8(prob[0, 0])
561
+
562
+ def _surface(self, cube, dev, tta):
563
+ import numpy as np
564
+ import torch
565
+ net = self.surface.to(dev)
566
+ c = cube.astype(np.float32)
567
+ x = torch.from_numpy((c - c.mean()) / max(float(c.std()), 1e-8))[None, None].to(dev) # z-score, per cube
568
+ with torch.inference_mode(), self._autocast(dev):
569
+ prob = _mirrored(lambda t: torch.softmax(net(t)["surface"].float(), 1)[:, 1:2], x, tta)
570
+ return _to_u8(prob[0, 0])
571
+
572
+
573
+ def _mirrored(fn, x, tta):
574
+ """fn(x), or with tta the mean of fn over the eight mirrorings of x, each flipped back (villa's mirror TTA)."""
575
+ if not tta:
576
+ return fn(x)
577
+ total = None
578
+ for axes in ((), (2,), (3,), (4,), (2, 3), (2, 4), (3, 4), (2, 3, 4)):
579
+ y = fn(x.flip(axes) if axes else x)
580
+ y = y.flip(axes) if axes else y
581
+ total = y if total is None else total + y
582
+ return total / 8.0
583
+
584
+
585
+ def _to_u8(prob):
586
+ import numpy as np
587
+ return np.clip(np.rint(prob.float().cpu().numpy() * 255.0), 0, 255).astype(np.uint8)
588
+
589
+
590
+ def _device_name(dev):
591
+ if dev.type == "cuda":
592
+ import torch
593
+ return torch.cuda.get_device_name(dev)
594
+ import platform
595
+ return platform.processor() or platform.machine()
596
+
597
+
598
+ def _window_starts(n, win, overlap):
599
+ if win >= n:
600
+ return [0]
601
+ step = win - overlap
602
+ starts = list(range(0, n - win + 1, step))
603
+ if starts[-1] != n - win:
604
+ starts.append(n - win)
605
+ return starts
606
+
607
+
608
+ def _taper(length, overlap):
609
+ import numpy as np
610
+ w = np.ones(length, np.float32)
611
+ if overlap <= 0 or length <= 1:
612
+ return w
613
+ r = np.arange(length, dtype=np.float32)
614
+ return np.minimum(np.minimum(r + 1.0, length - r) / float(overlap + 1), 1.0).astype(np.float32)
615
+
616
+
617
+ def check_layers(layers):
618
+ layers = tuple(dict.fromkeys(layers or ()))
619
+ bad = [x for x in layers if x not in LAYERS]
620
+ if bad:
621
+ raise LensError(f"unknown layer {bad[0]!r}; choose from {', '.join(LAYERS)}")
622
+ if not layers:
623
+ raise LensError("choose at least one model to run")
624
+ return layers
625
+
626
+
627
+ def gpu_seconds(size, layers, tta=False):
628
+ """What one cube asks ZeroGPU for. Generous until measured: the quota counts the time actually used."""
629
+ size, layers = check_size(size), check_layers(layers)
630
+ scale = (size / 256) ** 3
631
+ per = {"dino": 6.0, "ink": 4.0 * (8 if tta else 1), "surface": 4.0 * (8 if tta else 1)}
632
+ return int(math.ceil(12 + scale * sum(per[x] for x in layers)))
633
+
634
+
635
+ # ---------------------------------------------------------------- embeddings: clicks, similarity, colours
636
+
637
+ def unit(v, axis=-1):
638
+ import numpy as np
639
+ v = np.asarray(v, np.float32)
640
+ return v / np.maximum(np.linalg.norm(v, axis=axis, keepdims=True), 1e-12)
641
+
642
+
643
+ def grid_of(result):
644
+ """The (Z/8, Y/8, X/8, 864) float32 unit embeddings of a result, rebuilt from a reduced gallery cube if needed."""
645
+ import numpy as np
646
+ if "grid" in result:
647
+ return np.asarray(result["grid"], np.float32)
648
+ if "grid_reduced" in result:
649
+ g = np.asarray(result["grid_reduced"], np.float32) @ np.asarray(result["grid_basis"], np.float32).T
650
+ return unit(g)
651
+ raise LensError("this cube has no DINO embeddings; run it with the 3D DINO layer")
652
+
653
+
654
+ def reduce_grid(grid, k=REDUCED_DIM):
655
+ """Keep the top `k` directions of a cube's embeddings (uncentred SVD, so the common direction stays): 1/7 of the
656
+ size. Returns (coefficients float16, basis (864, k) float32). grid_of() rebuilds unit vectors from them."""
657
+ import numpy as np
658
+ g = np.asarray(grid, np.float32).reshape(-1, EMBED_DIM)
659
+ _, _, vt = np.linalg.svd(g[:: max(1, len(g) // 20000)], full_matrices=False)
660
+ basis = vt[:k].T.astype(np.float32)
661
+ coef = (g @ basis).reshape(*np.shape(grid)[:3], k)
662
+ return coef.astype(np.float16), basis
663
+
664
+
665
+ def token_of(voxel_zyx, size):
666
+ """The DINO patch (token) index holding a voxel of the cube."""
667
+ n = size // PATCH
668
+ return tuple(int(min(max(int(v) // PATCH, 0), n - 1)) for v in voxel_zyx)
669
+
670
+
671
+ def concept(grid, tokens):
672
+ """The organisers' recipe for a reference embedding: the unit-length mean of the clicked patches' embeddings."""
673
+ import numpy as np
674
+ if not tokens:
675
+ return None
676
+ vecs = np.stack([grid[t] for t in tokens]).astype(np.float32)
677
+ return unit(vecs.mean(0))
678
+
679
+
680
+ def similarity(grid, positive, negative=None):
681
+ """Per patch: cosine similarity to the positive concept, minus that to the negative one when there is one."""
682
+ import numpy as np
683
+ s = np.tensordot(grid, positive, axes=([-1], [0])).astype(np.float32)
684
+ if negative is not None:
685
+ s = s - np.tensordot(grid, negative, axes=([-1], [0])).astype(np.float32)
686
+ return s
687
+
688
+
689
+ def pca_colours(grid):
690
+ """The three main directions of a cube's embeddings as red, green and blue, stretched to the 1st and 99th
691
+ percentiles: patches the model sees as alike get alike colours. (Z, Y, X, 3) uint8 at patch resolution."""
692
+ import numpy as np
693
+ g = np.asarray(grid, np.float32).reshape(-1, grid.shape[-1])
694
+ g = g - g.mean(0)
695
+ _, _, vt = np.linalg.svd(g[:: max(1, len(g) // 20000)], full_matrices=False)
696
+ p = g @ vt[:3].T
697
+ # fix each component's sign so the colours do not flip between runs of the same cube
698
+ p *= np.sign(np.sum(p ** 3, axis=0, keepdims=True) + 1e-12)
699
+ lo, hi = np.percentile(p, 1, axis=0), np.percentile(p, 99, axis=0)
700
+ p = np.clip((p - lo) / np.maximum(hi - lo, 1e-6), 0, 1)
701
+ return (p * 255).astype(np.uint8).reshape(*grid.shape[:3], 3)
702
+
703
+
704
+ def upsample(tokens, size):
705
+ """Patch-resolution values (Z/8, Y/8, X/8[, C]) to voxels by repetition."""
706
+ import numpy as np
707
+ f = size // tokens.shape[0]
708
+ a = np.repeat(tokens, f, axis=0)
709
+ a = np.repeat(a, f, axis=1)
710
+ return np.repeat(a, f, axis=2)
711
+
712
+
713
+ def auc(scores, labels):
714
+ """Probability that a positive scores above a negative (ties count half). None when a class is empty."""
715
+ import numpy as np
716
+ s = np.asarray(scores, np.float64).ravel()
717
+ y = np.asarray(labels, bool).ravel()
718
+ n1, n0 = int(y.sum()), int((~y).sum())
719
+ if not n1 or not n0:
720
+ return None
721
+ order = np.argsort(s, kind="mergesort")
722
+ ranks = np.empty(len(s), np.float64)
723
+ sorted_s = s[order]
724
+ # average ranks over ties
725
+ i = 0
726
+ while i < len(s):
727
+ j = i
728
+ while j + 1 < len(s) and sorted_s[j + 1] == sorted_s[i]:
729
+ j += 1
730
+ ranks[order[i:j + 1]] = (i + j) / 2.0 + 1.0
731
+ i = j + 1
732
+ return float((ranks[y].sum() - n1 * (n1 + 1) / 2.0) / (n1 * n0))
733
+
734
+
735
+ def agreement(sim_tokens, ink_u8, threshold=0.5):
736
+ """How well a patch similarity map picks out the patches the 3D ink model marks as ink (mean probability over
737
+ the patch above `threshold`), as an AUC, beside a control that must fail: the same ink mask moved by half the
738
+ cube along every axis, which breaks the link between place and score. Agreement with a model, not ground
739
+ truth."""
740
+ import numpy as np
741
+ n = sim_tokens.shape[0]
742
+ f = ink_u8.shape[0] // n
743
+ ink_tok = ink_u8.reshape(n, f, n, f, n, f).mean(axis=(1, 3, 5)) / 255.0 > threshold
744
+ shifted = np.roll(ink_tok, (n // 2, n // 2, n // 2), axis=(0, 1, 2))
745
+ return {"auc": auc(sim_tokens, ink_tok), "auc_control_shifted": auc(sim_tokens, shifted),
746
+ "ink_patch_fraction": float(ink_tok.mean())}
747
+
748
+
749
+ def load_concept(path):
750
+ """A concept file a visitor uploads: .npy, float, shape (864,) or (K, 864), finite, at most 4 MB, no pickles.
751
+ Returns the unit-length mean."""
752
+ import numpy as np
753
+ p = Path(path)
754
+ if p.stat().st_size > MAX_CONCEPT_BYTES:
755
+ raise LensError(f"a concept file is at most {MAX_CONCEPT_BYTES >> 20} MB")
756
+ try:
757
+ a = np.load(p, allow_pickle=False)
758
+ except (ValueError, OSError) as exc:
759
+ raise LensError(f"not a plain .npy array: {exc}") from None
760
+ if a.dtype.kind != "f" or a.ndim not in (1, 2) or a.shape[-1] != EMBED_DIM:
761
+ raise LensError(f"a concept is a float array of shape ({EMBED_DIM},) or (K, {EMBED_DIM}), not {a.dtype} "
762
+ f"{a.shape}")
763
+ a = a.astype(np.float32).reshape(-1, EMBED_DIM)
764
+ if not np.isfinite(a).all() or not np.linalg.norm(a, axis=1).all():
765
+ raise LensError("the concept holds non-finite or zero vectors")
766
+ return unit(unit(a).mean(0))
767
+
768
+
769
+ # ---------------------------------------------------------------- saving and the gallery
770
+
771
+ def receipt(vid, origin, size, center, result, engine_info=None, extra=None):
772
+ v = volume(vid)
773
+ r = {"tool": "Scroll Lens", "version": VERSION, "volume": vid, "scroll": v["scroll"],
774
+ "zarr": f"{BUCKET}/{vid}/volumes/{v['scan']}", "level": 0, "voxel_um": v["voxel_um"],
775
+ "point_zyx": [int(c) for c in center], "cube_origin_zyx": [int(o) for o in origin], "cube_size": int(size),
776
+ "layers": [k for k in LAYERS if k in result or (k == "dino" and "grid_reduced" in result)],
777
+ "device": result.get("device"), "device_name": result.get("device_name"), "tta": result.get("tta", False),
778
+ "seconds": result.get("seconds", {}), "models_trained_on_this_scroll": v["role"],
779
+ "note": "Model output, not a reading. A cube is smaller than one letter."}
780
+ if engine_info:
781
+ r["engine"] = engine_info
782
+ if extra:
783
+ r.update(extra)
784
+ return r
785
+
786
+
787
+ def quantize(prob_u8, levels=16):
788
+ """uint8 probabilities rounded to `levels` evenly spaced values (0, 17, ..., 255 for 16): a compact gallery cube
789
+ is about a quarter of the size and no probability moves by more than 8/255."""
790
+ import numpy as np
791
+ step = 255 // (levels - 1)
792
+ return (np.rint(np.asarray(prob_u8, np.float32) / step) * step).clip(0, 255).astype(np.uint8)
793
+
794
+
795
+ def save_npz(path, cube_meta, result, compact=False):
796
+ """A cube's model outputs (not the scan, which anyone can read again from the bucket): embeddings as float16
797
+ unit vectors, ink and surface as uint8, and the receipt as JSON. `compact` (the gallery) keeps REDUCED_DIM
798
+ components of the embeddings (see reduce_grid) and 16 levels of each probability (see quantize)."""
799
+ import numpy as np
800
+ meta = dict(cube_meta, compact=bool(compact))
801
+ arrays = {"receipt": np.frombuffer(json.dumps(meta).encode(), np.uint8)}
802
+ if "grid" in result:
803
+ if compact:
804
+ arrays["grid_reduced"], arrays["grid_basis"] = reduce_grid(result["grid"])
805
+ else:
806
+ arrays["grid"] = np.asarray(result["grid"], np.float16)
807
+ elif "grid_reduced" in result:
808
+ arrays["grid_reduced"], arrays["grid_basis"] = result["grid_reduced"], result["grid_basis"]
809
+ for k in ("ink", "surface"):
810
+ if k in result:
811
+ arrays[k] = quantize(result[k]) if compact else np.asarray(result[k], np.uint8)
812
+ np.savez_compressed(path, **arrays)
813
+ return Path(path)
814
+
815
+
816
+ def load_npz(path):
817
+ """A cube saved by save_npz, checked: the shapes agree with the receipt and nothing holds Python objects."""
818
+ import numpy as np
819
+ with np.load(path, allow_pickle=False) as z:
820
+ out = {k: z[k] for k in z.files}
821
+ meta = json.loads(bytes(out.pop("receipt")).decode())
822
+ size = check_size(meta["cube_size"])
823
+ volume(meta["volume"])
824
+ n = size // PATCH
825
+ if "grid" in out and out["grid"].shape != (n, n, n, EMBED_DIM):
826
+ raise LensError(f"{path}: embeddings of shape {out['grid'].shape}, not {(n, n, n, EMBED_DIM)}")
827
+ if "grid_reduced" in out and (out["grid_reduced"].shape[:3] != (n, n, n)
828
+ or out["grid_basis"].shape != (EMBED_DIM, out["grid_reduced"].shape[3])):
829
+ raise LensError(f"{path}: reduced embeddings do not match their basis")
830
+ for k in ("ink", "surface"):
831
+ if k in out and (out[k].shape != (size,) * 3 or out[k].dtype != np.uint8):
832
+ raise LensError(f"{path}: {k} is {out[k].dtype} {out[k].shape}, not uint8 {(size,) * 3}")
833
+ return meta, out
834
+
835
+
836
+ def gallery(root=HERE / "gallery"):
837
+ """The precomputed cubes: gallery/index.json lists them, each with its .npz beside it."""
838
+ idx = Path(root) / "index.json"
839
+ if not idx.is_file():
840
+ return []
841
+ items = json.loads(idx.read_text()).get("cubes", [])
842
+ for it in items:
843
+ volume(it["volume"])
844
+ for key in ("id", "title", "note", "file", "point_zyx", "size"):
845
+ if key not in it:
846
+ raise LensError(f"gallery entry {it.get('id')!r} has no {key}")
847
+ if "/" in it["file"] or ".." in it["file"] or not it["file"].endswith(".npz"):
848
+ raise LensError(f"gallery entry {it['id']!r} names a file outside gallery/")
849
+ return items
850
+
851
+
852
+ # ---------------------------------------------------------------- the Space's own files
853
+
854
+ SPACE_FILES = ("README.md", "app.py", "lens.py", "requirements.txt", "LICENSE", "NOTICE")
855
+
856
+
857
+ def space_files(root=HERE):
858
+ """The code and text of the Space. The gallery is not among them: tools/build_gallery.py uploads it."""
859
+ files = [root / f for f in SPACE_FILES]
860
+ missing = [str(p) for p in files if not p.is_file()]
861
+ if missing:
862
+ raise RuntimeError(f"missing Space files: {missing}")
863
+ return files
864
+
865
+
866
+ def stage(dest, root=HERE):
867
+ dest = Path(dest)
868
+ if dest.exists() and any(dest.iterdir()):
869
+ raise RuntimeError(f"{dest} is not empty")
870
+ for p in space_files(root):
871
+ rel = p.relative_to(root)
872
+ (dest / rel).parent.mkdir(parents=True, exist_ok=True)
873
+ shutil.copy2(p, dest / rel)
874
+ return dest
875
+
876
+
877
+ # ---------------------------------------------------------------- command line
878
+
879
+ def main(argv=None):
880
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
881
+ sub = ap.add_subparsers(dest="cmd", required=True)
882
+ sub.add_parser("volumes", help="the scans this Space may read")
883
+ lk = sub.add_parser("look", help="run the models on one cube on this machine and save it")
884
+ lk.add_argument("volume")
885
+ lk.add_argument("zyx", nargs=3, type=int, help="a point in level-0 voxels, z y x")
886
+ lk.add_argument("--size", type=int, default=128, choices=SIZES)
887
+ lk.add_argument("--layers", default="dino,ink,surface")
888
+ lk.add_argument("--tta", action="store_true", help="average the eight mirrorings (8x the ink and surface time)")
889
+ lk.add_argument("--device", default=None, help="cpu or cuda (default: cuda when present)")
890
+ lk.add_argument("--out", default=".", help="folder for CUBE.npz and CUBE.json")
891
+ sub.add_parser("files", help="the Space's files with their SHA-256")
892
+ st = sub.add_parser("stage", help="copy the Space's files into an empty folder")
893
+ st.add_argument("dest")
894
+ a = ap.parse_args(argv)
895
+ try:
896
+ if a.cmd == "volumes":
897
+ for vid, v in VOLUMES.items():
898
+ print(f"{vid:12s} {v['scroll']:24s} {v['voxel_um']:.3f} um {v['scan']}")
899
+ return 0
900
+ if a.cmd == "files":
901
+ for p in space_files():
902
+ print(f"{sha256_file(p)} {p.relative_to(HERE)}")
903
+ return 0
904
+ if a.cmd == "stage":
905
+ print(stage(a.dest))
906
+ return 0
907
+ import torch
908
+ device = a.device or ("cuda" if torch.cuda.is_available() else "cpu")
909
+ layers = check_layers(a.layers.split(","))
910
+ origin = cube_origin(a.volume, a.zyx, a.size)
911
+ cube = read_cube(a.volume, origin, a.size)
912
+ check_cube(cube)
913
+ eng = Engine()
914
+ res = eng.run(cube, layers, device=device, tta=a.tta)
915
+ meta = receipt(a.volume, origin, a.size, a.zyx, res, eng.describe())
916
+ if "grid" in res and "ink" in res:
917
+ meta["agreement_with_ink_model"] = agreement(similarity(res["grid"].astype("float32"), eng.reference),
918
+ res["ink"])
919
+ out = Path(a.out)
920
+ out.mkdir(parents=True, exist_ok=True)
921
+ name = f"{a.volume}-{'-'.join(map(str, origin))}-{a.size}"
922
+ save_npz(out / f"{name}.npz", meta, res)
923
+ (out / f"{name}.json").write_text(json.dumps(meta, indent=1) + "\n")
924
+ print(json.dumps(meta, indent=1))
925
+ return 0
926
+ except LensError as exc:
927
+ print(f"lens: {exc}", file=sys.stderr)
928
+ return 2
929
+
930
+
931
+ if __name__ == "__main__":
932
+ sys.exit(main())
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Scroll Lens on a Hugging Face ZeroGPU Space (Gradio 6.28.0 and Python 3.12 come from the README card; `spaces` is
2
+ # preinstalled there). The torch, torchvision, numpy, timm, einops, zarr and numcodecs pins are the ones the
3
+ # AutoPipeline ink Space runs on the same hardware. The models' own code (dinovol and villa's vesuvius package) is
4
+ # downloaded at pinned commits when the Space starts, see lens.CODE.
5
+ torch==2.13.0
6
+ torchvision==0.28.0
7
+ numpy==2.5.2
8
+ timm==1.0.30
9
+ einops==0.8.2
10
+ zarr==3.4.1
11
+ numcodecs==0.16.5
12
+ pynrrd==1.1.3
13
+ pyyaml==6.0.3
14
+ requests==2.34.2
15
+ huggingface_hub>=0.34
16
+ scikit-image==0.26.0
17
+ plotly==7.1.0