File size: 32,444 Bytes
73e760d
960f984
73e760d
 
 
 
 
 
 
 
 
 
 
 
 
08b694d
acd0294
73e760d
acd0294
 
73e760d
 
 
 
 
 
 
 
 
 
 
 
 
47c4bf8
73e760d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
960f984
73e760d
 
 
 
 
 
 
 
 
08b694d
 
960f984
08b694d
960f984
08b694d
 
 
73e760d
47c4bf8
 
73e760d
acd0294
 
960f984
acd0294
73e760d
 
acd0294
 
 
 
 
960f984
acd0294
 
 
 
 
 
960f984
acd0294
73e760d
e250bca
 
 
08b694d
 
 
 
e250bca
08b694d
 
 
 
 
e250bca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
960f984
acd0294
 
e250bca
73e760d
 
 
1a448c2
 
 
 
 
 
 
 
 
 
 
 
 
d217dfe
73e760d
d217dfe
 
 
 
 
 
 
 
 
 
1a448c2
 
73e760d
1a448c2
d217dfe
73e760d
 
 
 
47c4bf8
1a448c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73e760d
 
 
 
4668908
 
e250bca
4668908
 
 
 
73e760d
 
 
 
4668908
 
 
e250bca
4668908
 
 
73e760d
 
39cc007
 
 
 
 
 
 
 
 
 
 
 
81ef57e
 
 
 
 
 
 
 
 
 
 
 
 
 
39cc007
 
 
 
 
e250bca
39cc007
 
 
 
e250bca
39cc007
 
 
 
 
 
 
 
 
 
 
e250bca
39cc007
 
 
 
 
 
 
 
 
 
 
e250bca
39cc007
 
 
77d8116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39cc007
 
81ef57e
 
39cc007
 
 
 
 
 
 
 
 
 
 
 
 
73e760d
 
 
 
 
 
960f984
73e760d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
960f984
73e760d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4668908
 
 
960f984
4668908
73e760d
 
960f984
73e760d
 
 
 
 
 
 
acd0294
 
 
 
960f984
acd0294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
960f984
acd0294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
960f984
acd0294
 
 
73e760d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80d8541
 
73e760d
80d8541
73e760d
 
acd0294
bb112f6
 
acd0294
960f984
acd0294
 
 
 
73e760d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a448c2
73e760d
 
 
 
 
960f984
73e760d
47c4bf8
 
73e760d
 
 
 
 
 
1a448c2
73e760d
 
 
acd0294
 
 
 
 
73e760d
 
bb112f6
73e760d
 
 
 
 
 
f2ba8d3
73e760d
 
 
 
 
 
 
 
 
80d8541
73e760d
 
 
80d8541
73e760d
 
 
960f984
73e760d
 
80d8541
73e760d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e1fd81
 
 
 
73e760d
7e1fd81
20ff4d8
 
51f840a
 
 
 
73e760d
7e1fd81
 
73e760d
 
 
 
 
 
 
4668908
 
 
 
 
 
 
 
73e760d
 
 
 
 
 
960f984
73e760d
 
 
47c4bf8
644f141
47c4bf8
 
 
73e760d
bb112f6
 
 
 
 
80d8541
bb112f6
73e760d
bb112f6
 
 
 
 
 
 
73e760d
 
 
 
 
 
47c4bf8
 
644f141
47c4bf8
 
 
73e760d
 
1a448c2
 
 
 
 
 
 
 
 
 
 
08b694d
 
 
 
 
 
73e760d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4668908
73e760d
 
4668908
 
73e760d
 
 
4668908
 
73e760d
 
bb112f6
 
80d8541
73e760d
 
80d8541
08b694d
 
 
73e760d
 
 
 
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
"""
RootScope on Hugging Face Spaces: upload a root-tip TIFF, get segmented and
classified cells back.

Modelled on the Cellpose-SAM Space (huggingface.co/spaces/mouseland/cellpose),
with one important difference: only two steps of the RootScope pipeline touch
the GPU (Cellpose-SAM segmentation and the DINOv2 embeddings). Those are the
only ones wrapped in @spaces.GPU. Feature extraction, the iterative
RandomForest/XGBoost/LightGBM refinement, anatomical post-processing and
overlay drawing all run on the CPU worker, off the GPU clock.

Run locally with:  python webapp/app.py
"""

import functools
import os
import pickle
import re
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path

