Spaces:
Running on Zero
Running on Zero
Scroll Lens 1.0.0
Browse files
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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|