SharpMeow commited on
Commit
475860e
路
verified 路
1 Parent(s): ab26408

Segment Lens 1.1.0 from ChaseHendrick/Scrolls c147d69

Browse files
Files changed (8) hide show
  1. CONNECT.md +2 -1
  2. README.md +18 -2
  3. app.js +2 -2
  4. app.py +429 -0
  5. connect.html +2 -1
  6. connect.json +2 -1
  7. index.html +2 -1
  8. requirements.txt +6 -0
CONNECT.md CHANGED
@@ -72,7 +72,7 @@ xyz = np.stack([get(c) for c in "xyz"], -1) # xyz[i, j] = (x, y, z) in scan vox
72
 
73
  ## This page's code and data
74
 
75
- `https://sharpmeow-segment-lens.static.hf.space/`
76
 
77
  License: Page code Apache 2.0; data as above. python -m kit atlas show SCROLL and python -m kit tifxyz check MESH_URL reproduce the numbers from a clone of ChaseHendrick/Scrolls.
78
 
@@ -80,5 +80,6 @@ License: Page code Apache 2.0; data as above. python -m kit atlas show SCROLL an
80
 
81
  ```text
82
  The whole atlas as one CSV: https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/atlas.csv
 
83
  Page source: https://github.com/ChaseHendrick/Scrolls/tree/main/spaces/segment-lens
84
  ```
 
72
 
73
  ## This page's code and data
74
 
75
+ `https://sharpmeow-segment-lens.hf.space/`
76
 
77
  License: Page code Apache 2.0; data as above. python -m kit atlas show SCROLL and python -m kit tifxyz check MESH_URL reproduce the numbers from a clone of ChaseHendrick/Scrolls.
78
 
 
80
 
81
  ```text
82
  The whole atlas as one CSV: https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/atlas.csv
83
+ API and MCP for agents (list_scrolls, list_segments, get_segment, segment_picture): https://sharpmeow-segment-lens.hf.space/agents/
84
  Page source: https://github.com/ChaseHendrick/Scrolls/tree/main/spaces/segment-lens
85
  ```
README.md CHANGED
@@ -3,8 +3,10 @@ title: Segment Lens
3
  emoji: 馃Л
4
  colorFrom: gray
5
  colorTo: blue
6
- sdk: static
7
- app_file: index.html
 
 
8
  pinned: false
9
  license: apache-2.0
10
  short_description: Every published Herculaneum segment, measured, in 3D
@@ -37,5 +39,19 @@ What it shows is geometry only: no CT, no ink, and nothing here judges whether a
37
  papyrus. The previews keep every k-th grid node (at most 256 per side), so a small flag can look larger than it is;
38
  the numbers come from the full grid.
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  Data: Vesuvius Challenge open data, CC BY-NC 4.0, via the Segment Atlas (same license). Code: Apache 2.0,
41
  by Chase Hendrick. three.js is MIT.
 
3
  emoji: 馃Л
4
  colorFrom: gray
5
  colorTo: blue
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
  short_description: Every published Herculaneum segment, measured, in 3D
 
39
  papyrus. The previews keep every k-th grid node (at most 256 per side), so a small flag can look larger than it is;
40
  the numbers come from the full grid.
41
 
42
+ ## For agents and scripts
43
+
44
+ The same atlas is an API and an MCP server at [/agents/](https://sharpmeow-segment-lens.hf.space/agents/):
45
+ `list_scrolls`, `list_segments`, `get_segment` and `segment_picture` (a picture of one mesh with its flags painted
46
+ on). MCP: add `https://sharpmeow-segment-lens.hf.space/agents/gradio_api/mcp/`. Python:
47
+
48
+ ```python
49
+ from gradio_client import Client
50
+ c = Client("https://sharpmeow-segment-lens.hf.space/agents/")
51
+ top = c.predict("PHerc0139", "area_cm2", 5, True, api_name="/list_segments")
52
+ seg = c.predict(top["segments"][0]["id"], api_name="/get_segment")
53
+ picture, info = c.predict(seg["id"], "3d", 30, 20, api_name="/segment_picture")
54
+ ```
55
+
56
  Data: Vesuvius Challenge open data, CC BY-NC 4.0, via the Segment Atlas (same license). Code: Apache 2.0,
57
  by Chase Hendrick. three.js is MIT.
app.js CHANGED
@@ -4,7 +4,7 @@
4
  import * as THREE from 'three';
5
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
6
 
7
- const VERSION = '1.0.0';
8
  // ?data=URL/ reads another copy of the atlas (a local build, for testing); the default is the published dataset
9
  const DATA = new URLSearchParams(location.search).get('data') || 'https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/';
10
  const S3 = 'https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com/';
@@ -27,7 +27,7 @@ renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
27
  const scene = new THREE.Scene();
28
  scene.background = new THREE.Color(0x05080b);
29
  const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 100);
30
- camera.position.set(0, 0, 3.2);
31
  const controls = new OrbitControls(camera, canvas);