import gradio as gr
import numpy as np
import pandas as pd
import tifffile
from PIL import Image

from rootscope import predict as rs
from rootscope.cnn_embeddings import load_dinov2
from rootscope.extract_features import load_cellpose_model
from rootscope.weights import is_v4, model_version, resolve_cnn_weights, resolve_model_dir

# ── ZeroGPU shim ─────────────────────────────────────────────────────────────
# `spaces` only exists on Hugging Face. Locally the decorator becomes a no-op
# so the same file runs on a workstation or a lab GPU node.
try:
    import spaces
except ImportError:  # noqa: BLE001
    class _NoSpaces:
        @staticmethod
        def GPU(*args, **kwargs):
            def wrap(fn):
                return fn
            return wrap

    spaces = _NoSpaces()  # type: ignore[assignment]


print = functools.partial(print, flush=True)  # noqa: A001 (Spaces log stdout)

# Everything the UI serves back (previews, overlays, CSVs, ZIPs) is written
# under here, and this one directory is handed to launch(allowed_paths=...).
# Gradio refuses to serve files from arbitrary locations on disk, so results
# must live somewhere it has been told about.
TMP_ROOT = Path(tempfile.gettempdir()) / "rootscope_runs"
TMP_ROOT.mkdir(parents=True, exist_ok=True)

MAX_PIXELS = 40_000_000  # ~6300x6300; refuse anything larger

# The Diagnostics panel is a developer tool, not something a visitor should
# see, so it is off by default. It earned its keep once already: it is how the
# ZeroGPU "GPU task aborted" failure was pinned to the XGBoost import rather
# than to the pipeline, so it stays in the file behind a switch instead of
# being deleted. To turn it on, add ROOTSCOPE_DIAGNOSTICS=1 in the Space's
# Settings → Variables and restart; no code change needed.
SHOW_DIAGNOSTICS = os.environ.get("ROOTSCOPE_DIAGNOSTICS") == "1"
MODEL_CHOICES = ["Ensemble", "RandomForest", "LightGBM", "XGBoost"]
if is_v4():
    MODEL_CHOICES = ["LightGBM"]   # v4 ships one classifier (3 seeds averaged inside)

# ── fetch weights at startup, load nothing ───────────────────────────────────
# Downloading here means the first visitor does not wait for ~550 MB. Note that
# these calls only put files on disk; they unpickle nothing.
print("[rootscope-web] fetching weights...")
MODEL_DIR = resolve_model_dir()
CNN_WEIGHTS = resolve_cnn_weights()

# NOTE: the classifiers are deliberately NOT loaded here.
#
# Unpickling XGBoost builds a Booster, which probes for CUDA devices, which
# registers state ZeroGPU's snapshot/restore cannot reproduce. Once that has
# happened in this process every @spaces.GPU task aborts, verified on this
# Space, where the diagnostics' bare torch.zeros(2048, 2048, device="cuda")
# came back "GPU task aborted" on a healthy CUDA 13.0 / torch 2.11 box.
#
# Having the model merely resident is enough, so loading it lazily on the
# classification path would not help: the GPU stages run first and would
# already be poisoned on the second request. Every classifier therefore lives
# in a subprocess that exits when it is done. See classify_worker.py.
CLASSIFY_WORKER = Path(__file__).resolve().parent / "classify_worker.py"

# NOTE: nothing is placed on CUDA at import.
#
# Hugging Face documents the opposite - models "must be placed on cuda at the
# root module level" - and that is what this file did originally. Moving the
# GPU models here was blamed for the ZeroGPU aborts at the time; that was the
# wrong culprit. The classifiers were the problem (see above), and once they
# left the process the diagnostics passed with the GPU models still lazy.
#
# So this arrangement is no longer load-bearing, only untested against the
# alternative. Module-level CUDA loading is what HF recommends and would skip
# rebuilding Cellpose-SAM per call, but the fork that runs each @spaces.GPU
# task discards anything these helpers cache anyway, and a measured run came in
# at 24 s end to end. Not worth churning until something needs the speed.
_GPU_MODELS = {}


def _gpu_cellpose():
    if "cellpose" not in _GPU_MODELS:
        print("[rootscope-web] building Cellpose-SAM on GPU (first GPU call)")
        _GPU_MODELS["cellpose"] = load_cellpose_model(use_gpu=True)
    return _GPU_MODELS["cellpose"]


