File size: 42,667 Bytes
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0be2e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0be2e8
 
fb722f1
 
 
 
f0be2e8
 
 
 
 
 
 
 
 
 
 
 
 
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0be2e8
 
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aa04a26
 
fb722f1
aa04a26
fb722f1
aa04a26
fb722f1
 
 
 
 
 
aa04a26
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f3d4ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f3d4ae
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0be2e8
 
 
fb722f1
 
 
 
 
 
 
 
f0be2e8
 
 
 
 
 
 
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0be2e8
 
 
 
 
 
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0be2e8
 
 
 
 
 
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aa04a26
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc41ae9
aa04a26
fb722f1
 
fc41ae9
fb722f1
f0be2e8
fb722f1
 
 
f0be2e8
 
 
 
 
 
fb722f1
f0be2e8
 
fc41ae9
 
fb722f1
 
f0be2e8
fb722f1
f0be2e8
fb722f1
 
 
f0be2e8
fc41ae9
 
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aa04a26
fb722f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
"""Lasagna Fields: make villa's lasagna field (cos, grad_mag, nx, ny) for one box of a public scan.

Everything that is not the web page: the scan list (the open data's metadata.json), reading a box of CT from the
open-data bucket, running a model on it, reading the field back, the agreement check against a published field,
pictures and the download. app.py is the page and the API.

The one model today is villa's own lasagna: its `predict3d` (lasagna/preprocess_cos_omezarr.py at a pinned commit)
with the `scrollprize/lasagna` checkpoint, called in this process exactly as its command line would be, so the field
is villa's field, not a copy of it. MODELS is the slot for other models (a faster student, a better net): each entry
names its runner, and a model is offered only when its entry says it is released.

  python3 lasfield.py selftest            the plumbing on synthetic CT (no network, no model)
  python3 lasfield.py run VOLUME Z Y X [SIZE]   one field on this computer (CUDA if present, else CPU)
"""
import concurrent.futures
import gzip
import hashlib
import io
import json
import math
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import threading
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path

import numpy as np

BUCKET = "https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com"
METADATA = BUCKET + "/metadata.json"
USER_AGENT = "lasagna-fields-space (huggingface.co/spaces/SharpMeow/lasagna-fields)"
# Scans this Space never reads: PHercParis4 is kept out of every public tool of ours (Chase's rule).
REFUSED_SAMPLES = {"phercparis4"}
CACHE = Path(os.environ.get("LASFIELD_CACHE") or Path(tempfile.gettempdir()) / "lasagna-fields")

# villa's lasagna at the commit the ten whole-scan fields of this Space were made with (docs/tools/lasagna-fields.md);
# lasagna/ imports vesuvius from vesuvius/src (train_unet_3d builds the net with vesuvius' NetworkFromConfig).
VILLA = dict(repo="https://github.com/ScrollPrize/villa.git",
             tarball="https://codeload.github.com/ScrollPrize/villa/tar.gz/{commit}",
             commit="db3dbea26185a9e09ab997bfa21a2a00477f0d56", keep=("lasagna/", "vesuvius/src/"),
             check="lasagna/preprocess_cos_omezarr.py")
WEIGHTS = dict(repo="scrollprize/lasagna", revision="de45f01147adea9f4ad0f621b1ccd885a1f03760", file="model_current.pt",
               sha256="4cc0c40a846b296769b9f7982470559078662a1a0b0e1f74ff41ed9341c0d600")

TILE = 192                   # predict3d's tile at this checkpoint (its patch size), stride TILE - overlap 64
MARGIN = TILE + 16 + 4       # CT read around the box: a tile, predict3d's border and the scaledown rounding
# predict3d widens a crop to whole output chunks (64 voxels of the quarter grid: 256 input voxels), so boxes are whole
# 256-voxel blocks of the input level: anything smaller costs the same, and the field matches villa's whole-scan run.
ALIGN = 256
SIZES = (256, 512)
DEFAULT_SIZE = 256
TARGET_UM = 9.4              # the model's training resolution: 1.2 m scans at level 0, 0.2 m (2.4 um) scans at level 2
CHANNELS = ("cos", "grad_mag", "nx", "ny")


class FieldError(Exception):
    """A refusal or failure shown to the visitor as is."""


def log(message):
    print(f"[lasfield {time.strftime('%H:%M:%S')}] {message}", flush=True)


# ---------------------------------------------------------------- http

def http_get(url, timeout=60, tries=4):
    """Bytes at `url`, or None for a missing object (an unwritten chunk is a 403 or 404 on the bucket)."""
    err = None
    for i in range(tries):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return r.read()
        except urllib.error.HTTPError as exc:
            if exc.code in (403, 404):
                return None
            err = exc
        except (urllib.error.URLError, OSError) as exc:
            err = exc
        time.sleep(1.5 * (i + 1))
    raise FieldError(f"could not read {url}: {err}")


def get_json(url):
    raw = http_get(url)
    if raw is None:
        raise FieldError(f"{url} does not exist")
    if raw[:2] == b"\x1f\x8b":
        raw = gzip.decompress(raw)
    return json.loads(raw)


# ---------------------------------------------------------------- the scans

_CATALOG = {"at": 0.0, "volumes": None}
_LOCK = threading.Lock()


def level_for(um):
    """The CT level whose voxels are nearest the model's 9.4 um (0 for 8.6 to 9.4 um scans, 2 for 2.4 um)."""
    return max(0, int(round(math.log2(TARGET_UM / float(um))))) if um else 0


