File size: 18,238 Bytes
475860e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a4e505
475860e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a4e505
475860e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7283d22
 
 
475860e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
"""Segment Lens on Hugging Face: the 3D page (index.html, app.js) at /, and the same atlas for AI agents and scripts
at /agents, as API endpoints (gradio_client) and MCP tools (/agents/gradio_api/mcp/).

Everything comes from the Segment Atlas dataset (SharpMeow/vesuvius-segment-atlas): atlas.jsonl, one row per
published tifxyz mesh, and lens/*.slg.gz, the decimated previews (format: "Lens files" in docs/tools/atlas.md of
ChaseHendrick/Scrolls). Nothing is written and no scan data is read. Previews exist only for scrolls on no prize
list, as on the page.

  python3 app.py            # serves on port 7860 (SEGMENT_LENS_DATA=URL/ reads another copy of the atlas)
"""

import functools
import gzip
import io
import json
import math
import os
import struct
import threading
import time
import urllib.request
from collections import OrderedDict
from pathlib import Path

VERSION = "1.1.0"
HERE = Path(__file__).resolve().parent
DATA = os.environ.get("SEGMENT_LENS_DATA",
                      "https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/")
S3 = "https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com/"
MAGIC = b"SLG1"
ATLAS_TTL_S = 3600
LENS_CACHE = 32
SORTS = ("area_cm2", "filled_share", "within_10pct", "holes_mm2", "folds_mm2", "stretched_mm2", "grid_h", "segment")
# the page's colors (app.js COLORS), as 0-255 RGB: plain, hole, fold, stretched
COLORS = {"base": (204, 214, 219), "hole": (89, 166, 255), "fold": (255, 92, 92), "str": (255, 166, 51)}
WEB_FILES = ("index.html", "app.js", "style.css", "connect.html", "connect.json", "CONNECT.md", "onescroll.html")
SUMMARY_KEYS = ("id", "scroll", "segment", "mesh", "kind", "volume", "voxel_um", "area_cm2", "filled_share",
                "within_10pct", "holes_mm2", "folds_mm2", "stretched_mm2", "clean", "prize_lists")


class LensError(ValueError):
    """A refusal an agent can act on: unknown id, no preview, bad argument."""


# ---------------------------------------------------------------- data