def _gpu_dinov2():
    if "dinov2" not in _GPU_MODELS:
        print("[rootscope-web] building DINOv2 on GPU (first GPU call)")
        _GPU_MODELS["dinov2"] = load_dinov2(
            weights_path=str(CNN_WEIGHTS) if CNN_WEIGHTS else None, use_gpu=True)
    return _GPU_MODELS["dinov2"]


print(f"[rootscope-web] ready: weights at {MODEL_DIR}")
print("[rootscope-web] no classifier loaded in this process (ZeroGPU safe); "
      "classification runs in a subprocess")
print("[rootscope-web] GPU models load on first use (no CUDA touched at import)")


# ── the two GPU stages ───────────────────────────────────────────────────────
# Each @spaces.GPU call asks ZeroGPU for a duration up front. Two rules bite:
# a call that overruns its reservation is killed, and a request larger than the
# visitor's REMAINING daily quota is refused outright with "requested GPU
# duration N is larger than the maximum allowed".
#
# That second rule is why the numbers below matter. A signed-out visitor has
# only 120 s of quota per day, so anything asking for more than that fails
# before the function runs, however small the image. The earlier version asked
# for a 150 s floor, calibrated off CPU timings taken when no GPU was available
# to measure, which made the Space unusable for everyone not signed in.
#
# Re-calibrated from the live Space, reading stage boundaries out of the log:
#
#   700x700, 214 cells   segmentation 27.7 s cold, 20 s warm   embeddings ~2 s
#
# The segmentation figure includes building Cellpose-SAM inside the call, which
# the fork running each task pays every single time, and it is most of the cost.
# Embeddings are genuinely cheap on a GPU; their formula is left generous
# because a big section has many more cells to batch.
#
# The formulas sit ~2x above the measured cold-start cost, and cap under 120 s
# so a signed-out visitor can always get a slot. Do not tighten these to hug
# the measurements: overrunning a reservation kills the job outright, while
# over-reserving only spends quota the visitor was not going to use anyway,
# since ZeroGPU bills actual wall clock rather than the amount requested.
MAX_DURATION = 100      # keep under the 120 s signed-out quota

def _segment_duration(img_rgb):
    mp = (img_rgb.shape[0] * img_rgb.shape[1]) / 1e6
    return int(min(MAX_DURATION, max(45, 30 + 60 * mp)))


def _embed_duration(masks, img_rgb, df_base):
    n = int(masks.max())
    return int(min(MAX_DURATION, max(25, 10 + 0.06 * n)))


def _gpu_call(fn, *args):
    """Run one GPU stage, turning ZeroGPU's refusals into plain language.

    A refused reservation is not a bug, it is the visitor being out of free GPU
    time, and the raw message ("requested GPU duration ... larger than the
    maximum allowed") tells them nothing about what to do next.
    """
    try:
        return fn(*args)
    except Exception as e:
        msg = str(e).lower()
        if any(k in msg for k in ("duration", "quota", "maximum allowed",
                                  "exceeded")):
            raise gr.Error(
                "Out of free GPU time for this image. Every visitor gets a "
                "daily GPU budget from Hugging Face: 2 minutes when signed "
                "out, 5 minutes with a free account. Sign in to Hugging Face "
                "and reload, come back tomorrow, or crop the image smaller "
                "and try again."
            ) from e
        raise


@spaces.GPU(duration=_segment_duration)
def _segment(img_rgb):
    import traceback
    try:
        return rs.stage_segment(img_rgb, gpu=True, cellpose_model=_gpu_cellpose())
    except Exception:
        # Without this the only symptom is ZeroGPU reporting "aborted".
        print("[rootscope-web] SEGMENTATION FAILED\n" + traceback.format_exc())
        raise


@spaces.GPU(duration=_embed_duration)
def _embed(masks, img_rgb, df_base):
    import traceback
    try:
        return rs.stage_embed(masks, img_rgb, df_base, gpu=True,
                              dinov2_model=_gpu_dinov2())
    except Exception:
        print("[rootscope-web] EMBEDDING FAILED\n" + traceback.format_exc())
        raise


# ── ZeroGPU diagnostics ──────────────────────────────────────────────────────
# "GPU task aborted" means the worker process died, so nothing it printed
# survives. Each step below is its own @spaces.GPU call: whichever one fails to
# come back is the culprit.

@spaces.GPU(duration=60)
def _diag_alloc():
    import torch
    x = torch.zeros(2048, 2048, device="cuda")
    return f"{torch.cuda.get_device_name(0)}, alloc ok, sum={float(x.sum())}"