def catalog(refresh_s=6 * 3600):
    """Every volume of the open data's metadata.json this Space may read, by volume id."""
    with _LOCK:
        if _CATALOG["volumes"] is not None and time.time() - _CATALOG["at"] < refresh_s:
            return _CATALOG["volumes"]
        meta = get_json(METADATA)
        vols = {}
        for sid, s in sorted((meta.get("samples") or {}).items()):
            if sid.lower() in REFUSED_SAMPLES:
                continue
            for vid, v in (s.get("volumes") or {}).items():
                pr = v.get("properties") or {}
                um, shape = pr.get("pixel_size_um"), pr.get("shape")
                if not um or not v.get("long_id"):
                    continue
                vols[vid] = {"volume": vid, "sample": sid, "long_id": v["long_id"], "voxel_um": float(um),
                             "shape_zyx": shape, "level": level_for(um),
                             "superseded_by": pr.get("superseded_by"),
                             "url": f"{BUCKET}/{sid}/volumes/{v['long_id']}/"}
        _CATALOG.update(at=time.time(), volumes=vols)
        return vols


def volume(vid):
    """The catalog row for a volume id (the 14-digit id or its long name), or a refusal."""
    vid = str(vid or "").strip().rstrip("/").split("/")[-1]
    vols = catalog()
    if vid in vols:
        return vols[vid]
    for v in vols.values():
        if v["long_id"] == vid or v["long_id"].startswith(vid + "-"):
            return v
    if any(vid.lower().startswith(s) for s in REFUSED_SAMPLES):
        raise FieldError("this Space does not read PHercParis4")
    raise FieldError(f"{vid!r} is not a volume in the Vesuvius Challenge open data (metadata.json); "
                     "give its 14-digit volume id, such as 20260102150214")


def zarray(v, level):
    meta = get_json(f"{v['url']}{level}/.zarray")
    if len(meta.get("shape", ())) != 3:
        raise FieldError(f"{v['long_id']} level {level} is not a 3D array")
    return meta


_PUBLISHED = {}
# the ten whole-scan fields this Space hosts, made with the same villa commit and checkpoint as Make a field: volume id
# to (sample, run folder) in the public bucket; the check uses them where the open data has no field
OWN_BUCKET = "https://huggingface.co/buckets/SharpMeow/lasagna-fields/resolve"
OWN_FIELDS = {
    "20250521115057": ("PHerc0175A", "20250521115057-lasagna-de45f011"),
    "20250521125822": ("PHerc0175B", "20250521125822-lasagna-de45f011"),
    "20250521133212": ("PHerc0306B", "20250521133212-lasagna-de45f011"),
    "20250521140913": ("PHerc0483A", "20250521140913-lasagna-de45f011"),
    "20251124083638": ("PHerc0483B", "20251124083638-lasagna-de45f011"),
    "20250521151210": ("PHerc0490A", "20250521151210-lasagna-de45f011"),
    "20250521151215": ("PHerc0490B", "20250521151215-lasagna-de45f011"),
    "20250821151531": ("PHerc0841", "20250821151531-lasagna-de45f011"),
    "20250728152254": ("PHerc0846A", "20250728152254-lasagna-de45f011"),
    "20250804142305": ("PHerc0846B", "20250804142305-lasagna-de45f011"),
}


def published_field(v):
    """The open data's own lasagna run for this volume at its default level, if there is one: its manifest's URL
    and groups. The run folder names start with the volume id; an -L2 suffix says it read level 2."""
    key = v["volume"]
    if key in _PUBLISHED:
        return _PUBLISHED[key]
    found = _published_run(v)
    if found is None:
        # a remasked volume replaces an older one in the same voxel frame (metadata.json's superseded_by); the field
        # published for the older one is the one to check against, with the mask edge the only difference
        for old in catalog().values():
            if (old.get("superseded_by") or {}).get("volume_id") == key and old["level"] == v["level"]:
                found = _published_run(old)
                if found:
                    found = dict(found, note=f"published for {old['volume']}, which this volume replaces in the "
                                             "same voxel frame")
                    break
    if found is None and key in OWN_FIELDS and v["level"] == 0:
        found = _own_run(*OWN_FIELDS[key])
    _PUBLISHED[key] = found
    return found


def _own_run(sample, run):
    """One of this Space's ten whole-scan fields, described like an open-data run."""
    url = f"{OWN_BUCKET}/{sample}/representations/predictions/lasagna/{run}/"
    try:
        manifest = get_json(f"{url}{sample}.lasagna.json")
    except FieldError as exc:
        log(f"own field lookup for {sample} failed: {exc}")
        return None
    return {"run": run, "url": url, "manifest": f"{url}{sample}.lasagna.json", "groups": manifest.get("groups"),
            "base_shape_zyx": manifest.get("base_shape_zyx"), "source": "lasagna-fields",
            "note": "this Space's own whole-scan field, made with the same villa commit and checkpoint"}


def _published_run(v):
    found = None
    try:
        prefix = f"{v['sample']}/representations/predictions/lasagna/"
        raw = http_get(f"{BUCKET}/?list-type=2&delimiter=/&prefix={prefix}") or b""
        runs = re.findall(rb"<Prefix>([^<]+)</Prefix>", raw)
        for run in sorted(r.decode() for r in runs if r.decode() != prefix):
            name = run[len(prefix):].rstrip("/")
            tail = name.rsplit("-", 1)[-1]
            lvl = int(tail[1:]) if re.fullmatch(r"L\d", tail) else 0
            if not name.startswith(v["volume"]) or lvl != v["level"]:
                continue
            listing = http_get(f"{BUCKET}/?list-type=2&delimiter=/&prefix={run}") or b""
            js = [k.decode() for k in re.findall(rb"<Key>([^<]+\.lasagna\.json)</Key>", listing)]
            if js:
                manifest = get_json(f"{BUCKET}/{js[0]}")
                found = {"run": name, "url": f"{BUCKET}/{run}", "manifest": f"{BUCKET}/{js[0]}",
                         "groups": manifest.get("groups"), "base_shape_zyx": manifest.get("base_shape_zyx"),
                         "source": "open-data"}
    except FieldError as exc:
        log(f"published field lookup for {v['volume']} failed: {exc}")
    return found