32
  controls.enableDamping = true;
33
  scene.add(new THREE.HemisphereLight(0xe8f4ff, 0x1a2228, 0.9));
 
4
  import * as THREE from 'three';
5
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
6
 
7
+ const VERSION = '1.1.0';
8
  // ?data=URL/ reads another copy of the atlas (a local build, for testing); the default is the published dataset
9
  const DATA = new URLSearchParams(location.search).get('data') || 'https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/';
10
  const S3 = 'https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com/';
 
27
  const scene = new THREE.Scene();
28
  scene.background = new THREE.Color(0x05080b);
29
  const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 100);
30
+ camera.position.set(0, 0, 4.0); // opens a little zoomed out, so the whole segment and some margin show
31
  const controls = new OrbitControls(camera, canvas);
32
  controls.enableDamping = true;
33
  scene.add(new THREE.HemisphereLight(0xe8f4ff, 0x1a2228, 0.9));
app.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Segment Lens on Hugging Face: the 3D page (index.html, app.js) at /, and the same atlas for AI agents and scripts
2
+ at /agents, as API endpoints (gradio_client) and MCP tools (/agents/gradio_api/mcp/).
3
+
4
+ Everything comes from the Segment Atlas dataset (SharpMeow/vesuvius-segment-atlas): atlas.jsonl, one row per
5
+ published tifxyz mesh, and lens/*.slg.gz, the decimated previews (format: "Lens files" in docs/tools/atlas.md of
6
+ ChaseHendrick/Scrolls). Nothing is written and no scan data is read. Previews exist only for scrolls on no prize
7
+ list, as on the page.
8
+
9
+ python3 app.py # serves on port 7860 (SEGMENT_LENS_DATA=URL/ reads another copy of the atlas)
10
+ """
11
+
12
+ import functools
13
+ import gzip
14
+ import io
15
+ import json
16
+ import math
17
+ import os
18
+ import struct
19
+ import threading
20
+ import time
21
+ import urllib.request
22
+ from collections import OrderedDict
23
+ from pathlib import Path
24
+
25
+ VERSION = "1.1.0"
26
+ HERE = Path(__file__).resolve().parent
27
+ DATA = os.environ.get("SEGMENT_LENS_DATA",
28
+ "https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/")
29
+ S3 = "https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com/"
30
+ MAGIC = b"SLG1"
31
+ ATLAS_TTL_S = 3600
32
+ LENS_CACHE = 32
33
+ SORTS = ("area_cm2", "filled_share", "within_10pct", "holes_mm2", "folds_mm2", "stretched_mm2", "grid_h", "segment")
34
+ # the page's colors (app.js COLORS), as 0-255 RGB: plain, hole, fold, stretched
35
+ COLORS = {"base": (204, 214, 219), "hole": (89, 166, 255), "fold": (255, 92, 92), "str": (255, 166, 51)}
36
+ WEB_FILES = ("index.html", "app.js", "style.css", "connect.html", "connect.json", "CONNECT.md")
37
+ SUMMARY_KEYS = ("id", "scroll", "segment", "mesh", "kind", "volume", "voxel_um", "area_cm2", "filled_share",
38
+ "within_10pct", "holes_mm2", "folds_mm2", "stretched_mm2", "clean", "prize_lists")
39
+
40
+
41
+ class LensError(ValueError):
42
+ """A refusal an agent can act on: unknown id, no preview, bad argument."""
43
+
44
+
45
+ # ---------------------------------------------------------------- data
46
+
47
+ def _get(url, timeout=60):
48
+ req = urllib.request.Request(url, headers={"User-Agent": f"segment-lens/{VERSION}"})
49
+ with urllib.request.urlopen(req, timeout=timeout) as r:
50
+ return r.read()
51
+
52
+
53
+ _atlas = {"rows": None, "at": 0.0}
54
+ _atlas_lock = threading.Lock()
55
+
56
+
57
+ def atlas_rows(get=_get):
58
+ """The atlas rows, fetched at most once an hour."""
59
+ with _atlas_lock:
60
+ if _atlas["rows"] is None or time.time() - _atlas["at"] > ATLAS_TTL_S:
61
+ text = get(DATA + "atlas.jsonl").decode("utf-8")
62
+ _atlas["rows"] = [json.loads(line) for line in text.splitlines() if line.strip()]
63
+ _atlas["at"] = time.time()
64
+ return _atlas["rows"]
65
+
66
+
67
+ def find_row(segment_id, rows=None):
68
+ rows = atlas_rows() if rows is None else rows
69
+ segment_id = (segment_id or "").strip()
70
+ for r in rows:
71
+ if r["id"] == segment_id:
72
+ return r
73
+ raise LensError(f"no mesh with id {segment_id!r}; list_segments gives the ids")
74
+
75
+
76
+ def parse_lens(blob):
77
+ """(h, w, k, xyz (h, w, 3) float64 with NaN for missing nodes, flags (h, w) uint8) from lens bytes: the reader
78
+ of kit.atlas.read_lens and the page's parseLens."""
79
+ import numpy as np
80
+ raw = gzip.decompress(blob)
81
+ if raw[:4] != MAGIC:
82
+ raise LensError("not a Segment Lens grid (bad magic)")
83
+ h, w, k = struct.unpack("<HHH", raw[4:10])
84
+ v = struct.unpack("<6f", raw[10:34])
85
+ lo, hi = np.array(v[:3]), np.array(v[3:])
86
+ n = h * w
87
+ if len(raw) != 34 + 7 * n:
88
+ raise LensError("Segment Lens grid has the wrong length")
89
+ q = np.frombuffer(raw, "<u2", 3 * n, 34).reshape(3, h, w)
90
+ flags = np.frombuffer(raw, np.uint8, n, 34 + 6 * n).reshape(h, w)
91
+ span = np.where(hi - lo > 0, hi - lo, 1.0)
92
+ xyz = np.stack([lo[i] + q[i] / 65534.0 * span[i] for i in range(3)], -1)
93
+ xyz[q[0] == 65535] = np.nan
94
+ return h, w, k, xyz, flags
95
+
96
+
97
+ _lens = OrderedDict()
98
+ _lens_lock = threading.Lock()
99
+
100
+
101
+ def load_lens(row, get=_get):
102
+ if not row.get("lens"):
103
+ why = ("its scroll is on a prize list, so it has numbers but no preview (public media rule)"
104
+ if row.get("prize_lists") else "it was not measured or has no filled nodes")
105
+ raise LensError(f"{row['id']} has no preview: {why}")
106
+ with _lens_lock:
107
+ if row["lens"] in _lens:
108
+ _lens.move_to_end(row["lens"])
109
+ return _lens[row["lens"]]
110
+ g = parse_lens(get(DATA + "lens/" + row["lens"]))
111
+ with _lens_lock:
112
+ _lens[row["lens"]] = g
113
+ while len(_lens) > LENS_CACHE:
114
+ _lens.popitem(last=False)
115
+ return g
116
+
117
+
118
+ def flag_counts(flags):
119
+ f = flags.ravel()
120
+ return {"nodes": int(f.size), "hole": int(((f & 1) > 0).sum()), "fold": int(((f & 2) > 0).sum()),
121
+ "stretched": int(((f & 4) > 0).sum())}
122
+
123
+
124
+ def node_colors(flags):
125
+ """One RGB per preview node, with the page's precedence: fold over stretched over hole over plain."""
126
+ import numpy as np
127
+ c = np.empty(flags.shape + (3,), np.uint8)
128
+ c[:] = COLORS["base"]
129
+ c[(flags & 1) > 0] = COLORS["hole"]
130
+ c[(flags & 4) > 0] = COLORS["str"]
131
+ c[(flags & 2) > 0] = COLORS["fold"]
132
+ return c
133
+
134
+
135
+ # ---------------------------------------------------------------- pictures
136
+
137
+ def render_grid(flags, xyz, size=512):
138
+ """The preview grid flat (rows down, columns across), each node colored by its flag; missing nodes black."""
139
+ import numpy as np
140
+ from PIL import Image
141
+ c = node_colors(flags)
142
+ c[np.isnan(xyz[..., 0])] = (12, 16, 20)
143
+ h, w = flags.shape
144
+ s = max(1, size // max(h, w))
145
+ return Image.fromarray(np.repeat(np.repeat(c, s, 0), s, 1))
146
+
147
+
148
+ def render_3d(flags, xyz, azimuth=30.0, elevation=20.0, size=512):
149
+ """An orthographic picture of the surface from (azimuth, elevation) degrees, the scan's z up: each grid cell
150
+ filled as two triangles' worth of points, shaded by its facing, colored by flag; nearest wins."""
151
+ import numpy as np
152
+ from PIL import Image
153
+ ok = ~np.isnan(xyz[..., 0])
154
+ if ok.sum() < 4:
155
+ raise LensError("the preview has too few nodes to draw")
156
+ # x, y across, z up (the page rotates the same way)
157
+ p = xyz[..., [0, 1, 2]].copy()
158
+ center = np.nanmean(p[ok], 0)
159
+ p -= center
160
+ scale = np.nanmax(np.abs(p[ok])) or 1.0
161
+ p /= scale
162
+ a, e = math.radians(azimuth), math.radians(elevation)
163
+ right = np.array([math.cos(a), math.sin(a), 0.0])
164
+ view = np.array([-math.sin(a) * math.cos(e), math.cos(a) * math.cos(e), math.sin(e)])
165
+ up = np.cross(right, view)
166
+ # normals from the grid's neighbors, for shading
167
+ dy = np.gradient(np.nan_to_num(p), axis=0)
168
+ dx = np.gradient(np.nan_to_num(p), axis=1)
169
+ nrm = np.cross(dx, dy)
170
+ nrm /= np.linalg.norm(nrm, axis=-1, keepdims=True) + 1e-9
171
+ light = np.abs(nrm @ view) * 0.75 + 0.25
172
+ # densify: 4 x 4 samples per cell, bilinear between the cell's corners
173
+ h, w = flags.shape
174
+ t = np.linspace(0, 1, 4, endpoint=False)
175
+ rr, cc = np.meshgrid(t, t, indexing="ij")
176
+ P00, P01, P10, P11 = p[:-1, :-1], p[:-1, 1:], p[1:, :-1], p[1:, 1:]
177
+ cell_ok = ok[:-1, :-1] & ok[:-1, 1:] & ok[1:, :-1] & ok[1:, 1:]
178
+ pts = (P00[..., None, None, :] * ((1 - rr) * (1 - cc))[..., None] + P01[..., None, None, :] * ((1 - rr) * cc)[..., None]
179
+ + P10[..., None, None, :] * (rr * (1 - cc))[..., None] + P11[..., None, None, :] * (rr * cc)[..., None])
180
+ pts = pts[cell_ok].reshape(-1, 3)
181
+ col = (node_colors(flags)[:-1, :-1][cell_ok].astype(np.float32) * light[:-1, :-1][cell_ok][:, None])
182
+ col = np.repeat(col, 16, 0)
183
+ u = pts @ right
184
+ v = pts @ up
185
+ d = pts @ view
186
+ half = size / 2 * 0.92
187
+ px = np.clip((u * half + size / 2).astype(int), 0, size - 1)
188
+ py = np.clip((size / 2 - v * half).astype(int), 0, size - 1)
189
+ order = np.argsort(d) # far first, near last, so near overwrite
190
+ img = np.full((size, size, 3), (5, 8, 11), np.uint8)
191
+ img[py[order], px[order]] = np.clip(col[order], 0, 255).astype(np.uint8)
192
+ return Image.fromarray(img)
193
+
194
+
195
+ # ---------------------------------------------------------------- tools
196
+
197
+ def page_url(segment_id=""):
198
+ host = os.environ.get("SPACE_HOST")
199
+ base = f"https://{host}/" if host else "/"
200
+ return base + (f"#id={segment_id}" if segment_id else "")
201
+
202
+
203
+ def kit_command(row):
204
+ return f"python -m kit tifxyz check {row['url'].replace(S3, 's3://vesuvius-challenge-open-data/')} --png check.png"
205
+
206
+
207
+ def summary(row):
208
+ out = {k: row.get(k) for k in SUMMARY_KEYS}
209
+ out["has_preview"] = bool(row.get("lens"))
210
+ return out
211
+
212
+
213
+ def list_scrolls():
214
+ """Every scroll in the Segment Atlas, with how many top-level meshes it has, how many have a 3D preview, and
215
+ their total area.
216
+
217
+ Returns:
218
+ JSON: scrolls (scroll, meshes, previews, area_cm2, prize_lists), total_meshes, version, open_in_browser.
219
+ """
220
+ rows = [r for r in atlas_rows() if r.get("kind") == "mesh"]
221
+ by = {}
222
+ for r in rows:
223
+ s = by.setdefault(r["scroll"], {"scroll": r["scroll"], "meshes": 0, "previews": 0, "area_cm2": 0.0,
224
+ "prize_lists": sorted(set(r.get("prize_lists") or []))})
225
+ s["meshes"] += 1
226
+ s["previews"] += bool(r.get("lens"))
227
+ s["area_cm2"] = round(s["area_cm2"] + (r.get("area_cm2") or 0.0), 3)
228
+ return {"scrolls": sorted(by.values(), key=lambda s: s["scroll"]), "total_meshes": len(rows),
229
+ "version": VERSION, "open_in_browser": page_url()}
230
+
231
+
232
+ def list_segments(scroll: str = "", sort: str = "area_cm2", top: int = 50, previews_only: bool = False):
233
+ """The measured meshes of one scroll (or all scrolls), largest first by default: each mesh's id, area, filled
234
+ share, isometry (share of edges within 10 percent) and the area of its holes, folds and stretched cells.
235
+
236
+ Args:
237
+ scroll: a scroll name as list_scrolls gives it, such as PHerc0139; empty for every scroll.
238
+ sort: one of area_cm2, filled_share, within_10pct, holes_mm2, folds_mm2, stretched_mm2, grid_h, segment.
239
+ Numbers sort largest first, segment names A to Z.
240
+ top: how many rows to return, 1 to 500.
241
+ previews_only: only meshes with a 3D preview (scrolls on no prize list).
242
+
243
+ Returns:
244
+ JSON: segments (one summary per mesh, with has_preview), count (before the top cut), and the call's settings.
245
+ """
246
+ if sort not in SORTS:
247
+ raise LensError(f"sort must be one of {', '.join(SORTS)}")
248
+ top = int(top)
249
+ if not 1 <= top <= 500:
250
+ raise LensError("top must be 1 to 500")
251
+ scroll = (scroll or "").strip()
252
+ rows = [r for r in atlas_rows() if r.get("kind") == "mesh" and (not scroll or r["scroll"] == scroll)]
253
+ if scroll and not rows:
254
+ raise LensError(f"no meshes for scroll {scroll!r}; list_scrolls gives the names")
255
+ if previews_only:
256
+ rows = [r for r in rows if r.get("lens")]
257
+ if sort == "segment":
258
+ rows = sorted(rows, key=lambda r: r["segment"])
259
+ else:
260
+ rows = sorted(rows, key=lambda r: -(r.get(sort) if r.get(sort) is not None else -math.inf))
261
+ return {"segments": [summary(r) for r in rows[:top]], "count": len(rows), "scroll": scroll or None,
262
+ "sort": sort, "top": top}
263
+
264
+
265
+ def get_segment(segment_id: str):
266
+ """Everything the Segment Atlas knows about one mesh: its open-data folder, grid size, area, isometry,
267
+ holes, folds and stretched cells (nodes and mm2), z range, the kit command that reproduces the numbers, and,
268
+ when it has a preview, the preview's flag counts.
269
+
270
+ Args:
271
+ segment_id: the mesh's id, as list_segments gives it (SCROLL/SEGMENT/MESH).
272
+
273
+ Returns:
274
+ JSON: the atlas row, plus kit_command, preview (grid size and flag counts, or why there is none) and
275
+ open_in_browser.
276
+ """
277
+ row = find_row(segment_id)
278
+ out = dict(row)
279
+ out["kit_command"] = kit_command(row) if row.get("url") else None
280
+ try:
281
+ h, w, k, xyz, flags = load_lens(row)
282
+ out["preview"] = {"grid_w": w, "grid_h": h, "every_kth_node": k, "flags": flag_counts(flags)}
283
+ except LensError as exc:
284
+ out["preview"] = {"none": str(exc)}
285
+ out["open_in_browser"] = page_url(row["id"])
286
+ return out
287
+
288
+
289
+ def segment_picture(segment_id: str, view: str = "3d", azimuth: float = 30.0, elevation: float = 20.0):
290
+ """A picture of one mesh's preview with its flags painted on, as on the page: holes blue, folds red,
291
+ stretched cells orange, plain grey. Geometry only: no CT, no ink, and a clean surface is not proof the trace
292
+ stays on one sheet. Only meshes with a preview (scrolls on no prize list) can be drawn.
293
+
294
+ Args:
295
+ segment_id: the mesh's id, as list_segments gives it.
296
+ view: "3d" for the surface seen from azimuth and elevation (the scan's z up), or "grid" for the flat grid.
297
+ azimuth: degrees around the scan's z axis, for view 3d.
298
+ elevation: degrees above the horizontal, -90 to 90, for view 3d.
299
+
300
+ Returns:
301
+ A PNG picture, and JSON: id, view, grid size, every_kth_node, flag counts, open_in_browser.
302
+ """
303
+ if view not in ("3d", "grid"):
304
+ raise LensError('view must be "3d" or "grid"')
305
+ if not -90 <= float(elevation) <= 90:
306
+ raise LensError("elevation must be -90 to 90")
307
+ row = find_row(segment_id)
308
+ h, w, k, xyz, flags = load_lens(row)
309
+ img = render_grid(flags, xyz) if view == "grid" else render_3d(flags, xyz, float(azimuth), float(elevation))
310
+ info = {"id": row["id"], "view": view, "grid_w": w, "grid_h": h, "every_kth_node": k,
311
+ "flags": flag_counts(flags), "open_in_browser": page_url(row["id"])}
312
+ if view == "3d":
313
+ info.update(azimuth=float(azimuth), elevation=float(elevation))
314
+ return img, info
315
+
316
+
317
+ # ---------------------------------------------------------------- app
318
+
319
+ AGENTS_TEXT = """\
320
+ # Segment Lens for agents and scripts
321
+
322
+ The same Segment Atlas the [3D page](/) shows, as API endpoints and MCP tools. Every answer that names a mesh
323
+ includes `open_in_browser`, a link that opens it on the page for a person.
324
+
325
+ - `list_scrolls`: every scroll, its mesh and preview counts and total area.
326
+ - `list_segments`: one scroll's meshes (or all), sorted, with area, filled share, isometry and flagged areas.
327
+ - `get_segment`: one mesh's full atlas row, the `kit tifxyz check` command and the preview's flag counts.
328
+ - `segment_picture`: a picture of one mesh with holes, folds and stretched cells painted on (3D or flat grid).
329
+
330
+ **MCP**: add the server `{url}/agents/gradio_api/mcp/`.
331
+
332
+ **Python**:
333
+
334
+ ```python
335
+ from gradio_client import Client
336
+ c = Client("{url}/agents/")
337
+ top = c.predict("PHerc0139", "area_cm2", 5, True, api_name="/list_segments")
338
+ seg = c.predict(top["segments"][0]["id"], api_name="/get_segment")
339
+ ```
340
+
341
+ Geometry only: no CT, no ink, and nothing here judges whether a segment stays on one sheet. Data: Vesuvius
342
+ Challenge open data via the Segment Atlas, CC BY-NC 4.0. Segment Lens {version}.
343
+ """
344
+
345
+
346
+ def fix_mcp_multiple_outputs():
347
+ """Gradio 6.28's MCP server joins a queued tool's several outputs into one text (the same patch as Scroll Lens),
348
+ so segment_picture's image would reach an agent as text. Hand them over one by one."""
349
+ try:
350
+ from gradio.mcp import GradioMCPServer
351
+ from anyio.to_thread import run_sync
352
+ except ImportError:
353
+ return
354
+
355
+ async def without_progress(self, job):
356
+ result = await run_sync(job.result)
357
+ return list(result) if isinstance(result, tuple) else [result]
358
+
359
+ GradioMCPServer._execute_tool_without_progress = without_progress
360
+
361
+
362
+ def build_demo():
363
+ import gradio as gr
364
+
365
+ def guard(fn):
366
+ # functools.wraps keeps the name, docstring and signature, which the API and the MCP tool list come from
367
+ @functools.wraps(fn)
368
+ def run(*a, **kw):
369
+ try:
370
+ return fn(*a, **kw)
371
+ except LensError as exc:
372
+ raise gr.Error(str(exc))
373
+ return run
374
+
375
+ url = page_url().rstrip("/") if page_url() != "/" else "https://sharpmeow-segment-lens.hf.space"
376
+ with gr.Blocks(title="Segment Lens for agents") as demo:
377
+ gr.Markdown(AGENTS_TEXT.format(url=url, version=VERSION))
378
+ with gr.Row():
379
+ sc = gr.Textbox(value="", label="scroll")
380
+ so = gr.Dropdown(list(SORTS), value="area_cm2", label="sort")
381
+ tp = gr.Number(value=50, precision=0, label="top")
382
+ pv = gr.Checkbox(value=False, label="previews_only")
383
+ sid = gr.Textbox(value="", label="segment_id")
384
+ with gr.Row():
385
+ vw = gr.Dropdown(["3d", "grid"], value="3d", label="view")
386
+ az = gr.Number(value=30.0, label="azimuth")
387
+ el = gr.Number(value=20.0, label="elevation")
388
+ with gr.Row():
389
+ b1, b2, b3, b4 = (gr.Button(n) for n in ("list_scrolls", "list_segments", "get_segment", "segment_picture"))
390
+ pic = gr.Image(type="pil", label="picture", format="png")
391
+ out = gr.JSON(label="answer")
392
+ b1.click(guard(list_scrolls), None, out, api_name="list_scrolls")
393
+ b2.click(guard(list_segments), [sc, so, tp, pv], out, api_name="list_segments")
394
+ b3.click(guard(get_segment), [sid], out, api_name="get_segment")
395
+ b4.click(guard(segment_picture), [sid, vw, az, el], [pic, out], api_name="segment_picture")
396
+ return demo
397
+
398
+
399
+ def build_app():
400
+ import gradio as gr
401
+ from fastapi import FastAPI
402
+ from fastapi.responses import FileResponse, RedirectResponse
403
+
404
+ fix_mcp_multiple_outputs()
405
+ app = FastAPI()
406
+ app = gr.mount_gradio_app(app, build_demo(), path="/agents", mcp_server=True, footer_links=["api"])
407
+
408
+ @app.get("/")
409
+ def index():
410
+ return FileResponse(HERE / "index.html")
411
+
412
+ def serve(name):
413
+ def f():
414
+ return FileResponse(HERE / name)
415
+ return f
416
+
417
+ for name in WEB_FILES:
418
+ app.add_api_route("/" + name, serve(name), methods=["GET"])
419
+
420
+ @app.get("/agents")
421
+ def agents():
422
+ return RedirectResponse("/agents/")
423
+
424
+ return app
425
+
426
+
427
+ if __name__ == "__main__":
428
+ import uvicorn
429
+ uvicorn.run(build_app(), host="0.0.0.0", port=int(os.environ.get("PORT", "7860")))
connect.html CHANGED
@@ -57,9 +57,10 @@ xyz = np.stack([get(c) for c in &quot;xyz&quot;], -1) # xyz[i, j] = (x, y, z) i
57
  </div>
58
  <div class="scc-src" id="connect-page"><h3>This page&#x27;s code and data</h3>
59
  <p class="scc-meta">Page code Apache 2.0; data as above 路 python -m kit atlas show SCROLL and python -m kit tifxyz check MESH_URL reproduce the numbers from a clone of ChaseHendrick/Scrolls.</p>
60
- <div class="scc-url"><button type="button" class="scc-copy" data-testid="connect-copy" onclick="navigator.clipboard&&navigator.clipboard.writeText(this.parentNode.querySelector('code').innerText).then(()=>{this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1200)})">Copy</button><pre><code>https://sharpmeow-segment-lens.static.hf.space/</code></pre></div>
61
  <details><summary>Links and embed<span class="scc-b ok">tested 2026-10-24</span></summary>
62
  <div class="scc-code"><button type="button" class="scc-copy" data-testid="connect-copy" onclick="navigator.clipboard&&navigator.clipboard.writeText(this.parentNode.querySelector('code').innerText).then(()=>{this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1200)})">Copy</button><pre><code>The whole atlas as one CSV: https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/atlas.csv
 
63
  Page source: https://github.com/ChaseHendrick/Scrolls/tree/main/spaces/segment-lens</code></pre></div></details>
64
  </div>
65
  <footer>Machine-readable: <a href="https://huggingface.co/spaces/SharpMeow/segment-lens/blob/main/connect.json" target="_blank" rel="noopener">connect.json</a> and <a href="https://huggingface.co/spaces/SharpMeow/segment-lens/blob/main/CONNECT.md" target="_blank" rel="noopener">CONNECT.md</a>. Checked 2026-10-24. "Tested" means the snippet ran against this public data on that date. Scan data keeps its own license (the Vesuvius Challenge open data is CC BY-NC 4.0).</footer></section>
 
57
  </div>
58
  <div class="scc-src" id="connect-page"><h3>This page&#x27;s code and data</h3>
59
  <p class="scc-meta">Page code Apache 2.0; data as above 路 python -m kit atlas show SCROLL and python -m kit tifxyz check MESH_URL reproduce the numbers from a clone of ChaseHendrick/Scrolls.</p>
60
+ <div class="scc-url"><button type="button" class="scc-copy" data-testid="connect-copy" onclick="navigator.clipboard&&navigator.clipboard.writeText(this.parentNode.querySelector('code').innerText).then(()=>{this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1200)})">Copy</button><pre><code>https://sharpmeow-segment-lens.hf.space/</code></pre></div>
61
  <details><summary>Links and embed<span class="scc-b ok">tested 2026-10-24</span></summary>
62
  <div class="scc-code"><button type="button" class="scc-copy" data-testid="connect-copy" onclick="navigator.clipboard&&navigator.clipboard.writeText(this.parentNode.querySelector('code').innerText).then(()=>{this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1200)})">Copy</button><pre><code>The whole atlas as one CSV: https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/atlas.csv
63
+ API and MCP for agents (list_scrolls, list_segments, get_segment, segment_picture): https://sharpmeow-segment-lens.hf.space/agents/
64
  Page source: https://github.com/ChaseHendrick/Scrolls/tree/main/spaces/segment-lens</code></pre></div></details>
65
  </div>
66
  <footer>Machine-readable: <a href="https://huggingface.co/spaces/SharpMeow/segment-lens/blob/main/connect.json" target="_blank" rel="noopener">connect.json</a> and <a href="https://huggingface.co/spaces/SharpMeow/segment-lens/blob/main/CONNECT.md" target="_blank" rel="noopener">CONNECT.md</a>. Checked 2026-10-24. "Tested" means the snippet ran against this public data on that date. Scan data keeps its own license (the Vesuvius Challenge open data is CC BY-NC 4.0).</footer></section>
connect.json CHANGED
@@ -15,8 +15,9 @@
15
  "license": "CC BY-NC 4.0 (Vesuvius Challenge open data)",
16
  "note": "Every row of the atlas names its mesh folder in url; the table's mesh folder link opens it. Read live from the open-data bucket, never copied here."},
17
  {"id": "page", "title": "This page's code and data", "kind": "page-api",
18
- "url": "https://sharpmeow-segment-lens.static.hf.space/",
19
  "links": [{"label": "The whole atlas as one CSV", "url": "https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/atlas.csv"},
 
20
  {"label": "Page source", "url": "https://github.com/ChaseHendrick/Scrolls/tree/main/spaces/segment-lens"}],
21
  "license": "Page code Apache 2.0; data as above",
22
  "note": "python -m kit atlas show SCROLL and python -m kit tifxyz check MESH_URL reproduce the numbers from a clone of ChaseHendrick/Scrolls."}
 
15
  "license": "CC BY-NC 4.0 (Vesuvius Challenge open data)",
16
  "note": "Every row of the atlas names its mesh folder in url; the table's mesh folder link opens it. Read live from the open-data bucket, never copied here."},
17
  {"id": "page", "title": "This page's code and data", "kind": "page-api",
18
+ "url": "https://sharpmeow-segment-lens.hf.space/",
19
  "links": [{"label": "The whole atlas as one CSV", "url": "https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/atlas.csv"},
20
+ {"label": "API and MCP for agents (list_scrolls, list_segments, get_segment, segment_picture)", "url": "https://sharpmeow-segment-lens.hf.space/agents/"},
21
  {"label": "Page source", "url": "https://github.com/ChaseHendrick/Scrolls/tree/main/spaces/segment-lens"}],
22
  "license": "Page code Apache 2.0; data as above",
23
  "note": "python -m kit atlas show SCROLL and python -m kit tifxyz check MESH_URL reproduce the numbers from a clone of ChaseHendrick/Scrolls."}
index.html CHANGED
@@ -153,9 +153,10 @@ xyz = np.stack([get(c) for c in &quot;xyz&quot;], -1) # xyz[i, j] = (x, y, z) i
153
  </div>
154
  <div class="scc-src" id="connect-page"><h3>This page&#x27;s code and data</h3>
155
  <p class="scc-meta">Page code Apache 2.0; data as above 路 python -m kit atlas show SCROLL and python -m kit tifxyz check MESH_URL reproduce the numbers from a clone of ChaseHendrick/Scrolls.</p>
156
- <div class="scc-url"><button type="button" class="scc-copy" data-testid="connect-copy" onclick="navigator.clipboard&&navigator.clipboard.writeText(this.parentNode.querySelector('code').innerText).then(()=>{this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1200)})">Copy</button><pre><code>https://sharpmeow-segment-lens.static.hf.space/</code></pre></div>
157
  <details><summary>Links and embed<span class="scc-b ok">tested 2026-10-24</span></summary>
158
  <div class="scc-code"><button type="button" class="scc-copy" data-testid="connect-copy" onclick="navigator.clipboard&&navigator.clipboard.writeText(this.parentNode.querySelector('code').innerText).then(()=>{this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1200)})">Copy</button><pre><code>The whole atlas as one CSV: https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/atlas.csv
 
159
  Page source: https://github.com/ChaseHendrick/Scrolls/tree/main/spaces/segment-lens</code></pre></div></details>
160
  </div>
161
  <footer>Machine-readable: <a href="https://huggingface.co/spaces/SharpMeow/segment-lens/blob/main/connect.json" target="_blank" rel="noopener">connect.json</a> and <a href="https://huggingface.co/spaces/SharpMeow/segment-lens/blob/main/CONNECT.md" target="_blank" rel="noopener">CONNECT.md</a>. Checked 2026-10-24. "Tested" means the snippet ran against this public data on that date. Scan data keeps its own license (the Vesuvius Challenge open data is CC BY-NC 4.0).</footer></section>
 
153
  </div>
154
  <div class="scc-src" id="connect-page"><h3>This page&#x27;s code and data</h3>
155
  <p class="scc-meta">Page code Apache 2.0; data as above 路 python -m kit atlas show SCROLL and python -m kit tifxyz check MESH_URL reproduce the numbers from a clone of ChaseHendrick/Scrolls.</p>
156
+ <div class="scc-url"><button type="button" class="scc-copy" data-testid="connect-copy" onclick="navigator.clipboard&&navigator.clipboard.writeText(this.parentNode.querySelector('code').innerText).then(()=>{this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1200)})">Copy</button><pre><code>https://sharpmeow-segment-lens.hf.space/</code></pre></div>
157
  <details><summary>Links and embed<span class="scc-b ok">tested 2026-10-24</span></summary>
158
  <div class="scc-code"><button type="button" class="scc-copy" data-testid="connect-copy" onclick="navigator.clipboard&&navigator.clipboard.writeText(this.parentNode.querySelector('code').innerText).then(()=>{this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1200)})">Copy</button><pre><code>The whole atlas as one CSV: https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/atlas.csv
159
+ API and MCP for agents (list_scrolls, list_segments, get_segment, segment_picture): https://sharpmeow-segment-lens.hf.space/agents/
160
  Page source: https://github.com/ChaseHendrick/Scrolls/tree/main/spaces/segment-lens</code></pre></div></details>
161
  </div>
162
  <footer>Machine-readable: <a href="https://huggingface.co/spaces/SharpMeow/segment-lens/blob/main/connect.json" target="_blank" rel="noopener">connect.json</a> and <a href="https://huggingface.co/spaces/SharpMeow/segment-lens/blob/main/CONNECT.md" target="_blank" rel="noopener">CONNECT.md</a>. Checked 2026-10-24. "Tested" means the snippet ran against this public data on that date. Scan data keeps its own license (the Vesuvius Challenge open data is CC BY-NC 4.0).</footer></section>
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Segment Lens on a free CPU Space (Gradio 6.28.0 and Python 3.12 come from the README card). numpy reads the
2
+ # preview grids; mcp is gradio's MCP server extra (mount_gradio_app(mcp_server=True)) at the version gradio 6.28.0
3
+ # resolves with, and pydantic in the range that extra asks for. Pillow and uvicorn come with gradio.
4
+ numpy==2.5.2
5
+ mcp==1.30.0
6
+ pydantic>=2.11.10,<=2.12.5