@spaces.GPU(duration=300)
def _diag_alloc_long():
    """Same trivial work, maximum reservation.

    Before running the task, ZeroGPU has to materialise every CUDA tensor this
    process registered at startup - which here means the 1.15 GB Cellpose-SAM
    checkpoint and the DINOv2 backbone. If that restore is what blows the
    budget, a trivial allocation still fails at 60 s but passes at 300 s.
    """
    import torch
    x = torch.zeros(2048, 2048, device="cuda")
    return f"{torch.cuda.get_device_name(0)}, alloc ok at 300s, sum={float(x.sum())}"


@spaces.GPU(duration=60)
def _diag_devices():
    import torch
    out = []
    try:
        p = next(_gpu_cellpose().net.parameters())
        out.append(f"cellpose net on {p.device} ({p.dtype})")
    except Exception as e:
        out.append(f"cellpose net ERR {type(e).__name__}: {e}")
    try:
        p = next(_gpu_dinov2().parameters())
        out.append(f"dinov2 on {p.device}")
    except Exception as e:
        out.append(f"dinov2 ERR {type(e).__name__}: {e}")
    out.append(f"torch {torch.__version__}")
    return "; ".join(out)


@spaces.GPU(duration=120)
def _diag_cellpose():
    import numpy as np
    img = (np.random.rand(256, 256, 3) * 255).astype(np.uint8)
    m = rs.stage_segment(img, gpu=True, cellpose_model=_gpu_cellpose())
    return f"segmented 256x256 noise -> {int(m.max())} labels, dtype {m.dtype}"


@spaces.GPU(duration=120)
def _diag_dino():
    import numpy as np
    from rootscope.cnn_embeddings import extract_cnn_embeddings
    masks = np.zeros((128, 128), dtype=np.int32)
    masks[20:60, 20:60] = 1
    masks[70:110, 70:110] = 2
    img = (np.random.rand(128, 128, 3) * 255).astype(np.uint8)
    df = extract_cnn_embeddings(masks, img, use_gpu=True, model=_gpu_dinov2())
    return f"embedded {len(df)} cells, {df.shape[1] - 1} dims"


def env_report():
    """What actually got installed. Runs in the main process, so it still
    reports even when every @spaces.GPU task aborts."""
    import importlib, torch
    lines = [
        f"torch            {torch.__version__}",
        f"torch.version.cuda {torch.version.cuda}",
        f"cuda.is_available  {torch.cuda.is_available()}",
        f"cuda.device_count  {torch.cuda.device_count()}",
    ]
    for mod in ("torchvision", "spaces", "gradio", "cellpose", "numpy",
                "sklearn", "cv2", "xgboost", "lightgbm"):
        try:
            m = importlib.import_module(mod)
            lines.append(f"{mod:16s} {getattr(m, '__version__', '?')}")
        except Exception as e:
            lines.append(f"{mod:16s} IMPORT FAILED: {type(e).__name__}: {e}")
    import os
    lines.append(f"ZEROGPU env      "
                 f"{ {k: v for k, v in os.environ.items() if 'ZERO' in k.upper() or 'SPACES' in k.upper()} }")
    return "\n".join(lines)


def run_diagnostics():
    steps = [
        ("1. CUDA allocation (60s)", _diag_alloc),
        ("1b. CUDA allocation (300s)", _diag_alloc_long),
        ("2. model devices", _diag_devices),
        ("3. Cellpose-SAM on GPU", _diag_cellpose),
        ("4. DINOv2 on GPU", _diag_dino),
    ]
    lines = []
    for name, fn in steps:
        try:
            lines.append(f"PASS  {name}: {fn()}")
        except Exception as e:
            lines.append(f"FAIL  {name}: {type(e).__name__}: {e}")
    return "\n".join(lines)


# ── helpers ──────────────────────────────────────────────────────────────────