# ---------------------------------------------------------------- a box

def plan(vid, z, y, x, size=DEFAULT_SIZE, level=None, model="lasagna"):
    """Where the field will be made: the `size`-voxel box of whole ALIGN blocks at the input level that holds
    level-0 voxel (z, y, x) (as near its middle as the grid allows), and the CT read around it. Refuses a point
    outside the scan or a size not offered."""
    v = volume(vid)
    spec = MODELS.get(model)
    if not spec or not spec["offered"]:
        raise FieldError(f"model {model!r} is not offered; choose from {', '.join(k for k, m in MODELS.items() if m['offered'])}")
    size = int(size)
    if size not in SIZES:
        raise FieldError(f"size {size} is not offered; choose from {', '.join(map(str, SIZES))}")
    level = v["level"] if level in (None, "", "auto") else int(level)
    if not 0 <= level <= 5:
        raise FieldError("level must be 0 to 5")
    base = zarray(v, 0)
    meta = zarray(v, level)
    shape = [int(s) for s in meta["shape"]]
    f = 2 ** level
    center = [int(round(float(c))) for c in (z, y, x)]
    for c, n in zip(center, base["shape"]):
        if not 0 <= c < n:
            raise FieldError(f"the point {center} is outside the scan (level-0 shape {base['shape']})")
    origin = [max(0, min((c // f - size // 2 + ALIGN // 2) // ALIGN * ALIGN, (n - 1) // ALIGN * ALIGN))
              for c, n in zip(center, shape)]
    box_size = [min(size, n - o) for o, n in zip(origin, shape)]
    read = [[max(0, o - MARGIN), min(n, o + s + MARGIN)] for o, s, n in zip(origin, box_size, shape)]
    ch = meta["chunks"]
    nchunks = math.prod((b - 1) // c - a // c + 1 for (a, b), c in zip(read, ch))
    out = {"volume": v["volume"], "sample": v["sample"], "long_id": v["long_id"], "voxel_um": v["voxel_um"],
            "url": v["url"], "level": level, "level_shape_zyx": shape, "base_shape_zyx": base["shape"],
            "center_level0_zyx": center, "origin_zyx": origin, "size_zyx": box_size,
            "origin_level0_zyx": [o * f for o in origin], "size_level0_zyx": [s * f for s in box_size],
            "read_zyx": read, "chunks": nchunks, "chunk_bytes": math.prod(ch) * np.dtype(meta["dtype"]).itemsize,
            "model": model}
    out["tiles"] = tiles(out)
    out["gpu_s"] = gpu_seconds(out, model)
    return out


def tiles(p_or_size, tile=TILE, overlap=64, border=16, sd_max=4):
    """Tiles predict3d runs for a box. predict3d puts tiles on one lattice over the whole scan (so a box's field is
    the whole-scan field there) and runs every lattice tile that touches the box plus its border; with villa loaded
    this asks villa's own _canonical_local_tile_positions, else it counts the same lattice here."""
    if isinstance(p_or_size, dict):
        f = 2 ** p_or_size["level"]
        starts = p_or_size["origin_zyx"]
        sizes = p_or_size["size_zyx"]
        shape = p_or_size["level_shape_zyx"]
    else:
        sizes, starts, shape = list(p_or_size), [10 * 1024] * 3, [100 * 1024] * 3
    stride = tile - overlap
    try:
        from tiled_predict3d import _canonical_local_tile_positions as positions
    except ImportError:
        positions = None
    n = 1
    for s0, sz, vol in zip(starts, sizes, shape):
        padded = -(-(sz + 2 * border) // sd_max) * sd_max
        if positions is not None:
            n *= len(positions(volume_size=vol, crop_start=s0, crop_padded_size=padded, tile_size=tile,
                               stride=stride, border=border, scaledown_multiple=sd_max))
        else:
            first, last = max(0, (s0 - tile) // stride), (s0 + padded) // stride
            n *= sum(1 for k in range(first, last + 1) if k * stride - s0 < padded and k * stride - s0 + tile > 0)
    return n


def gpu_seconds(p_or_size, model="lasagna"):
    """The GPU time to ask ZeroGPU for: the model's start-up plus its per-tile time, with a margin of 1.5."""
    spec = MODELS[model]
    return int(math.ceil(1.5 * (spec["startup_s"] + spec["tile_s"] * tiles(p_or_size))))


# ---------------------------------------------------------------- the CT

def stage(p, root):
    """The CT around the box on local disk, laid out like the open data: an OME-Zarr group whose level 0 is only its
    metadata (predict3d reads the scan's base shape from it) and whose input level holds the chunks it reads."""
    root = Path(root)
    shutil.rmtree(root, ignore_errors=True)
    meta0 = get_json(f"{p['url']}0/.zarray")
    meta = get_json(f"{p['url']}{p['level']}/.zarray")
    zattrs = get_json(f"{p['url']}.zattrs")
    sep = meta.get("dimension_separator", ".")
    (root / "0").mkdir(parents=True)
    (root / "0" / ".zarray").write_text(json.dumps(meta0))
    lvl = root / str(p["level"])
    lvl.mkdir(parents=True, exist_ok=True)
    (lvl / ".zarray").write_text(json.dumps(meta))
    (root / ".zgroup").write_text(json.dumps({"zarr_format": 2}))
    (root / ".zattrs").write_text(json.dumps(zattrs))
    ch = meta["chunks"]
    keys = [(i, j, k) for i in range(p["read_zyx"][0][0] // ch[0], (p["read_zyx"][0][1] - 1) // ch[0] + 1)
            for j in range(p["read_zyx"][1][0] // ch[1], (p["read_zyx"][1][1] - 1) // ch[1] + 1)
            for k in range(p["read_zyx"][2][0] // ch[2], (p["read_zyx"][2][1] - 1) // ch[2] + 1)]

    def one(key):
        name = sep.join(map(str, key))
        raw = http_get(f"{p['url']}{p['level']}/{name}")
        if raw is None:
            return 0
        dest = lvl.joinpath(*name.split("/"))
        dest.parent.mkdir(parents=True, exist_ok=True)
        dest.write_bytes(raw)
        return len(raw)

    t = time.time()
    with concurrent.futures.ThreadPoolExecutor(32) as ex:
        sizes = list(ex.map(one, keys))
    return {"chunks": len(keys), "written": sum(1 for s in sizes if s), "bytes": sum(sizes),
            "seconds": round(time.time() - t, 2), "input": str(lvl)}


def read_box(arr_path, box):
    """One zarr array's values in a box [[z0, z1], [y0, y1], [x0, x1]] (zarr 3 reads villa's v2 output)."""
    import zarr
    a = zarr.open_array(str(arr_path), mode="r")
    return np.asarray(a[box[0][0]:box[0][1], box[1][0]:box[1][1], box[2][0]:box[2][1]])


def ct_box(p, root):
    lvl = Path(root) / str(p["level"])
    import zarr
    a = zarr.open_array(str(lvl), mode="r")
    o, s = p["origin_zyx"], p["size_zyx"]
    return np.asarray(a[o[0]:o[0] + s[0], o[1]:o[1] + s[1], o[2]:o[2] + s[2]])


# ---------------------------------------------------------------- code and weights

def sha256_file(path, block=1 << 22):
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        for part in iter(lambda: fh.read(block), b""):
            h.update(part)
    return h.hexdigest()


def _fetch_villa(dest):
    """villa's lasagna/ and vesuvius/src/ at the pinned commit: a sparse, blobless git fetch, else GitHub's tarball."""
    commit = VILLA["commit"]
    tmp = dest.with_name(dest.name + ".part")
    shutil.rmtree(tmp, ignore_errors=True)
    tmp.mkdir(parents=True)
    try:
        git = ["git", "-C", str(tmp)]
        for cmd in (["init", "-q"], ["remote", "add", "origin", VILLA["repo"]],
                    ["sparse-checkout", "set", *[k.rstrip("/") for k in VILLA["keep"]]],
                    ["fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", commit],
                    ["checkout", "-q", "FETCH_HEAD"]):
            subprocess.run(git + cmd, capture_output=True, text=True, check=True, timeout=900)
        head = subprocess.run(git + ["rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip()
        if head != commit:
            raise RuntimeError(f"git fetched {head}, not {commit}")
        shutil.rmtree(tmp / ".git", ignore_errors=True)
    except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
        log(f"git fetch of villa failed ({getattr(exc, 'stderr', None) or exc}); trying the tarball")
        shutil.rmtree(tmp, ignore_errors=True)
        tmp.mkdir(parents=True)
        root = tmp.resolve()
        req = urllib.request.Request(VILLA["tarball"].format(commit=commit), headers={"User-Agent": USER_AGENT})
        with urllib.request.urlopen(req, timeout=900) as resp, tarfile.open(fileobj=resp, mode="r|gz") as tf:
            for m in tf:
                _, _, rel = m.name.partition("/")
                if not rel.startswith(VILLA["keep"]) or m.issym() or m.islnk():
                    continue
                target = (root / rel).resolve()
                if root not in target.parents:
                    continue
                if m.isdir():
                    target.mkdir(parents=True, exist_ok=True)
                elif m.isfile():
                    target.parent.mkdir(parents=True, exist_ok=True)
                    with tf.extractfile(m) as src, open(target, "wb") as out:
                        shutil.copyfileobj(src, out)
    if not (tmp / VILLA["check"]).is_file():
        raise RuntimeError(f"villa at {commit} has no {VILLA['check']}")
    (tmp / "COMMIT").write_text(commit + "\n")
    shutil.rmtree(dest, ignore_errors=True)
    os.replace(tmp, dest)


def ensure_villa(cache=CACHE):
    """villa's lasagna on sys.path (as top-level modules and as the `lasagna` package, the way its own install lays
    them out) with vesuvius/src beside it. LASFIELD_VILLA names a local villa checkout instead."""
    if os.environ.get("LASFIELD_VILLA"):
        root = Path(os.environ["LASFIELD_VILLA"]).expanduser().resolve()
        source = "local checkout (LASFIELD_VILLA)"
    else:
        root = Path(cache) / f"villa-{VILLA['commit'][:12]}"
        if not (root / "COMMIT").is_file():
            log(f"fetching villa {VILLA['commit'][:12]}")
            _fetch_villa(root)
        source = "pinned download"
    shim = Path(cache) / "villa-shim"
    shim.mkdir(parents=True, exist_ok=True)
    link = shim / "lasagna"
    if not link.exists():
        link.symlink_to(root / "lasagna", target_is_directory=True)
    for path in (root / "vesuvius" / "src", shim, root / "lasagna"):
        if str(path) not in sys.path:
            sys.path.insert(0, str(path))
    os.environ["PYTHONPATH"] = os.pathsep.join([str(root / "lasagna"), str(shim), str(root / "vesuvius" / "src"),
                                                os.environ.get("PYTHONPATH", "")]).rstrip(os.pathsep)
    return {"source": source, "commit": VILLA["commit"], "path": str(root)}


def ensure_weights(cache=CACHE):
    """The checkpoint from the Hub at its pinned revision, refused unless its SHA-256 matches. LASFIELD_WEIGHTS names
    a local copy of model_current.pt instead (its SHA-256 is still checked)."""
    if os.environ.get("LASFIELD_WEIGHTS"):
        path = Path(os.environ["LASFIELD_WEIGHTS"]).expanduser()
    else:
        from huggingface_hub import hf_hub_download
        path = Path(hf_hub_download(WEIGHTS["repo"], WEIGHTS["file"], revision=WEIGHTS["revision"],
                                    cache_dir=str(Path(cache) / "hub")))
    digest = sha256_file(path)
    if digest != WEIGHTS["sha256"]:
        raise RuntimeError(f"{WEIGHTS['repo']}/{WEIGHTS['file']} has SHA-256 {digest}, not {WEIGHTS['sha256']}")
    return path


# ---------------------------------------------------------------- the models

def predict3d_argv(p, ct_input, out_json, checkpoint, device):
    """villa predict3d's command line for the box, as its CLI takes it: --crop is x y z w h d in the scan's base
    (level 0) voxels, which predict3d divides by the input level's factor (its log: base_crop, input_crop).
    Synchronous flush and accumulation (no worker processes): the stored field is the same."""
    o, s = p["origin_level0_zyx"], p["size_level0_zyx"]
    return ["predict3d", "--input", str(ct_input), "--output", str(out_json), "--unet-checkpoint", str(checkpoint),
            "--tile-size", str(TILE), "--crop", str(o[2]), str(o[1]), str(o[0]), str(s[2]), str(s[1]), str(s[0]),
            "--device", device, "--no-download", "--flush-workers", "0", "--accumulator-workers", "0"]


POOL_CPUS = 8                # pyramid processes predict3d may start (its default is every CPU of the host)


@__import__("contextlib").contextmanager
def _pool_friendly():
    """Let predict3d's OME-Zarr pyramid step start its process pool when it runs inside a daemon process (ZeroGPU
    runs a GPU call in one, and Python refuses children there), and cap that pool at POOL_CPUS. The pool's processes
    only downsample chunks on the CPU. Restored on exit."""
    import multiprocessing as mp
    proc = mp.current_process()
    was_daemon = proc._config.get("daemon")
    real = mp.cpu_count
    mp.cpu_count = lambda: max(1, min(POOL_CPUS, real()))
    if was_daemon:
        proc._config["daemon"] = False
    try:
        yield
    finally:
        mp.cpu_count = real
        if was_daemon:
            proc._config["daemon"] = was_daemon


def run_villa_lasagna(p, ct_input, out_dir, device, checkpoint):
    """villa's predict3d in this process. Returns its exit code, wall seconds and the tail of its log."""
    import contextlib
    import preprocess_cos_omezarr as pco
    out_dir = Path(out_dir)
    shutil.rmtree(out_dir, ignore_errors=True)
    out_dir.mkdir(parents=True)
    out_json = out_dir / f"{p['sample']}-{p['volume']}.lasagna.json"
    argv = predict3d_argv(p, ct_input, out_json, checkpoint, device)
    buf = io.StringIO()
    t = time.time()

    class Tee(io.TextIOBase):
        def write(self, s):
            buf.write(s)
            sys.__stdout__.write(s)
            return len(s)

    try:
        with contextlib.redirect_stdout(Tee()), contextlib.redirect_stderr(Tee()), _pool_friendly():
            rc = pco.cli_main(argv)
    except SystemExit as exc:
        rc = exc.code if isinstance(exc.code, int) else 1
    return {"exit": int(rc or 0), "wall_s": round(time.time() - t, 2), "argv": argv,
            "log_tail": buf.getvalue()[-6000:], "manifest": str(out_json)}


# The model slot. `offered` False keeps a model off the page and the API until it passes its own held-out test;
# Lasagnetta (the lasagna student) and any better net come in here with their own runner, checkpoint and costs.
MODELS = {
    "lasagna": {"title": "villa lasagna (exact)", "offered": True, "run": run_villa_lasagna,
                "about": "villa's own predict3d with the scrollprize/lasagna checkpoint (MIT): the same field villa "
                         "makes, at the same grids (cos at half, the rest at a quarter of the input level)",
                # 25 Oct 2026, L4: 310 ms a 192^3 tile end to end; the GPU here is faster, so these are ceilings
                "startup_s": 25, "tile_s": 0.35},
}


# ---------------------------------------------------------------- the field

def field_boxes(p, manifest):
    """Each channel's array path and the box covering the plan's box in that channel's grid."""
    man = json.loads(Path(manifest).read_text())
    root = Path(manifest).parent
    out = {}
    for name in CHANNELS:
        g = man["groups"][name]
        sd = 2 ** int(g["scaledown"])
        box = [[o // sd, -(-(o + s) // sd)] for o, s in zip(p["origin_level0_zyx"], p["size_level0_zyx"])]
        out[name] = {"path": root / g["zarr"], "scaledown": int(g["scaledown"]), "box": box}
    return out, man


def read_field(p, manifest):
    boxes, man = field_boxes(p, manifest)
    arrays = {n: read_box(b["path"], b["box"]) for n, b in boxes.items()}
    return arrays, boxes, man


def decode_normal(nx_u8, ny_u8):
    """The unit normal from stored nx, ny (villa's encode_normal_nxny_u8 flips n so that nz >= 0)."""
    nx = (nx_u8.astype(np.float32) - 128.0) / 127.0
    ny = (ny_u8.astype(np.float32) - 128.0) / 127.0
    nz = np.sqrt(np.clip(1.0 - nx * nx - ny * ny, 0.0, 1.0))
    n = np.stack([nx, ny, nz])
    return n / (np.linalg.norm(n, axis=0, keepdims=True) + 1e-8)


def _corr(a, b, mask):
    a, b = a[mask].astype(np.float64), b[mask].astype(np.float64)
    if a.size < 100 or a.std() == 0 or b.std() == 0:
        return None
    return round(float(np.corrcoef(a, b)[0, 1]), 4)


def compare(mine, theirs):
    """Agreement of two fields on the same grids: Pearson r of cos and grad_mag where either is non-zero, and the
    median angle between normals (sign ignored) where both are, over all those voxels and over the strong half (our
    grad_mag at or above its median there). Where the layer signal is weak the normal is barely defined and tiny
    numerical differences turn it, so the strong half is the fair test."""
    m_cos = (mine["cos"] > 0) | (theirs["cos"] > 0)
    m_oth = (mine["grad_mag"] > 0) | (theirs["grad_mag"] > 0)
    both = (mine["nx"] > 0) & (theirs["nx"] > 0)
    out = {"cos_r": _corr(mine["cos"], theirs["cos"], m_cos),
           "grad_mag_r": _corr(mine["grad_mag"], theirs["grad_mag"], m_oth), "normal_median_deg": None,
           "voxels": int(m_cos.sum())}
    if both.sum() >= 100:
        a, b = decode_normal(mine["nx"], mine["ny"]), decode_normal(theirs["nx"], theirs["ny"])
        dot = np.abs((a * b).sum(axis=0))
        out["normal_median_deg"] = round(float(np.degrees(np.arccos(np.clip(np.median(dot[both]), 0, 1)))), 2)
        g = mine["grad_mag"]
        strong = both & (g >= np.median(g[both]))
        if strong.sum() >= 100:
            out["normal_median_deg_strong"] = round(
                float(np.degrees(np.arccos(np.clip(np.median(dot[strong]), 0, 1)))), 2)
    return out


def read_published(pub, boxes, shift=(0, 0, 0)):
    """The published field's values in the same boxes, optionally shifted (in each channel's voxels)."""
    import zarr
    out = {}
    for name, b in boxes.items():
        g = (pub["groups"] or {}).get(name)
        if not g or int(g["scaledown"]) != b["scaledown"]:
            return None
        a = zarr.open_array(pub["url"] + g["zarr"], mode="r", zarr_format=2)
        box = [[lo + d, hi + d] for (lo, hi), d in zip(b["box"], shift)]
        if any(lo < 0 or hi > n for (lo, hi), n in zip(box, a.shape)):
            return None
        out[name] = np.asarray(a[box[0][0]:box[0][1], box[1][0]:box[1][1], box[2][0]:box[2][1]])
    return out


CONTROL_SHIFT = 48           # input-level voxels: the control reads the published field this far away in y and x


OPEN_DATA_NORMALS = ("cos and grad_mag should match the open data's field exactly. Its normals can differ in "
                     "parts of a box: villa changed how lasagna estimates normals after the open data's fields "
                     "were made (April 2026, by their run names), and Make a field runs the later code, the same "
                     "as this Space's ten whole-scan fields, which it matches exactly")


def agreement(p, arrays, boxes):
    """How close this field is to the open data's own lasagna field for the scan, where one exists, with a control
    that must score lower: the published field read CONTROL_SHIFT input voxels away along y and x (a few papyrus
    layers), the same physical offset on every channel's grid."""
    pub = published_field(volume(p["volume"]))
    if not pub:
        return {"published": None, "note": "the open data has no lasagna field for this volume at this level"}
    shift0 = CONTROL_SHIFT * 2 ** p["level"]
    try:
        same = read_published(pub, boxes)
        shifted = {}
        for name, b in boxes.items():
            d = shift0 // 2 ** b["scaledown"]
            r = read_published(pub, {name: b}, (0, d, d))
            if r is None:
                shifted = None
                break
            shifted[name] = r[name]
    except Exception as exc:  # noqa: BLE001  (reported, never fatal)
        return {"published": pub["run"], "error": f"{type(exc).__name__}: {exc}"[:300]}
    if same is None:
        return {"published": pub["run"], "note": "the published field uses other grids; not compared"}
    out = {"published": pub["run"], "published_url": pub["url"], "published_source": pub.get("source"),
           "same_box": compare(arrays, same)}
    if pub.get("note"):
        out["published_note"] = pub["note"]
    if pub.get("source") == "open-data":
        out["normals_note"] = OPEN_DATA_NORMALS
    if shifted is not None:
        out["control_shifted"] = compare(arrays, shifted)
        out["control_note"] = (f"the published field {CONTROL_SHIFT} input voxels away in y and x: "
                               "it must agree less than the same box")
    return out


# ---------------------------------------------------------------- pictures and the download

def _u8(img, lo=None, hi=None):
    img = img.astype(np.float32)
    lo = np.percentile(img, 1) if lo is None else lo
    hi = np.percentile(img, 99) if hi is None else hi
    return np.clip((img - lo) / max(hi - lo, 1e-6) * 255, 0, 255).astype(np.uint8)


def _up(img, n):
    return np.repeat(np.repeat(img, n, axis=0), n, axis=1) if n > 1 else img


def _resize(img, shape):
    """An image (H, W) or (H, W, 3) uint8 resized to `shape` (H, W) with bilinear smoothing, for display only."""
    from PIL import Image
    return np.asarray(Image.fromarray(img).resize((int(shape[1]), int(shape[0])), Image.BILINEAR))


def picture(ct, arrays, boxes, axis=0):
    """One middle slice through the box along `axis` (0 z, 1 y, 2 x): CT, cos, grad_mag and the normal as color
    (|x| red, |y| green, |z| blue), side by side at the CT's grid. Returns an RGB uint8 array."""
    def mid(a):
        return np.take(a, a.shape[axis] // 2, axis=axis)
    ct_img = _u8(mid(ct))
    shape = ct_img.shape
    panels = [np.stack([ct_img] * 3, -1)]
    for name in ("cos", "grad_mag"):
        img = _u8(mid(arrays[name]), 0, 255 if name == "cos" else None)
        panels.append(np.stack([_resize(img, shape)] * 3, -1))
    n = decode_normal(mid(arrays["nx"]), mid(arrays["ny"]))
    rgb = (np.abs(np.moveaxis(n, 0, -1)) * 255).astype(np.uint8)
    rgb[mid(arrays["nx"]) == 0] = 0
    panels.append(_resize(rgb, shape))
    gap = np.full((shape[0], 6, 3), 255, np.uint8)
    row = []
    for i, t in enumerate(panels):
        row += [t] + ([gap] if i < len(panels) - 1 else [])
    return np.concatenate(row, axis=1)


PANELS = ("CT", "cos", "grad_mag", "normal")
LEGEND = "normal as color: x red, y green, z blue · villa lasagna, model output"


def compose(pictures, title="", note=LEGEND):
    """The three rows (z, y, x middle slices) under one header naming the panels, each row named on its left, on a
    white card. Text is sized for the card to be shown about 700 px wide. Returns an RGB uint8 array."""
    from PIL import Image, ImageDraw, ImageFont
    rows = [_fit(r, pictures[0].shape[:2]) if r.shape != pictures[0].shape else r for r in pictures]
    h, w = rows[0].shape[:2]
    k = max(1.0, w / 640)
    try:
        font = ImageFont.load_default(size=round(14 * k))
        bold = ImageFont.load_default(size=round(16 * k))
    except TypeError:                      # Pillow before 10.1
        font = bold = ImageFont.load_default()
    pw = (w - 18) // 4
    left, gap = round(66 * k), round(8 * k)
    top = round(26 * k) + (round(30 * k) if title else 0)
    foot = round(26 * k) if note else 0
    canvas = Image.new("RGB", (left + w + gap, top + 3 * h + 3 * gap + foot), (255, 255, 255))
    d = ImageDraw.Draw(canvas)
    if title:
        d.text((left, round(6 * k)), title, fill=(35, 32, 28), font=bold)
    for i, name in enumerate(PANELS):
        d.text((left + i * (pw + 6) + 2, top - round(21 * k)), name, fill=(93, 85, 75), font=font)
    for i, (r, name) in enumerate(zip(rows, ("z slice", "y slice", "x slice"))):
        y0 = top + i * (h + gap)
        canvas.paste(Image.fromarray(r), (left, y0))
        d.text((round(6 * k), y0 + h // 2 - round(8 * k)), name, fill=(93, 85, 75), font=font)
    if note:
        d.text((left, top + 3 * h + 2 * gap + round(4 * k)), note, fill=(93, 85, 75), font=font)
    return np.asarray(canvas)


def png(rgb):
    from PIL import Image
    buf = io.BytesIO()
    Image.fromarray(rgb).save(buf, format="PNG", optimize=True)
    return buf.getvalue()


def package(out_dir, p, arrays, receipt, dest):
    """The download: villa's own output folder (its .lasagna.json and four OME-Zarr channels, holding only this
    box, in the scan's base coordinates, so lasagna's fit opens it as is), field.npz with the box's arrays, and the
    receipt."""
    dest = Path(dest)
    with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
        for f in sorted(Path(out_dir).rglob("*")):
            if f.is_file():
                zf.write(f, f"villa/{f.relative_to(out_dir)}")
        buf = io.BytesIO()
        np.savez_compressed(buf, **arrays)
        zf.writestr("field.npz", buf.getvalue())
        zf.writestr("receipt.json", json.dumps(receipt, indent=1))
        zf.writestr("README.txt", README_TXT)
    return dest


README_TXT = """Lasagna field for one box, made by the Lasagna Fields Space (huggingface.co/spaces/SharpMeow/lasagna-fields).

villa/       villa lasagna predict3d's own output: SCAN.lasagna.json and SCAN_{cos,grad_mag,nx,ny}.ome.zarr. Only the
             chunks of this box are written; the arrays have the whole scan's shape, in its base (level 0) frame, so
             villa's lasagna fit (lasagna/fit.py) opens the manifest as is.
field.npz    the box as numpy arrays: cos and grad_mag, nx and ny (uint8, as villa stores them). Grids: see receipt.json
             (boxes: each channel's voxel box and scaledown from level 0).
receipt.json what ran: scan, box, model, villa commit, checkpoint SHA-256, timings, and the agreement check.

Encodings (villa): cos = clip(cos * 255); grad_mag = clip(grad_mag * 1000 * grad_mag_factor); nx, ny = the unit
normal's x and y times 127 plus 128, with the normal flipped so its z is not negative.
Scan data: Vesuvius Challenge open data, CC BY-NC 4.0. Model and code: ScrollPrize/villa lasagna, MIT.
This is model output: geometry inputs, not a reading of any text.
"""


# ---------------------------------------------------------------- one run, end to end (no GPU decorator here)

def prepare(p, work):
    """Everything before the GPU: code, weights and the CT on disk."""
    t = time.time()
    code = ensure_villa()
    ck = ensure_weights()
    st = stage(p, Path(work) / "ct")
    return {"code": code, "checkpoint": str(ck), "stage": st, "prepare_s": round(time.time() - t, 2)}


def finish(p, prep, ran, work, out_root):
    """Everything after the GPU: read the field, compare, draw, pack."""
    if ran["exit"] != 0:
        raise FieldError(f"villa predict3d exited {ran['exit']}: {ran['log_tail'][-800:]}")
    arrays, boxes, man = read_field(p, ran["manifest"])
    if not any(int(a.max()) for a in arrays.values()):
        raise FieldError("the field is empty: the box is probably outside the scroll (masked CT is all zero there)")
    ct = ct_box(p, Path(work) / "ct")
    pictures = [picture(ct, arrays, boxes, axis) for axis in (0, 1, 2)]
    agree = agreement(p, arrays, boxes)
    receipt = {"made_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "plan": p,
               "model": {"id": p["model"], "title": MODELS[p["model"]]["title"],
                         "villa_commit": VILLA["commit"], "checkpoint": f"{WEIGHTS['repo']}@{WEIGHTS['revision']}",
                         "checkpoint_file": WEIGHTS["file"], "checkpoint_sha256": WEIGHTS["sha256"]},
               "predict3d_argv": [a if not a.startswith("/") else Path(a).name for a in ran["argv"]],
               "manifest": {k: man[k] for k in man if k != "groups"},
               "boxes": {n: {"scaledown": b["scaledown"], "box_zyx": b["box"]} for n, b in boxes.items()},
               "timings": {"stage_s": prep["stage"]["seconds"], "stage_bytes": prep["stage"]["bytes"],
                           "model_s": ran["wall_s"]},
               "agreement": agree,
               "claim": "Model output: a lasagna field is geometry fitted by a model, not a reading of any text."}
    out_root = Path(out_root)
    out_root.mkdir(parents=True, exist_ok=True)
    stem = f"{p['sample']}-{p['volume']}-z{p['center_level0_zyx'][0]}-y{p['center_level0_zyx'][1]}-x{p['center_level0_zyx'][2]}-s{p['size_zyx'][0]}"
    zpath = package(Path(ran["manifest"]).parent, p, arrays, receipt, out_root / f"{stem}.zip")
    return {"receipt": receipt, "zip": str(zpath), "pictures": pictures, "arrays": arrays}


def make(p, device=None, work=None, out_root=None, runner=None):
    """One field on this computer: stage, run, finish. `runner(p, ct_input, out_dir, device, checkpoint)` replaces
    the model call (the Space passes its ZeroGPU-decorated one)."""
    import torch
    device = device or ("cuda" if torch.cuda.is_available() else "cpu")
    work = Path(work or tempfile.mkdtemp(prefix="lasfield-"))
    prep = prepare(p, work)
    run = runner or MODELS[p["model"]]["run"]
    ran = run(p, prep["stage"]["input"], work / "out", device, prep["checkpoint"])
    return finish(p, prep, ran, work, out_root or work / "dl")


# ---------------------------------------------------------------- self-test (no network, no model)

def selftest():
    """The parts that need neither network nor model: grids, the tile count, the GPU ask, comparison and the
    control, decoding and pictures. Each check can fail."""
    ok = []
    assert level_for(9.362) == 0 and level_for(8.64) == 0 and level_for(2.399) == 2 and level_for(1.129) == 3
    ok.append("levels")
    assert tiles([256] * 3) < tiles([512] * 3)
    assert gpu_seconds([256] * 3) < gpu_seconds([512] * 3)
    ok.append("tiles")
    rng = np.random.default_rng(0)
    z = np.arange(64)[:, None, None] * np.ones((1, 64, 64))
    cos = (np.cos(z / 3.0) * 127 + 128).astype(np.uint8)
    f = {"cos": cos, "grad_mag": (cos // 2 + 1).astype(np.uint8), "nx": np.full_like(cos, 128),
         "ny": np.full_like(cos, 128)}
    g = {k: np.clip(v.astype(int) + rng.integers(-3, 4, v.shape), 1, 255).astype(np.uint8) for k, v in f.items()}
    c_same = compare(f, g)
    c_shift = compare(f, {k: np.roll(v, 5, axis=0) for k, v in g.items()})
    assert c_same["cos_r"] > 0.99 and c_shift["cos_r"] < 0.5, (c_same, c_shift)
    assert c_same["normal_median_deg"] < 3, c_same
    ok.append("compare and control")
    n = decode_normal(np.array([255], np.uint8), np.array([128], np.uint8))
    assert abs(n[0, 0] - 1) < 0.01 and abs(n[2, 0]) < 0.1
    ok.append("normals")
    boxes = {"cos": {"scaledown": 1}, "grad_mag": {"scaledown": 2}, "nx": {"scaledown": 2}, "ny": {"scaledown": 2}}
    ct = rng.integers(0, 255, (128, 128, 128)).astype(np.uint8)
    arr = {"cos": cos, "grad_mag": f["grad_mag"][::2, ::2, ::2], "nx": f["nx"][::2, ::2, ::2],
           "ny": f["ny"][::2, ::2, ::2]}
    pic = picture(ct, arr, boxes, 0)
    assert pic.shape == (128, 4 * 128 + 18, 3), pic.shape
    card = compose([pic, pic, pic], "test")
    assert card.shape[0] > 3 * 128 and card.shape[1] > pic.shape[1], card.shape
    ok.append("pictures")
    argv = predict3d_argv({"origin_level0_zyx": [1, 2, 3], "size_level0_zyx": [4, 5, 6]}, "in", "out.json", "ck.pt",
                          "cuda")
    assert argv[argv.index("--crop") + 1:argv.index("--crop") + 7] == ["3", "2", "1", "6", "5", "4"]
    ok.append("predict3d crop order")
    return {"passed": True, "checks": ok}


def main(argv=None):
    argv = list(sys.argv[1:] if argv is None else argv)
    if not argv or argv[0] == "selftest":
        print(json.dumps(selftest(), indent=1))
        return 0
    if argv[0] == "run":
        vid, z, y, x = argv[1:5]
        size = int(argv[5]) if len(argv) > 5 else DEFAULT_SIZE
        p = plan(vid, z, y, x, size)
        log(json.dumps(p))
        r = make(p, out_root=os.environ.get("LASFIELD_OUT") or ".")
        print(json.dumps(r["receipt"], indent=1))
        print(r["zip"])
        return 0
    print(__doc__)
    return 2


if __name__ == "__main__":
    sys.exit(main())