def _get(url, timeout=60):
    req = urllib.request.Request(url, headers={"User-Agent": f"segment-lens/{VERSION}"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.read()


_atlas = {"rows": None, "at": 0.0}
_atlas_lock = threading.Lock()


def atlas_rows(get=_get):
    """The atlas rows, fetched at most once an hour."""
    with _atlas_lock:
        if _atlas["rows"] is None or time.time() - _atlas["at"] > ATLAS_TTL_S:
            text = get(DATA + "atlas.jsonl").decode("utf-8")
            _atlas["rows"] = [json.loads(line) for line in text.splitlines() if line.strip()]
            _atlas["at"] = time.time()
        return _atlas["rows"]


def find_row(segment_id, rows=None):
    rows = atlas_rows() if rows is None else rows
    segment_id = (segment_id or "").strip()
    for r in rows:
        if r["id"] == segment_id:
            return r
    raise LensError(f"no mesh with id {segment_id!r}; list_segments gives the ids")


def parse_lens(blob):
    """(h, w, k, xyz (h, w, 3) float64 with NaN for missing nodes, flags (h, w) uint8) from lens bytes: the reader
    of kit.atlas.read_lens and the page's parseLens."""
    import numpy as np
    raw = gzip.decompress(blob)
    if raw[:4] != MAGIC:
        raise LensError("not a Segment Lens grid (bad magic)")
    h, w, k = struct.unpack("<HHH", raw[4:10])
    v = struct.unpack("<6f", raw[10:34])
    lo, hi = np.array(v[:3]), np.array(v[3:])
    n = h * w
    if len(raw) != 34 + 7 * n:
        raise LensError("Segment Lens grid has the wrong length")
    q = np.frombuffer(raw, "<u2", 3 * n, 34).reshape(3, h, w)
    flags = np.frombuffer(raw, np.uint8, n, 34 + 6 * n).reshape(h, w)
    span = np.where(hi - lo > 0, hi - lo, 1.0)
    xyz = np.stack([lo[i] + q[i] / 65534.0 * span[i] for i in range(3)], -1)
    xyz[q[0] == 65535] = np.nan
    return h, w, k, xyz, flags


_lens = OrderedDict()
_lens_lock = threading.Lock()


def load_lens(row, get=_get):
    if not row.get("lens"):
        why = ("its scroll is on a prize list, so it has numbers but no preview (public media rule)"
               if row.get("prize_lists") else "it was not measured or has no filled nodes")
        raise LensError(f"{row['id']} has no preview: {why}")
    with _lens_lock:
        if row["lens"] in _lens:
            _lens.move_to_end(row["lens"])
            return _lens[row["lens"]]
    g = parse_lens(get(DATA + "lens/" + row["lens"]))
    with _lens_lock:
        _lens[row["lens"]] = g
        while len(_lens) > LENS_CACHE:
            _lens.popitem(last=False)
    return g


def flag_counts(flags):
    f = flags.ravel()
    return {"nodes": int(f.size), "hole": int(((f & 1) > 0).sum()), "fold": int(((f & 2) > 0).sum()),
            "stretched": int(((f & 4) > 0).sum())}


def node_colors(flags):
    """One RGB per preview node, with the page's precedence: fold over stretched over hole over plain."""
    import numpy as np
    c = np.empty(flags.shape + (3,), np.uint8)
    c[:] = COLORS["base"]
    c[(flags & 1) > 0] = COLORS["hole"]
    c[(flags & 4) > 0] = COLORS["str"]
    c[(flags & 2) > 0] = COLORS["fold"]
    return c


# ---------------------------------------------------------------- pictures

def render_grid(flags, xyz, size=512):
    """The preview grid flat (rows down, columns across), each node colored by its flag; missing nodes black."""
    import numpy as np
    from PIL import Image
    c = node_colors(flags)
    c[np.isnan(xyz[..., 0])] = (12, 16, 20)
    h, w = flags.shape
    s = max(1, size // max(h, w))
    return Image.fromarray(np.repeat(np.repeat(c, s, 0), s, 1))


def render_3d(flags, xyz, azimuth=30.0, elevation=20.0, size=512):
    """An orthographic picture of the surface from (azimuth, elevation) degrees, the scan's z up: each grid cell
    filled as two triangles' worth of points, shaded by its facing, colored by flag; nearest wins."""
    import numpy as np
    from PIL import Image
    ok = ~np.isnan(xyz[..., 0])
    if ok.sum() < 4:
        raise LensError("the preview has too few nodes to draw")
    # x, y across, z up (the page rotates the same way)
    p = xyz[..., [0, 1, 2]].copy()
    center = np.nanmean(p[ok], 0)
    p -= center
    scale = np.nanmax(np.abs(p[ok])) or 1.0
    p /= scale
    a, e = math.radians(azimuth), math.radians(elevation)
    right = np.array([math.cos(a), math.sin(a), 0.0])
    view = np.array([-math.sin(a) * math.cos(e), math.cos(a) * math.cos(e), math.sin(e)])
    up = np.cross(right, view)
    # normals from the grid's neighbors, for shading
    dy = np.gradient(np.nan_to_num(p), axis=0)
    dx = np.gradient(np.nan_to_num(p), axis=1)
    nrm = np.cross(dx, dy)
    nrm /= np.linalg.norm(nrm, axis=-1, keepdims=True) + 1e-9
    light = np.abs(nrm @ view) * 0.75 + 0.25
    # densify: 4 x 4 samples per cell, bilinear between the cell's corners
    h, w = flags.shape
    t = np.linspace(0, 1, 4, endpoint=False)
    rr, cc = np.meshgrid(t, t, indexing="ij")
    P00, P01, P10, P11 = p[:-1, :-1], p[:-1, 1:], p[1:, :-1], p[1:, 1:]
    cell_ok = ok[:-1, :-1] & ok[:-1, 1:] & ok[1:, :-1] & ok[1:, 1:]
    pts = (P00[..., None, None, :] * ((1 - rr) * (1 - cc))[..., None] + P01[..., None, None, :] * ((1 - rr) * cc)[..., None]
           + P10[..., None, None, :] * (rr * (1 - cc))[..., None] + P11[..., None, None, :] * (rr * cc)[..., None])
    pts = pts[cell_ok].reshape(-1, 3)
    col = (node_colors(flags)[:-1, :-1][cell_ok].astype(np.float32) * light[:-1, :-1][cell_ok][:, None])
    col = np.repeat(col, 16, 0)
    u = pts @ right
    v = pts @ up
    d = pts @ view
    half = size / 2 * 0.92
    px = np.clip((u * half + size / 2).astype(int), 0, size - 1)
    py = np.clip((size / 2 - v * half).astype(int), 0, size - 1)
    order = np.argsort(d)  # far first, near last, so near overwrite
    img = np.full((size, size, 3), (5, 8, 11), np.uint8)
    img[py[order], px[order]] = np.clip(col[order], 0, 255).astype(np.uint8)
    return Image.fromarray(img)


# ---------------------------------------------------------------- tools

def page_url(segment_id=""):
    host = os.environ.get("SPACE_HOST")
    base = f"https://{host}/" if host else "/"
    return base + (f"#id={segment_id}" if segment_id else "")


def kit_command(row):
    return f"python -m kit tifxyz check {row['url'].replace(S3, 's3://vesuvius-challenge-open-data/')} --png check.png"


def summary(row):
    out = {k: row.get(k) for k in SUMMARY_KEYS}
    out["has_preview"] = bool(row.get("lens"))
    return out


def list_scrolls():
    """Every scroll in the Segment Atlas, with how many top-level meshes it has, how many have a 3D preview, and
    their total area.

    Returns:
        JSON: scrolls (scroll, meshes, previews, area_cm2, prize_lists), total_meshes, version, open_in_browser.
    """
    rows = [r for r in atlas_rows() if r.get("kind") == "mesh"]
    by = {}
    for r in rows:
        s = by.setdefault(r["scroll"], {"scroll": r["scroll"], "meshes": 0, "previews": 0, "area_cm2": 0.0,
                                        "prize_lists": sorted(set(r.get("prize_lists") or []))})
        s["meshes"] += 1
        s["previews"] += bool(r.get("lens"))
        s["area_cm2"] = round(s["area_cm2"] + (r.get("area_cm2") or 0.0), 3)
    return {"scrolls": sorted(by.values(), key=lambda s: s["scroll"]), "total_meshes": len(rows),
            "version": VERSION, "open_in_browser": page_url()}


def list_segments(scroll: str = "", sort: str = "area_cm2", top: int = 50, previews_only: bool = False):
    """The measured meshes of one scroll (or all scrolls), largest first by default: each mesh's id, area, filled
    share, isometry (share of edges within 10 percent) and the area of its holes, folds and stretched cells.

    Args:
        scroll: a scroll name as list_scrolls gives it, such as PHerc0139; empty for every scroll.
        sort: one of area_cm2, filled_share, within_10pct, holes_mm2, folds_mm2, stretched_mm2, grid_h, segment.
            Numbers sort largest first, segment names A to Z.
        top: how many rows to return, 1 to 500.
        previews_only: only meshes with a 3D preview (scrolls on no prize list).

    Returns:
        JSON: segments (one summary per mesh, with has_preview), count (before the top cut), and the call's settings.
    """
    if sort not in SORTS:
        raise LensError(f"sort must be one of {', '.join(SORTS)}")
    top = int(top)
    if not 1 <= top <= 500:
        raise LensError("top must be 1 to 500")
    scroll = (scroll or "").strip()
    rows = [r for r in atlas_rows() if r.get("kind") == "mesh" and (not scroll or r["scroll"] == scroll)]
    if scroll and not rows:
        raise LensError(f"no meshes for scroll {scroll!r}; list_scrolls gives the names")
    if previews_only:
        rows = [r for r in rows if r.get("lens")]
    if sort == "segment":
        rows = sorted(rows, key=lambda r: r["segment"])
    else:
        rows = sorted(rows, key=lambda r: -(r.get(sort) if r.get(sort) is not None else -math.inf))
    return {"segments": [summary(r) for r in rows[:top]], "count": len(rows), "scroll": scroll or None,
            "sort": sort, "top": top}


def get_segment(segment_id: str):
    """Everything the Segment Atlas knows about one mesh: its open-data folder, grid size, area, isometry,
    holes, folds and stretched cells (nodes and mm2), z range, the kit command that reproduces the numbers, and,
    when it has a preview, the preview's flag counts.

    Args:
        segment_id: the mesh's id, as list_segments gives it (SCROLL/SEGMENT/MESH).

    Returns:
        JSON: the atlas row, plus kit_command, preview (grid size and flag counts, or why there is none) and
        open_in_browser.
    """
    row = find_row(segment_id)
    out = dict(row)
    out["kit_command"] = kit_command(row) if row.get("url") else None
    try:
        h, w, k, xyz, flags = load_lens(row)
        out["preview"] = {"grid_w": w, "grid_h": h, "every_kth_node": k, "flags": flag_counts(flags)}
    except LensError as exc:
        out["preview"] = {"none": str(exc)}
    out["open_in_browser"] = page_url(row["id"])
    return out


def segment_picture(segment_id: str, view: str = "3d", azimuth: float = 30.0, elevation: float = 20.0):
    """A picture of one mesh's preview with its flags painted on, as on the page: holes blue, folds red,
    stretched cells orange, plain gray. Geometry only: no CT, no ink, and a clean surface is not proof the trace
    stays on one sheet. Only meshes with a preview (scrolls on no prize list) can be drawn.

    Args:
        segment_id: the mesh's id, as list_segments gives it.
        view: "3d" for the surface seen from azimuth and elevation (the scan's z up), or "grid" for the flat grid.
        azimuth: degrees around the scan's z axis, for view 3d.
        elevation: degrees above the horizontal, -90 to 90, for view 3d.

    Returns:
        A PNG picture, and JSON: id, view, grid size, every_kth_node, flag counts, open_in_browser.
    """
    if view not in ("3d", "grid"):
        raise LensError('view must be "3d" or "grid"')
    if not -90 <= float(elevation) <= 90:
        raise LensError("elevation must be -90 to 90")
    row = find_row(segment_id)
    h, w, k, xyz, flags = load_lens(row)
    img = render_grid(flags, xyz) if view == "grid" else render_3d(flags, xyz, float(azimuth), float(elevation))
    info = {"id": row["id"], "view": view, "grid_w": w, "grid_h": h, "every_kth_node": k,
            "flags": flag_counts(flags), "open_in_browser": page_url(row["id"])}
    if view == "3d":
        info.update(azimuth=float(azimuth), elevation=float(elevation))
    return img, info


# ---------------------------------------------------------------- app

AGENTS_TEXT = """\
# Segment Lens for agents and scripts

The same Segment Atlas the [3D page](/) shows, as API endpoints and MCP tools. Every answer that names a mesh
includes `open_in_browser`, a link that opens it on the page for a person.

- `list_scrolls`: every scroll, its mesh and preview counts and total area.
- `list_segments`: one scroll's meshes (or all), sorted, with area, filled share, isometry and flagged areas.
- `get_segment`: one mesh's full atlas row, the `kit tifxyz check` command and the preview's flag counts.
- `segment_picture`: a picture of one mesh with holes, folds and stretched cells painted on (3D or flat grid).

**MCP**: add the server `{url}/agents/gradio_api/mcp/`.

**Python**:

```python
from gradio_client import Client
c = Client("{url}/agents/")
top = c.predict("PHerc0139", "area_cm2", 5, True, api_name="/list_segments")
seg = c.predict(top["segments"][0]["id"], api_name="/get_segment")
```

Geometry only: no CT, no ink, and nothing here judges whether a segment stays on one sheet. Data: Vesuvius
Challenge open data via the Segment Atlas, CC BY-NC 4.0. Segment Lens {version}.
"""


def fix_mcp_multiple_outputs():
    """Gradio 6.28's MCP server joins a queued tool's several outputs into one text (the same patch as Scroll Lens),
    so segment_picture's image would reach an agent as text. Hand them over one by one."""
    try:
        from gradio.mcp import GradioMCPServer
        from anyio.to_thread import run_sync
    except ImportError:
        return

    async def without_progress(self, job):
        result = await run_sync(job.result)
        return list(result) if isinstance(result, tuple) else [result]

    GradioMCPServer._execute_tool_without_progress = without_progress


def build_demo():
    import gradio as gr

    def guard(fn):
        # functools.wraps keeps the name, docstring and signature, which the API and the MCP tool list come from
        @functools.wraps(fn)
        def run(*a, **kw):
            try:
                return fn(*a, **kw)
            except LensError as exc:
                raise gr.Error(str(exc))
        return run

    url = page_url().rstrip("/") if page_url() != "/" else "https://sharpmeow-segment-lens.hf.space"
    with gr.Blocks(title="Segment Lens for agents") as demo:
        gr.Markdown(AGENTS_TEXT.format(url=url, version=VERSION))
        with gr.Row():
            sc = gr.Textbox(value="", label="scroll")
            so = gr.Dropdown(list(SORTS), value="area_cm2", label="sort")
            tp = gr.Number(value=50, precision=0, label="top")
            pv = gr.Checkbox(value=False, label="previews_only")
        sid = gr.Textbox(value="", label="segment_id")
        with gr.Row():
            vw = gr.Dropdown(["3d", "grid"], value="3d", label="view")
            az = gr.Number(value=30.0, label="azimuth")
            el = gr.Number(value=20.0, label="elevation")
        with gr.Row():
            b1, b2, b3, b4 = (gr.Button(n) for n in ("list_scrolls", "list_segments", "get_segment", "segment_picture"))
        pic = gr.Image(type="pil", label="picture", format="png")
        out = gr.JSON(label="answer")
        b1.click(guard(list_scrolls), None, out, api_name="list_scrolls")
        b2.click(guard(list_segments), [sc, so, tp, pv], out, api_name="list_segments")
        b3.click(guard(get_segment), [sid], out, api_name="get_segment")
        b4.click(guard(segment_picture), [sid, vw, az, el], [pic, out], api_name="segment_picture")
    return demo


def build_app():
    import gradio as gr
    from fastapi import FastAPI
    from fastapi.responses import FileResponse, RedirectResponse

    fix_mcp_multiple_outputs()
    app = FastAPI()
    # ssr_mode=False: on Spaces (GRADIO_SSR_MODE and GRADIO_SERVER_PORT set) server-side rendering starts a
    # Node server on port 7860, so uvicorn could not bind there and the Space stopped at startup
    app = gr.mount_gradio_app(app, build_demo(), path="/agents", mcp_server=True, footer_links=["api"], ssr_mode=False)

    @app.get("/")
    def index():
        return FileResponse(HERE / "index.html")

    def serve(name):
        def f():
            return FileResponse(HERE / name)
        return f

    for name in WEB_FILES:
        app.add_api_route("/" + name, serve(name), methods=["GET"])

    @app.get("/agents")
    def agents():
        return RedirectResponse("/agents/")

    return app


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(build_app(), host="0.0.0.0", port=int(os.environ.get("PORT", "7860")))