def read_um_per_px(tif_path):
    """Pull the pixel size out of the TIFF metadata, if it has any.

    RootScope does not read the scale from the file, and the 1.0 default
    silently distorts every size-derived feature, so prefill the box rather
    than let the user forget.
    """
    try:
        with tifffile.TiffFile(tif_path) as tf:
            if tf.ome_metadata:
                m = re.search(r'PhysicalSizeX="([0-9.eE+-]+)"', tf.ome_metadata)
                if m:
                    return round(float(m.group(1)), 6)
            tags = tf.pages[0].tags
            if "XResolution" in tags and "ResolutionUnit" in tags:
                num, den = tags["XResolution"].value
                if num:
                    px_per_unit = num / den
                    unit = int(tags["ResolutionUnit"].value)
                    if unit == 3:      # centimetre
                        return round(10_000.0 / px_per_unit, 6)
                    if unit == 2:      # inch
                        return round(25_400.0 / px_per_unit, 6)
    except Exception as e:  # noqa: BLE001
        print(f"[rootscope-web] could not read pixel size: {e}")
    return None


def _preview_png(img_rgb):
    """Write a display-only PNG of the (max-projected) image.

    The gr.Image component is filepath-typed, so whatever we show becomes its
    value. That displayed PNG must never become the thing we segment. The
    original TIFF path is kept in a State instead.
    """
    tmp = tempfile.NamedTemporaryFile(suffix="_preview.png", delete=False,
                                      dir=TMP_ROOT)
    Image.fromarray(img_rgb.astype(np.uint8)).save(tmp.name)
    return tmp.name


def on_upload(filepath):
    """Show the uploaded image and prefill um/px from its metadata."""
    if not filepath:
        return None, gr.update(), "Upload a root-tip TIFF to begin.", None
    try:
        img_rgb = rs.load_image(filepath)
    except Exception as e:  # noqa: BLE001
        return None, gr.update(), f"Could not read that file: {e}", None

    preview = _preview_png(img_rgb)
    h, w = img_rgb.shape[:2]
    if Path(filepath).suffix.lower() not in (".tif", ".tiff"):
        return (preview, gr.update(),
                f"That file is `{Path(filepath).suffix}`, not a TIFF. RootScope "
                f"expects the original 16-bit confocal TIFF. A PNG or JPEG has "
                f"already lost bit depth and metadata.", filepath)
    scale = read_um_per_px(filepath)
    if scale is None:
        note = (f"Loaded {w}×{h}. **No pixel size in the metadata**. Set "
                f"microns/pixel yourself; leaving it at 1.0 distorts every "
                f"size-derived feature.")
        return preview, gr.update(), note, filepath
    note = f"Loaded {w}×{h}. Pixel size from metadata: **{scale} µm/px**."
    return preview, gr.update(value=scale), note, filepath


def _classify_subprocess(workdir, **job):
    """Run the CPU classification stage in a child process and return its table.

    The child loads the classifiers itself and exits, so no XGBoost Booster is
    ever built in this process. See classify_worker.py for why that matters.

    The job goes over a pickle in the run's own directory rather than through a
    pipe: `masks` and `img_rgb` are a few MB each and a file is easier to
    inspect when something goes wrong.
    """
    job_path = workdir / "_classify_job.pkl"
    result_path = workdir / "_classify_result.pkl"
    job["out_dir"] = str(workdir)
    job["model_dir"] = str(MODEL_DIR)
    with open(job_path, "wb") as f:
        pickle.dump(job, f, protocol=pickle.HIGHEST_PROTOCOL)

    proc = subprocess.run(
        [sys.executable, str(CLASSIFY_WORKER), str(job_path), str(result_path)],
        # so `import rootscope` resolves the same copy the parent imported
        cwd=str(CLASSIFY_WORKER.parent),
        capture_output=True, text=True, timeout=1800,
    )
    # The child's stdout is the classification log, so surface it in the Space
    # logs as if it had run here.
    if proc.stdout:
        print(proc.stdout, end="")
    if proc.stderr:
        print("[classify-worker stderr]\n" + proc.stderr, end="")

    if not result_path.exists():
        raise gr.Error(
            f"Classification subprocess died without a result "
            f"(exit {proc.returncode}). See the Space logs."
        )
    with open(result_path, "rb") as f:
        result = pickle.load(f)

    for p in (job_path, result_path):
        p.unlink(missing_ok=True)   # never let these reach the results ZIP

    if not result.get("ok"):
        print("[rootscope-web] CLASSIFICATION FAILED\n"
              + result.get("traceback", "(no traceback)"))
        raise gr.Error("Classification failed. See the Space logs.")
    return result["df"]


def _summarize(df, model_name):
    sub = df[df["model"] == model_name]
    counts = sub["predicted_cell_type"].value_counts()
    summary = pd.DataFrame({
        "cell type": counts.index,
        "cells": counts.values,
        "% of cells": (100 * counts.values / counts.values.sum()).round(1),
        "mean confidence": [
            round(float(sub.loc[sub["predicted_cell_type"] == ct,
                                "prediction_confidence"].mean()), 3)
            for ct in counts.index
        ],
    })
    return sub, summary


def _render(results, model_name):
    """Build the display for one model from an already-computed run."""
    workdir = Path(results["workdir"])
    stem = results["stem"]
    overlay_path = workdir / f"{stem}_{model_name}_overlay.png"
    csv_path = workdir / f"{stem}_{model_name}_predictions.csv"

    sub, summary = _summarize(results["df"], model_name)
    stats = (f"**{model_name}** · {len(sub)} cells · {results['n_layers']} "
             f"tissue layers · {results['um_per_px']} µm/px")
    if len(sub) and "n_xylem_poles" in sub.columns:
        row = sub.iloc[0]
        stats += (f" · xylem poles: {int(row['n_xylem_poles'])} · "
                  f"phloem poles: {int(row['n_phloem_poles'])}")

    # The per-image trust report (*_report.txt) is not shown on the Space (Nina, 2026-10-08);
    # the command-line package still writes it.
    return (str(overlay_path), summary, stats,
            gr.DownloadButton(value=str(csv_path), visible=True))


def run_rootscope(tif_path, tif_file, um_per_px, which_model, label_cells,
                  max_rounds, species="", smooth_layers=True,
                  progress=gr.Progress()):
    # tif_path comes from the State that on_upload fills; tif_file is whatever
    # the File component is holding right now. Either alone is fragile: the
    # State is empty if Run is pressed before on_upload finishes, and it is
    # dropped on a page reload. Three runs died on "Upload a TIFF first" in the
    # Space logs with a file plainly selected, so take whichever we have.
    tif_path = tif_path or tif_file
    if not tif_path:
        raise gr.Error("Upload a TIFF first.")
    if not um_per_px or float(um_per_px) <= 0:
        raise gr.Error("Microns per pixel must be greater than 0.")

    progress(0.02, desc="Reading image")
    img_rgb = rs.load_image(tif_path)
    h, w = img_rgb.shape[:2]
    if h * w > MAX_PIXELS:
        raise gr.Error(
            f"Image is {w}×{h} ({h * w / 1e6:.0f} MP), over this demo's "
            f"{MAX_PIXELS / 1e6:.0f} MP limit. Crop it, or run RootScope locally."
        )

    # Not cleaned up: Gradio serves the downloads straight out of this
    # directory, so it has to outlive the request. Spaces are ephemeral, but on
    # a long-lived server add a reaper for old TMP_ROOT/run_* directories.
    workdir = Path(tempfile.mkdtemp(prefix="run_", dir=TMP_ROOT))
    stem = Path(tif_path).stem.replace(".aivia", "")

    # ---- GPU: segmentation ----
    progress(0.10, desc="Segmenting with Cellpose-SAM (GPU)")
    masks = _gpu_call(_segment, img_rgb)
    n_cells = int(masks.max())
    if n_cells == 0:
        raise gr.Error("Cellpose-SAM found no cells in this image.")

    # ---- CPU: tissue layers + handcrafted features ----
    progress(0.35, desc=f"{n_cells} cells, layer index and features")
    masks, df_base, layer_lookup, adjacency, n_layers = rs.stage_features(
        masks, img_rgb, um_per_px=float(um_per_px),
        remove_debris=not is_v4())   # v4 keeps every cell, as in the manuscript
    if df_base is None:
        raise gr.Error("Every detected object was filtered out as debris "
                       "outside the tissue body.")

    # ---- GPU: DINOv2 embeddings ----
    progress(0.50, desc="DINOv2 embeddings (GPU)")
    df_base = _gpu_call(_embed, masks, img_rgb, df_base)

    # ---- CPU: iterative classification, post-processing, overlays ----
    progress(0.65, desc="Classifying cells (iterative ensemble)")
    df = _classify_subprocess(
        workdir,
        df_base=df_base, masks=masks, img_rgb=img_rgb,
        layer_lookup=layer_lookup, adjacency=adjacency,
        stem=stem, source_name=Path(tif_path).name,
        um_per_px=float(um_per_px), max_rounds=int(max_rounds),
        label_cells=bool(label_cells),
        species=(species or "").strip() or None, smooth_layers=bool(smooth_layers),
    )

    progress(0.95, desc="Packaging results")
    zip_path = workdir / f"{stem}_rootscope_results.zip"
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
        for f in sorted(workdir.iterdir()):
            if f.suffix in (".png", ".csv") or (f.suffix == ".json" and not f.name.endswith("_report.json")):
                z.write(f, arcname=f.name)

    available = [m for m in MODEL_CHOICES
                 if (workdir / f"{stem}_{m}_overlay.png").exists()]
    chosen = which_model if which_model in available else available[0]

    results = {"workdir": str(workdir), "stem": stem, "df": df,
               "n_layers": n_layers, "um_per_px": float(um_per_px),
               "available": available}
    overlay_path, summary, stats, csv_button = _render(results, chosen)
    return (overlay_path, summary, stats, csv_button,
            gr.DownloadButton(value=str(zip_path), visible=True),
            gr.update(choices=available, value=chosen),
            results)


def switch_model(results, model_name):
    """Swap the displayed model without recomputing, since every model's overlay and
    CSV was already written to the run's temp dir."""
    if not results or model_name not in results.get("available", []):
        return gr.update(), gr.update(), gr.update(), gr.update()
    return _render(results, model_name)


LEGEND = """
<div style="display:flex;flex-wrap:wrap;gap:12px;font-size:13px;margin-top:6px">
  <span><b style="color:#FF69B4">■</b> root cap</span>
  <span><b style="color:#0000FF">■</b> epidermis</span>
  <span><b style="color:#CCCC00">■</b> exodermis</span>
  <span><b style="color:#00C800">■</b> cortex</span>
  <span><b style="color:#FFA500">■</b> endodermis</span>
  <span><b style="color:#800080">■</b> pericycle</span>
  <span><b style="color:#FF0000">■</b> xylem</span>
  <span><b style="color:#888888">■</b> phloem</span>
  <span><b style="color:#00BFBF">■</b> stele</span>
</div>
"""

# Gradio 6 moved `theme` from the Blocks constructor to launch(); Gradio 5
# only accepts it on Blocks. Pass it wherever this version wants it.
_THEME = gr.themes.Soft()
_THEME_ON_LAUNCH = int(gr.__version__.split(".")[0]) >= 6
_blocks_kwargs = {} if _THEME_ON_LAUNCH else {"theme": _THEME}

TITLE = ("RootScope: Cross-species Root Cell-Type Classification from "
         "Confocal Microscopy Images")

with gr.Blocks(title=TITLE, **_blocks_kwargs) as demo:
    gr.Markdown(
        f"# {TITLE}\n"
        "RootScope uses Cellpose-SAM to segment every cell and describes each "
        "one with morpho-topological features and fine-tuned DINOv2 "
        "embeddings. Classification is refined through an iterative process in "
        "which predictions from neighboring cells are fed back as new "
        "features, updating every label until the classification converges. "
        "Each cell is then assigned to one of nine anatomical types.\n\n"
        "[github](https://github.com/ct-tranchau/Rootscope) · "
        "[model weights](https://huggingface.co/ct-tranchau/Rootscope) · "
        "[model card](https://github.com/ct-tranchau/Rootscope/blob/main/MODEL_CARD.md)"
    )

    tif_state = gr.State(None)    # the real TIFF; never the displayed preview
    results_state = gr.State(None)  # last completed run, for switching models

    with gr.Row():
        with gr.Column(scale=1):
            # NOT a gr.Image: an Image component re-encodes uploads to PNG,
            # which strips the OME metadata and the original bit depth. A File
            # component passes the actual TIFF through untouched.
            tif_upload = gr.File(label="Input TIFF",
                                 file_types=[".tif", ".tiff"],
                                 file_count="single", type="filepath")
            input_image = gr.Image(label="Preview", type="filepath",
                                   interactive=False, height=300)
            status = gr.Markdown("Upload a root-tip TIFF to begin.")

            um_per_px = gr.Number(
                label="Microns per pixel",
                value=1.0,
                info="Read from the file's metadata when present. This is not "
                     "optional. The 1.0 default distorts every size-derived "
                     "feature.",
            )
            model_pick = gr.Dropdown(
                MODEL_CHOICES, value=MODEL_CHOICES[0], label="Model to display",
                info=("One LightGBM (three training seeds averaged inside); "
                      "this is the published result." if is_v4() else
                      "All four are computed in one run; this only picks which "
                      "one you see. Ensemble is the published result."),
            )
            species = gr.Textbox(
                label="Species (optional)", value="", visible=is_v4(),
                placeholder="e.g. Pisum sativum",
                info="Not required. If given, it is checked against the species "
                     "table: for a training species it settles whether the root "
                     "has an exodermis; otherwise the image decides.",
            )
            with gr.Accordion("Advanced", open=False):
                smooth_layers = gr.Checkbox(
                    label="Smooth along the outer layers", value=True,
                    visible=is_v4(),
                    info="A single cell whose neighbours on both sides along "
                         "the layer agree on another type takes that type "
                         "(epidermis, exodermis, cortex only).",
                )
                label_cells = gr.Checkbox(
                    label="Write cell-type names on the overlay", value=False,
                    info="Readable when zoomed into one region; on a dense "
                         "section the text overlaps and hides the image.",
                )
                max_rounds = gr.Slider(
                    1, 20, value=(6 if is_v4() else 10), step=1,
                    label="Max refinement rounds",
                    info=("Evaluated at 6; going to 10 changes accuracy "
                          "by under 0.1 pp." if is_v4() else
                          "The classifier re-predicts using neighbours' types "
                          "until predictions stop changing."),
                )
            run_btn = gr.Button("Run RootScope", variant="primary")
            # Hugging Face meters free GPU per visitor, not per Space, so the
            # daily budget is theirs to raise by signing in. Saying so here is
            # cheaper than fielding "it stopped working" reports; the
            # Cellpose-SAM Space carries the same note.
            gr.Markdown(
                "<sub>Free GPU time is per visitor, per day: 2 minutes signed "
                "out, 5 minutes with a free "
                "[Hugging Face account](https://huggingface.co/join). One "
                "image costs roughly 30 seconds. Sign in and reload if you run "
                "out.</sub>"
            )
            if SHOW_DIAGNOSTICS:
                with gr.Accordion("Diagnostics", open=False):
                    env_btn = gr.Button("Report environment (no GPU needed)")
                    diag_btn = gr.Button("Test the GPU step by step")
                    diag_out = gr.Textbox(label="Result", lines=7,
                                          show_copy_button=True)

        with gr.Column(scale=1):
            overlay = gr.Image(label="Predicted cell types", type="filepath",
                               height=420)
            gr.HTML(LEGEND)
            stats_md = gr.Markdown()
            summary = gr.Dataframe(label="Cells per type", interactive=False,
                                   wrap=True)
            with gr.Row():
                csv_btn = gr.DownloadButton("Download predictions (CSV)",
                                            visible=False)
                zip_btn = gr.DownloadButton("Download everything (ZIP)",
                                            visible=False)

    # On a Space the TIFFs sit next to app.py; in a git checkout they are one
    # level up in examples/.
    here = Path(__file__).parent
    examples_dir = next((d for d in (here / "examples", here.parent / "examples")
                         if (d / "Acorulea_RootTip_Maturation.tif").exists()), None)
    if examples_dir is not None:
        gr.Examples(
            examples=[
                [str(examples_dir / "Acorulea_RootTip_Maturation.tif")],
                [str(examples_dir / "Spennellii_RootTip_EarlyMaturation.tif")],
            ],
            fn=on_upload,
            inputs=[tif_upload],
            outputs=[input_image, um_per_px, status, tif_state],
            run_on_click=True,
            # Only wires up the preview; the real work still happens on Run.
            cache_examples=False,
            label="Example images (click one, then press Run)",
        )

    tif_upload.change(on_upload, tif_upload,
                      [input_image, um_per_px, status, tif_state])
    run_btn.click(
        run_rootscope,
        [tif_state, tif_upload, um_per_px, model_pick, label_cells, max_rounds,
         species, smooth_layers],
        [overlay, summary, stats_md, csv_btn, zip_btn, model_pick, results_state],
    )
    model_pick.change(switch_model, [results_state, model_pick],
                      [overlay, summary, stats_md, csv_btn])
    if SHOW_DIAGNOSTICS:
        diag_btn.click(run_diagnostics, None, diag_out)
        env_btn.click(env_report, None, diag_out)

if __name__ == "__main__":
    launch_kwargs = {"theme": _THEME} if _THEME_ON_LAUNCH else {}
    demo.queue(max_size=20).launch(allowed_paths=[str(TMP_ROOT)], **launch_kwargs)