Spaces:
Running on Zero
Running on Zero
disco-space a0eaa21cc0a6, brain.toml
Browse files- README.md +4 -4
- app.py +15 -17
- data/SOURCE.md +2 -2
- data/classes/qte-now-80.npy +3 -0
- data/protocols/c2.bvals +1 -0
- data/protocols/c2.bvecs +3 -0
- data/protocols/clinical.bvals +1 -0
- data/protocols/clinical.bvecs +3 -0
- data/protocols/ogse.bvals +1 -0
- data/protocols/ogse.bvecs +3 -0
- data/protocols/research.bvals +1 -0
- data/protocols/research.bvecs +3 -0
- requirements.txt +10 -10
- space/app.py +191 -49
- space/brain.toml +7 -3
- space/classes.py +119 -0
- space/config.toml +74 -26
- space/limits.py +1 -1
- space/pipeline.py +102 -16
- space/reference.py +42 -0
- space/sources/brain.py +125 -44
- space/sources/disco.py +216 -110
- space/tensor.py +245 -0
- space/viewers.py +49 -0
- tests/test_acceptance.py +75 -59
- tests/test_app.py +93 -8
- tests/test_brain.py +143 -28
- tests/test_deploy_requirements.py +40 -0
- tests/test_dmipy_names.py +45 -0
- tests/test_gate_job.py +23 -0
- tests/test_pipeline.py +125 -39
- tests/test_tensor.py +106 -0
- tests/test_viewers.py +12 -0
- tools/build_brain_asset.py +1 -2
- tools/build_brain_fixture.py +9 -7
- tools/deploy.py +49 -2
- tools/gate_job.py +28 -2
- tools/live.py +3 -3
- tools/smoke_tensor.py +55 -0
README.md
CHANGED
|
@@ -8,13 +8,13 @@ sdk_version: 6.28.0
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
startup_duration_timeout: 1h
|
| 11 |
-
license:
|
| 12 |
short_description: A real brain replayed from packs, to an 84-region connectome
|
| 13 |
---
|
| 14 |
|
| 15 |
# The brain Space on ZeroGPU
|
| 16 |
|
| 17 |
-
The same application as `rfick/disco-
|
| 18 |
a real brain as a compositional replay phantom. The input is the multi-tissue CSD of one scan, MASiVar `sub-cIs1`
|
| 19 |
(OpenNeuro ds003416, Cai et al. MRM 2021, CC0): its white-matter FOD field, its WM / GM / CSF fractions and an
|
| 20 |
84-region parcellation, made once offline and published as the dataset `SubstrateCommons/masivar-brain`. Each run
|
|
@@ -27,7 +27,7 @@ https://github.com/dmrai-lab/disco-space (`requirements-zero.txt`, `app.py`, `sp
|
|
| 27 |
**The scanner.** The menu holds the ideal scanner and two catalogued machines, the Siemens Prisma 3 T and Terra 7 T,
|
| 28 |
each played at every voxel as it delivers there with the head centre at isocentre (voxels binned into encoding
|
| 29 |
classes at 1 % of b). The Hyperfine Swoop 64 mT is not on this menu, and the page says why under it: its own gradient
|
| 30 |
-
is exact in dmipy-
|
| 31 |
(`docs/scanner.md`). The DiSCo Spaces play it exactly.
|
| 32 |
|
| 33 |
**Where the work runs.** The packs' pose responses (the only physics that depends on the tissue and the field) are
|
|
@@ -49,7 +49,7 @@ reservation is killed. Only the device part of a run holds the GPU, with the pac
|
|
| 49 |
cached (part of the reservation shown, which falls once a run has cached them); the page's files and figures after
|
| 50 |
it do not.
|
| 51 |
|
| 52 |
-
Not re-measured
|
| 53 |
WM stop mask; dmipy-sim 7f6f1fa, dmipy-fit 0c7dde8 with the single-tissue reconstruction, dmipy-tract 7da22c3;
|
| 54 |
`tools/measure_brain.py`, 2026-09-30), steady state, seconds:
|
| 55 |
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
startup_duration_timeout: 1h
|
| 11 |
+
license: agpl-3.0
|
| 12 |
short_description: A real brain replayed from packs, to an 84-region connectome
|
| 13 |
---
|
| 14 |
|
| 15 |
# The brain Space on ZeroGPU
|
| 16 |
|
| 17 |
+
The same application as `rfick/disco-replay` (dmrai-lab/disco-space), serving `space/brain.toml` (`DISCO_CONFIG`):
|
| 18 |
a real brain as a compositional replay phantom. The input is the multi-tissue CSD of one scan, MASiVar `sub-cIs1`
|
| 19 |
(OpenNeuro ds003416, Cai et al. MRM 2021, CC0): its white-matter FOD field, its WM / GM / CSF fractions and an
|
| 20 |
84-region parcellation, made once offline and published as the dataset `SubstrateCommons/masivar-brain`. Each run
|
|
|
|
| 27 |
**The scanner.** The menu holds the ideal scanner and two catalogued machines, the Siemens Prisma 3 T and Terra 7 T,
|
| 28 |
each played at every voxel as it delivers there with the head centre at isocentre (voxels binned into encoding
|
| 29 |
classes at 1 % of b). The Hyperfine Swoop 64 mT is not on this menu, and the page says why under it: its own gradient
|
| 30 |
+
is exact in dmipy-replay's closed form, but it bins this head into 9,752 classes at 2-3 minutes each
|
| 31 |
(`docs/scanner.md`). The DiSCo Spaces play it exactly.
|
| 32 |
|
| 33 |
**Where the work runs.** The packs' pose responses (the only physics that depends on the tissue and the field) are
|
|
|
|
| 49 |
cached (part of the reservation shown, which falls once a run has cached them); the page's files and figures after
|
| 50 |
it do not.
|
| 51 |
|
| 52 |
+
Not re-measured on the dmipy 3.0 line (the tables below name their own pins). Measured on an L40S with the BATMAN development fixture (96 x 96 x 60 at 2.5 mm, 90,205 brain voxels, 36,605 in the
|
| 53 |
WM stop mask; dmipy-sim 7f6f1fa, dmipy-fit 0c7dde8 with the single-tissue reconstruction, dmipy-tract 7da22c3;
|
| 54 |
`tools/measure_brain.py`, 2026-09-30), steady state, seconds:
|
| 55 |
|
app.py
CHANGED
|
@@ -4,14 +4,15 @@ the source's share a run needs that the page has not cached, a brain's pack resp
|
|
| 4 |
as the request arrives); the cache lookup before it and the page's drawing and files after it stay in this process
|
| 5 |
(disco-space#7). The configuration is ``DISCO_CONFIG`` (``space/config.toml``, the DiSCo Space, unless the
|
| 6 |
Space sets another: ``brain.toml`` for the brain Space). The compute backend is torch (``DISCO_BACKEND=torch``), the
|
| 7 |
-
layout's
|
|
|
|
| 8 |
deterministic algorithms are on and TF32 off inside the call. The source's data come from the Hub at the config's
|
| 9 |
revision, downloaded once when the container starts (the parent process loads it before the page serves; the forked
|
| 10 |
GPU worker inherits it). ``spaces`` must be imported before torch."""
|
| 11 |
import os
|
| 12 |
|
| 13 |
os.environ.setdefault("DISCO_BACKEND", "torch")
|
| 14 |
-
os.environ.setdefault("DISCO_RESIDENT", "0") # the pool drops the device between calls:
|
| 15 |
os.environ.setdefault("JAX_PLATFORMS", "cpu") # jax is a library dependency here; it never sees the GPU
|
| 16 |
|
| 17 |
from space.limits import cap_threads, cap_affinity # noqa: E402 (no numpy behind it)
|
|
@@ -47,7 +48,7 @@ def probe():
|
|
| 47 |
for the deployment check, `/probe`)."""
|
| 48 |
import glob, json, time
|
| 49 |
import numpy as np
|
| 50 |
-
st = A._load(); src = st["source"]; where = getattr(getattr(src, "
|
| 51 |
out = dict(torch=torch.__version__, cuda=torch.cuda.is_available(), device=torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
|
| 52 |
gpu_memory_gb=round(torch.cuda.get_device_properties(0).total_memory / 1e9, 1) if torch.cuda.is_available() else None,
|
| 53 |
layout=where, data=sorted(os.path.basename(p) for p in glob.glob(os.path.join(where, "*")))[:12], backend=os.environ.get("DISCO_BACKEND"),
|
|
@@ -59,24 +60,21 @@ def probe():
|
|
| 59 |
out["host_ram_total_gb"] = round(int(mem["MemTotal"].split()[0]) / 1e6, 1); out["host_ram_available_gb"] = round(int(mem["MemAvailable"].split()[0]) / 1e6, 1)
|
| 60 |
except Exception as e:
|
| 61 |
out["meminfo"] = repr(e)[:100]
|
| 62 |
-
files = sorted(glob.glob(os.path.join(where, "
|
| 63 |
if files:
|
| 64 |
t0 = time.perf_counter(); m = np.load(files[0], mmap_mode="r"); chunk = np.array(m[:100000]); dt = time.perf_counter() - t0
|
| 65 |
out["read_mb"] = round(chunk.nbytes / 1e6, 1); out["read_mb_per_s"] = round(chunk.nbytes / 1e6 / dt, 1)
|
| 66 |
-
t0 = time.perf_counter(); torch.as_tensor(chunk, device="cuda"); torch.cuda.synchronize(); out["upload_mb_per_s"] = round(chunk.nbytes / 1e6 / (time.perf_counter() - t0), 1)
|
| 67 |
-
# the
|
| 68 |
try:
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
t0 = time.perf_counter(); src.moments.image(name, b, u, backend="torch", resident=False); torch.cuda.synchronize(); out["image_eager_nonresident_s"] = round(time.perf_counter() - t0, 2)
|
| 78 |
-
finally:
|
| 79 |
-
SM._TORCH_KERNEL.update(kernel) # the probe leaves the kernel as it found it
|
| 80 |
except Exception as e:
|
| 81 |
out["image_probe_error"] = repr(e)[:300]
|
| 82 |
return json.dumps(out)
|
|
|
|
| 4 |
as the request arrives); the cache lookup before it and the page's drawing and files after it stay in this process
|
| 5 |
(disco-space#7). The configuration is ``DISCO_CONFIG`` (``space/config.toml``, the DiSCo Space, unless the
|
| 6 |
Space sets another: ``brain.toml`` for the brain Space). The compute backend is torch (``DISCO_BACKEND=torch``), the
|
| 7 |
+
layout's packed words are streamed to the device a chunk at a time inside every call (``resident = false``: nothing
|
| 8 |
+
survives between calls),
|
| 9 |
deterministic algorithms are on and TF32 off inside the call. The source's data come from the Hub at the config's
|
| 10 |
revision, downloaded once when the container starts (the parent process loads it before the page serves; the forked
|
| 11 |
GPU worker inherits it). ``spaces`` must be imported before torch."""
|
| 12 |
import os
|
| 13 |
|
| 14 |
os.environ.setdefault("DISCO_BACKEND", "torch")
|
| 15 |
+
os.environ.setdefault("DISCO_RESIDENT", "0") # the pool drops the device between calls: words streamed per call
|
| 16 |
os.environ.setdefault("JAX_PLATFORMS", "cpu") # jax is a library dependency here; it never sees the GPU
|
| 17 |
|
| 18 |
from space.limits import cap_threads, cap_affinity # noqa: E402 (no numpy behind it)
|
|
|
|
| 48 |
for the deployment check, `/probe`)."""
|
| 49 |
import glob, json, time
|
| 50 |
import numpy as np
|
| 51 |
+
st = A._load(); src = st["source"]; where = getattr(getattr(src, "layout", None), "path", "")
|
| 52 |
out = dict(torch=torch.__version__, cuda=torch.cuda.is_available(), device=torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
|
| 53 |
gpu_memory_gb=round(torch.cuda.get_device_properties(0).total_memory / 1e9, 1) if torch.cuda.is_available() else None,
|
| 54 |
layout=where, data=sorted(os.path.basename(p) for p in glob.glob(os.path.join(where, "*")))[:12], backend=os.environ.get("DISCO_BACKEND"),
|
|
|
|
| 60 |
out["host_ram_total_gb"] = round(int(mem["MemTotal"].split()[0]) / 1e6, 1); out["host_ram_available_gb"] = round(int(mem["MemAvailable"].split()[0]) / 1e6, 1)
|
| 61 |
except Exception as e:
|
| 62 |
out["meminfo"] = repr(e)[:100]
|
| 63 |
+
files = sorted(glob.glob(os.path.join(where, "p_*.npy")))
|
| 64 |
if files:
|
| 65 |
t0 = time.perf_counter(); m = np.load(files[0], mmap_mode="r"); chunk = np.array(m[:100000]); dt = time.perf_counter() - t0
|
| 66 |
out["read_mb"] = round(chunk.nbytes / 1e6, 1); out["read_mb_per_s"] = round(chunk.nbytes / 1e6 / dt, 1)
|
| 67 |
+
t0 = time.perf_counter(); torch.as_tensor(chunk.view(np.int32), device="cuda"); torch.cuda.synchronize(); out["upload_mb_per_s"] = round(chunk.nbytes / 1e6 / (time.perf_counter() - t0), 1)
|
| 68 |
+
# the time-segment image inside this worker, streamed and (when it fits) resident: first and second call
|
| 69 |
try:
|
| 70 |
+
name = "d10.2-D16.7"
|
| 71 |
+
seq = src.sequence(name, np.full(184, 1000.0), np.tile([0.6, 0.0, 0.8], (184, 1)))
|
| 72 |
+
for resident in (False, True):
|
| 73 |
+
for k in ("first", "second"):
|
| 74 |
+
t0 = time.perf_counter(); src.layout.image(seq, partition=name, backend="torch", resident=resident); torch.cuda.synchronize()
|
| 75 |
+
out[f"image_{'resident' if resident else 'streamed'}_{k}_s"] = round(time.perf_counter() - t0, 2)
|
| 76 |
+
out["gpu_peak_gb"] = round(torch.cuda.max_memory_allocated() / 1e9, 2)
|
| 77 |
+
src.layout.release()
|
|
|
|
|
|
|
|
|
|
| 78 |
except Exception as e:
|
| 79 |
out["image_probe_error"] = repr(e)[:300]
|
| 80 |
return json.dumps(out)
|
data/SOURCE.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
From the Diffusion-Simulated Connectivity (DiSCo) dataset, Rafael-Patino J., Girard G., Truffet R., Pizzolato M.,
|
| 4 |
Caruyer E., Thiran J.-P., "The Diffusion-Simulated Connectivity (DiSCo) dataset", Data in Brief 38 (2021) 107429,
|
| 5 |
-
https://doi.org/10.1016/j.dib.2021.107429; data
|
| 6 |
|
| 7 |
| file | what |
|
| 8 |
|---|---|
|
|
@@ -11,6 +11,6 @@ https://doi.org/10.1016/j.dib.2021.107429; data at https://doi.org/10.5281/zenod
|
|
| 11 |
| `DiSCo_gradients.bvals`, `DiSCo_gradients_dipy.bvecs` | the dataset's 364-measurement gradient table (b in s/mm²), the "DiSCo 364" acquisition |
|
| 12 |
| `DiSCo_Connectivity_Matrix_Strands_Count.txt` | ground truth: strands per region pair |
|
| 13 |
| `DiSCo_Connectivity_Matrix_Cross-Sectional_Area.txt` | ground truth: total cross-sectional area per region pair |
|
| 14 |
-
| `DiSCo_Strands_Trajectories.tck`, `
|
| 15 |
|
| 16 |
Unmodified copies of the dataset's DiSCo1 files, redistributed here under CC BY 4.0 so the Space is self-contained.
|
|
|
|
| 2 |
|
| 3 |
From the Diffusion-Simulated Connectivity (DiSCo) dataset, Rafael-Patino J., Girard G., Truffet R., Pizzolato M.,
|
| 4 |
Caruyer E., Thiran J.-P., "The Diffusion-Simulated Connectivity (DiSCo) dataset", Data in Brief 38 (2021) 107429,
|
| 5 |
+
https://doi.org/10.1016/j.dib.2021.107429; data on Mendeley Data, https://doi.org/10.17632/fgf86jdfg6.1 (files checked by sha256 against the published release). Licence CC BY 4.0.
|
| 6 |
|
| 7 |
| file | what |
|
| 8 |
|---|---|
|
|
|
|
| 11 |
| `DiSCo_gradients.bvals`, `DiSCo_gradients_dipy.bvecs` | the dataset's 364-measurement gradient table (b in s/mm²), the "DiSCo 364" acquisition |
|
| 12 |
| `DiSCo_Connectivity_Matrix_Strands_Count.txt` | ground truth: strands per region pair |
|
| 13 |
| `DiSCo_Connectivity_Matrix_Cross-Sectional_Area.txt` | ground truth: total cross-sectional area per region pair |
|
| 14 |
+
| `DiSCo_Strands_Diameters.txt` (the centrelines, `DiSCo_Strands_Trajectories.tck`, are read from `SubstrateCommons/disco-replay` `substrate/` at the pinned revision) | the 12,196 strands' centerlines (in units of the 25 µm voxel, i.e. the image grid) and inner diameters (mm): the ground-truth view |
|
| 15 |
|
| 16 |
Unmodified copies of the dataset's DiSCo1 files, redistributed here under CC BY 4.0 so the Space is self-contained.
|
data/classes/qte-now-80.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6310e918172eb0086c9c31158307d8e4cfeca8c25b1a9fbab0a25e6838c71706
|
| 3 |
+
size 24128
|
data/protocols/c2.bvals
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
0.0 0.0 0.0 0.0 0.0 0.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0
|
data/protocols/c2.bvecs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 -0.32949755 0.33414940 -0.60771156 -0.82127342 0.01160086 -0.12962089 0.26498506 0.56912603 -0.40095140 0.11311349 -0.25030891 0.77356999 -0.52375456 0.94083501 -0.63920498 -0.67014535 -0.45412061 0.61922918 0.79604789 -0.78743880 -0.18826079 -0.94768325 -0.58407055 -0.02913436 -0.62545224 -0.33733820 -0.21403085 0.00421902 0.58345560 -0.35220267 0.45295742 0.91782422 0.94162595 0.28168108 0.79855761 0.99971654 0.48125673 0.36140462 0.24089400 0.26950471 -0.82478877 0.13564362 0.29332779 0.56938601 -0.08884351 -0.33494071 -0.85970235 0.73020584 0.92961722 -0.03034955 -0.60692305 0.80574615 0.08571867 -0.94986286 -0.03689412 -0.79313489 0.57162376 0.36836640 -0.49539615 0.74900912
|
| 2 |
+
0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.62521613 0.94159591 -0.79319112 -0.55746095 0.64167929 -0.22505973 0.77269471 -0.07315939 0.80871935 0.37214629 -0.52159459 -0.63362406 0.30920269 -0.33860613 -0.01697085 0.68218268 -0.25517415 0.28358495 -0.51642751 0.22226796 0.96109710 0.09345700 -0.46706432 -0.91920621 0.52925384 -0.89663480 0.37609875 0.07555217 0.60185062 -0.73383382 -0.38312209 -0.21553952 0.10663176 -0.64667073 0.02326119 -0.01496704 0.81874023 0.15874085 -0.16398178 -0.95125392 -0.13803286 0.94259977 -0.84100598 -0.78380323 0.84364697 0.05931043 0.41440579 -0.31387397 0.35178020 -0.74332437 -0.70211555 0.37244222 -0.45885689 -0.22994878 -0.99750236 -0.42974786 -0.61279834 0.51158361 0.86507917 0.62230435
|
| 3 |
+
0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.70748580 0.04172908 0.03917277 0.12143827 0.76688533 0.96568450 0.57682390 0.81898917 0.43036146 0.92125592 0.81564976 0.01048864 0.79377255 0.01324307 0.76884913 0.29245853 0.85361621 0.73221227 0.31561113 0.57492355 0.20211443 0.30522492 0.66386182 0.39269726 0.57332353 0.28679084 0.90152123 0.99713292 0.54530302 0.58089690 0.80501369 0.33337877 0.31932779 0.70885311 0.60146876 0.01851587 0.31313957 0.91879706 0.95659817 0.14994393 0.54833422 0.30513354 0.45460724 0.24789529 0.52949658 0.94037067 0.29862973 0.60686289 0.10982952 0.66824231 0.37239518 0.46049966 0.88436569 0.21185870 0.06023173 0.43157134 0.54564134 0.77626568 0.07887130 0.22742611
|
data/protocols/clinical.bvals
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
0.0 0.0 0.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0
|
data/protocols/clinical.bvecs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
0.00000000 0.00000000 0.00000000 -0.21204524 -0.95684375 0.79698041 -0.41182571 -0.81011573 -0.11514776 -0.92695104 0.43532954 0.92492393 -0.42978830 -0.64150046 -0.39834740 -0.23078110 -0.72213785 -0.55205845 0.72558771 0.38087223 -0.63961904 0.96273433 0.35854872 0.01680837 0.04499735 0.64600654 0.69874109 -0.28489889 0.47941432 0.02769143 0.18831204 0.17906768 0.77676570
|
| 2 |
+
0.00000000 0.00000000 0.00000000 -0.75054853 0.24143549 0.60309286 -0.90060083 0.12191004 0.46881409 -0.24577886 -0.12018932 -0.25417356 0.08442250 -0.30479278 0.91680538 -0.37028342 0.63418578 0.48863467 -0.66199141 0.35783255 -0.64027306 0.19605979 -0.79351726 -0.96157594 0.97637349 -0.44154424 0.48464294 0.81674577 0.81785461 0.02205862 -0.52460313 0.73817677 0.05109827
|
| 3 |
+
0.00000000 0.00000000 0.00000000 0.62587036 0.16173726 0.03318481 0.13898823 0.57345483 0.87575929 0.28346871 0.89221226 0.28268628 0.89897434 0.70397338 0.02805805 0.89979457 0.27627039 0.67562388 0.18785595 0.85257974 0.42536795 0.18628785 0.49170436 0.27402405 0.21135290 0.62266703 0.52619589 0.50175589 0.31823852 0.99937310 0.83025908 0.65040743 0.62771332
|
data/protocols/ogse.bvals
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
0.0 0.0 0.0 0.0 0.0 0.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0
|
data/protocols/ogse.bvecs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 -0.32949755 0.33414940 -0.60771156 -0.82127342 0.01160086 -0.12962089 0.26498506 0.56912603 -0.40095140 0.11311349 -0.25030891 0.77356999 -0.52375456 0.94083501 -0.63920498 -0.67014535 -0.45412061 0.61922918 0.79604789 -0.78743880 -0.18826079 -0.94768325 -0.58407055 -0.02913436 -0.62545224 -0.33733820 -0.21403085 0.00421902 0.58345560 -0.35220267 0.45295742 0.91782422 0.94162595 0.28168108 0.79855761 0.99971654 0.48125673 0.36140462 0.24089400 0.26950471 -0.82478877 0.13564362 0.29332779 0.56938601 -0.08884351 -0.33494071 -0.85970235 0.73020584 0.92961722 -0.03034955 -0.60692305 0.80574615 0.08571867 -0.94986286 -0.03689412 -0.79313489 0.57162376 0.36836640 -0.49539615 0.74900912
|
| 2 |
+
0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.62521613 0.94159591 -0.79319112 -0.55746095 0.64167929 -0.22505973 0.77269471 -0.07315939 0.80871935 0.37214629 -0.52159459 -0.63362406 0.30920269 -0.33860613 -0.01697085 0.68218268 -0.25517415 0.28358495 -0.51642751 0.22226796 0.96109710 0.09345700 -0.46706432 -0.91920621 0.52925384 -0.89663480 0.37609875 0.07555217 0.60185062 -0.73383382 -0.38312209 -0.21553952 0.10663176 -0.64667073 0.02326119 -0.01496704 0.81874023 0.15874085 -0.16398178 -0.95125392 -0.13803286 0.94259977 -0.84100598 -0.78380323 0.84364697 0.05931043 0.41440579 -0.31387397 0.35178020 -0.74332437 -0.70211555 0.37244222 -0.45885689 -0.22994878 -0.99750236 -0.42974786 -0.61279834 0.51158361 0.86507917 0.62230435
|
| 3 |
+
0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.70748580 0.04172908 0.03917277 0.12143827 0.76688533 0.96568450 0.57682390 0.81898917 0.43036146 0.92125592 0.81564976 0.01048864 0.79377255 0.01324307 0.76884913 0.29245853 0.85361621 0.73221227 0.31561113 0.57492355 0.20211443 0.30522492 0.66386182 0.39269726 0.57332353 0.28679084 0.90152123 0.99713292 0.54530302 0.58089690 0.80501369 0.33337877 0.31932779 0.70885311 0.60146876 0.01851587 0.31313957 0.91879706 0.95659817 0.14994393 0.54833422 0.30513354 0.45460724 0.24789529 0.52949658 0.94037067 0.29862973 0.60686289 0.10982952 0.66824231 0.37239518 0.46049966 0.88436569 0.21185870 0.06023173 0.43157134 0.54564134 0.77626568 0.07887130 0.22742611
|
data/protocols/research.bvals
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0
|
data/protocols/research.bvecs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.59576860 0.93816635 -0.98905967 0.62451169 0.60683766 0.46290349 0.52148610 0.67795329 0.57111780 -0.17229806 -0.60572887 0.30809534 0.09534500 -0.77316638 0.09213804 -0.93519076 -0.55432683 0.81317227 0.87438895 -0.32853522 -0.56029699 0.97598393 -0.23544716 0.73124810 -0.00744073 -0.91070654 -0.16119654 0.09450063 0.39933516 0.57567786 -0.50070270 -0.83540161 0.98983934 -0.60281077 0.26703580 0.14275761 0.76160413 0.22068804 0.46004974 -0.16201257 -0.76341115 -0.91346498 0.19530566 -0.83054568 -0.07599847 -0.32379331 0.20157094 -0.60199623 -0.96376113 -0.43532233 -0.37974241 -0.67767550 0.33952724 0.44205976 -0.70961734 0.80187651 0.74817156 0.36770201 -0.86826836 -0.41544466 0.88698753 -0.58844532 0.67213557 -0.48615765 0.05142893 -0.36298452 0.90965478 -0.05848180 0.42943351 -0.78648992 -0.65587245 -0.39711485 -0.74887479 0.36635901 -0.05166445 -0.35496186 0.04103047 -0.12970923 0.06803766 -0.79174685 -0.18015876 0.82118333 -0.24712053 0.33019542 0.62372888 0.18643620 -0.40296738 0.19068873 0.92264726 -0.12540350 -0.33939275 0.16937678 0.89443500 0.34132607 -0.11320959 -0.81549102 0.64271660 -0.56923596 -0.76110906 -0.18178130 -0.28013937 -0.91360323 0.62079660 -0.46983928 0.38745233 -0.38416406 0.86822315 0.38120650 0.97531028 0.14051826 0.09884027 0.91267749 0.12291292 0.09417199 0.03506780 0.75051710 -0.63253219 -0.93656587 -0.74230411 -0.17556086 -0.90079207 0.56642162 -0.07126760 -0.38884372 -0.81568460 -0.07870329 0.77216408 0.34610728 -0.14906497 0.49412932 -0.41089422 -0.70913609 0.73202853 0.04978052 0.25106660 0.14193245 -0.75510751 0.66482405 -0.54595570 -0.64863191 0.86777392 -0.61767795 0.38730006 0.53672683 -0.96571430 -0.08597049 -0.19297778 -0.01677332 -0.42439546 0.58300144 0.95287282 0.78830792 -0.15891744 0.44046530 0.43636594 -0.12828867 0.62440566 -0.62827991 0.05265537 0.30402380 0.72392095 0.89347943 -0.22828902 -0.35081051 -0.86751866 0.30659065 -0.53668306 0.50998312 0.30891840 0.56165136 0.19374169 -0.58996776 -0.53446692 -0.79846068 -0.35390888 -0.97788739 0.75071745 -0.99547768 0.84048957 -0.31808885 0.94484484 -0.84507777 -0.27683998 0.88646476 -0.53324067 -0.41203317 -0.89513761 0.95935617 0.49252736 0.02044253 -0.75474992 -0.27425308 -0.60748804 -0.14677795 0.74445785 0.65184168 0.07207788 0.13286927 -0.77145863 0.12854416 0.84895556 0.38505005 -0.46946387 -0.32491845 0.56555779 0.24922406 0.10881076 0.60604096 0.99616741 -0.67208291 0.73654120 0.00020812 -0.46032509 -0.37727868 -0.61581083 0.35902896 0.36400089 -0.77937346 0.11745982 -0.65124185 0.34467616 -0.59758113 0.51582304 0.71983068 0.52860193 -0.89761540 -0.08842218 0.24092043 -0.82485704 0.33566474 0.53588812 0.86327729 0.39890986 -0.29144269 0.27586801 -0.20370148 -0.90224938 0.24743918 0.68573160 0.92545984 0.79372111 0.00141729 -0.47546969 -0.52426327 -0.43561588 0.43586461 -0.96073423 -0.73996389 -0.35118151 -0.19461791 0.59799415 0.16707138 0.27516140 -0.49623143 -0.64003755 0.84463988 -0.02563607 -0.16777918 0.75162129 -0.24697176 0.06620066 -0.71414354 -0.10757112 -0.15534669 -0.87945643 0.53522975 -0.96915272 -0.01847400 -0.98015195 0.72625438
|
| 2 |
+
0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 -0.34328993 -0.02894166 -0.07537980 -0.77822261 0.16522689 -0.09717810 0.72628440 -0.09066748 -0.57192510 -0.42570880 -0.06645117 0.95007460 -0.54106777 0.62788656 0.60148275 -0.34745037 0.23970275 -0.22071348 0.45331689 -0.92347344 -0.82719563 0.16614636 -0.86119434 0.32668414 -0.83868609 0.03894678 0.12418008 0.15327271 -0.76705287 0.53968238 0.69338938 0.29984505 -0.11459020 -0.33206066 0.83397117 0.95050176 -0.46440436 0.39494465 0.39803948 0.97133778 -0.39143607 -0.22386741 -0.90417263 -0.48421087 0.90476887 0.33809415 -0.97354662 -0.58847011 0.18757384 0.53221513 -0.23412573 -0.68869468 -0.32800806 0.86362704 0.58171516 -0.56702420 0.53449434 -0.54890950 0.43490556 0.90722617 0.26762841 0.77273332 0.71376276 -0.79077834 0.99864256 -0.69682954 -0.30236529 0.38688077 -0.87328527 -0.61725755 0.42880755 0.03881415 0.13169272 0.15159132 -0.96429629 0.86683029 0.78388462 -0.13470650 -0.30775216 -0.13527179 0.59493900 0.07050457 0.76497525 0.64939030 -0.70278082 -0.72691934 -0.49299344 -0.08314931 -0.38487194 -0.66612704 -0.76522024 0.28827414 -0.44369383 0.07146179 0.94669326 0.42041533 0.31113019 -0.73033240 0.24207340 -0.97875668 -0.58813087 0.14427350 -0.61873144 -0.38421131 -0.77768481 0.84994249 0.49572154 0.86362573 0.05213835 0.97147518 0.72495971 0.33554201 0.88218359 -0.02003787 -0.29239537 0.45134487 0.66200677 0.30802605 -0.65774550 -0.08149437 -0.37795979 -0.77430176 -0.75072201 0.51297984 -0.02603652 0.18323856 0.58426463 0.74015658 0.82866461 -0.86769512 0.70423284 -0.38430215 -0.16766046 -0.96939287 -0.45494868 -0.86386005 -0.53601743 -0.41311978 -0.82943151 -0.18515687 0.20623515 0.06647726 0.91913452 0.55014454 -0.07461521 0.99616771 0.39808667 -0.54237055 -0.11408583 0.70047299 -0.23070901 -0.54778626 0.65259125 -0.56524431 0.30300002 -0.89603466 0.76911011 0.49328279 0.50839647 -0.19727400 -0.68756584 -0.07298230 -0.34776838 -0.89275958 -0.23690190 -0.92239684 0.30665494 -0.28982189 0.53553365 -0.00057833 -0.68829445 0.78718234 -0.58433993 0.57476084 0.15904028 -0.20890286 0.09035692 0.08881355 -0.33727115 0.94161979 -0.07278939 0.42737516 0.50462476 0.45055703 0.43525339 0.66745109 -0.11119547 0.18188953 0.85647022 0.87327104 0.31801063 0.93861836 -0.74126614 0.71700485 -0.47129467 -0.30718999 0.06162447 0.73130663 -0.30652513 -0.95586512 0.37808679 0.71043815 -0.01998251 -0.59424799 -0.80730057 -0.17827675 -0.82406971 0.12498971 -0.06188978 -0.74004371 0.64270367 0.53716192 -0.36470688 0.25424118 0.17272700 -0.86935053 -0.74307542 0.02510924 -0.42406819 0.56495917 0.07704918 -0.49651760 -0.12763604 0.30301213 -0.54601361 -0.33753347 -0.91631661 0.96247567 -0.54996550 -0.93995524 0.50041170 0.11962207 -0.38316851 -0.21390578 0.87976651 0.04508451 0.16816510 -0.60994601 0.55493582 -0.32361836 -0.56216559 0.96810208 -0.70511581 0.84300859 -0.89431241 0.31580809 0.24785562 -0.54227393 -0.86733432 -0.78135457 -0.69126622 0.31346996 0.53654334 0.78059548 -0.17313862 -0.23968231 -0.19664312 -0.97660760 -0.05244464 0.85364998 -0.99780619 0.65146757 0.30182409 -0.44708130 0.47460028 0.75394102 -0.24229357 -0.65640243 -0.02491093 -0.68741560
|
| 3 |
+
0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.72609352 0.34497286 0.12680240 0.06599022 0.77746262 0.88106571 0.44784285 0.72949212 0.58883457 0.88830479 0.79289140 0.04935098 0.83555670 0.08928721 0.79355471 0.06853090 0.79703472 0.53855029 0.17305420 0.19814494 0.04259901 0.14089267 0.45045416 0.59879353 0.54456431 0.41121374 0.97907864 0.98365497 0.50215667 0.61427839 0.51817755 0.46064857 0.08418529 0.72550321 0.48289127 0.27598311 0.45198201 0.89180464 0.79367424 0.17393922 0.51379103 0.33980158 0.37990465 0.27519758 0.41906746 0.88365731 0.10759245 0.53972536 0.18968538 0.72611399 0.89497533 0.25778975 0.88155089 0.24234584 0.39755591 0.18835502 0.39313498 0.75066210 0.23867804 0.06592729 0.37633517 0.23793974 0.19691747 0.37191446 0.00825285 0.61860394 0.28478662 0.92027341 0.23012974 0.02065724 0.62125309 0.91694779 0.64949486 0.91804202 0.25973726 0.35015328 0.61954935 0.98235924 0.94903081 0.59568320 0.78332012 0.56629237 0.59476408 0.68502790 0.34214237 0.66093102 0.77108674 0.97812269 0.02440558 0.73522019 0.54703798 0.94244869 0.05587325 0.93722448 0.30158822 0.39777551 0.70008098 0.37760430 0.60175864 0.09482047 0.75869890 0.38015035 0.48143845 0.79475324 0.49506264 0.36057695 0.02118273 0.32989726 0.21459601 0.19102511 0.68166269 0.23330527 0.45458171 0.99535427 0.95565433 0.48271305 0.40205729 0.16722536 0.12788851 0.98108973 0.21382246 0.28217607 0.65676281 0.76527920 0.57791066 0.97991297 0.24979487 0.57652233 0.53953183 0.05423453 0.57898363 0.59113270 0.66032129 0.24041500 0.85439292 0.48332287 0.37748902 0.62236727 0.11821902 0.73823681 0.45214543 0.78361644 0.07204445 0.63973845 0.24865328 0.01609243 0.89682026 0.83997193 0.89826105 0.41163929 0.19699424 0.28018000 0.74085755 0.69748778 0.84721648 0.42504576 0.13633491 0.60161155 0.85951175 0.93201529 0.05649487 0.44313440 0.90936312 0.28268766 0.43736583 0.23491750 0.78608781 0.80988918 0.78598545 0.82737381 0.69908148 0.17967194 0.61064880 0.17919408 0.92165866 0.00979013 0.65441496 0.03370965 0.42405833 0.11032524 0.31932731 0.32123829 0.81774903 0.10572887 0.72540256 0.62027229 0.43169923 0.21575899 0.15451722 0.48680571 0.57377853 0.20923867 0.28545189 0.68143985 0.47293111 0.69335181 0.99549345 0.66898159 0.55757862 0.26419361 0.36922194 0.58907902 0.88272554 0.73572911 0.16855317 0.95189536 0.55594004 0.78555199 0.06180726 0.02529581 0.21080574 0.84347912 0.80937612 0.89051795 0.76872778 0.33959955 0.56155344 0.62605634 0.89798071 0.50666083 0.93555426 0.62957690 0.84713377 0.62452176 0.64996087 0.28347443 0.39057060 0.12489169 0.13095346 0.06175207 0.68000886 0.49034976 0.83309832 0.93236553 0.38717920 0.97799442 0.39707249 0.75282117 0.47097601 0.19696510 0.23232877 0.25055210 0.52606108 0.12035172 0.10219646 0.84278544 0.12472902 0.39798545 0.35270770 0.59296620 0.40565257 0.93478540 0.79775150 0.38003298 0.74858197 0.47868139 0.98013993 0.13449069 0.65750665 0.45857023 0.00052158 0.25610353 0.94727539 0.88090052 0.03620729 0.38092263 0.04513119 0.75418470 0.19667640 0.00379049
|
requirements.txt
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
-
# The ZeroGPU (Gradio SDK) deployment: PyTorch on the shared pool; jax stays as a CPU library dependency of dmipy
|
| 2 |
-
# and never touches the GPU. The dmipy packages
|
| 3 |
-
# dmipy-
|
| 4 |
torch>=2.4
|
| 5 |
tomli; python_version < "3.11" # the ZeroGPU image is Python 3.10 (its README python_version is ignored)
|
| 6 |
spaces>=0.30
|
| 7 |
gradio==6.28.0
|
| 8 |
-
jax>=0.
|
| 9 |
jaxopt==0.8.5
|
| 10 |
-
numpy>=
|
| 11 |
scipy>=1.11
|
| 12 |
nibabel==5.4.2
|
| 13 |
safetensors>=0.4
|
|
@@ -16,8 +16,8 @@ requests>=2.31
|
|
| 16 |
matplotlib>=3.8
|
| 17 |
plotly>=5.20
|
| 18 |
pillow>=10
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
dmipy-sim
|
| 22 |
-
dmipy-fit
|
| 23 |
-
dmipy-tract
|
|
|
|
| 1 |
+
# The ZeroGPU (Gradio SDK) deployment: PyTorch on the shared pool; jax stays as a CPU library dependency of the dmipy
|
| 2 |
+
# packages (the brain page's pose expansion runs on it) and never touches the GPU. The dmipy packages are the released
|
| 3 |
+
# 3.0 line from PyPI; dmipy-replay's extras: `pose` (the pose expansion and the CPU thread cap), `jax`, `torch`.
|
| 4 |
torch>=2.4
|
| 5 |
tomli; python_version < "3.11" # the ZeroGPU image is Python 3.10 (its README python_version is ignored)
|
| 6 |
spaces>=0.30
|
| 7 |
gradio==6.28.0
|
| 8 |
+
jax>=0.6.2
|
| 9 |
jaxopt==0.8.5
|
| 10 |
+
numpy>=2.0,<3
|
| 11 |
scipy>=1.11
|
| 12 |
nibabel==5.4.2
|
| 13 |
safetensors>=0.4
|
|
|
|
| 16 |
matplotlib>=3.8
|
| 17 |
plotly>=5.20
|
| 18 |
pillow>=10
|
| 19 |
+
dmipy-replay[pose,jax,torch]==0.2.0
|
| 20 |
+
dmipy-analytic==3.0.0
|
| 21 |
+
dmipy-sim==3.0.0
|
| 22 |
+
dmipy-fit==3.0.0
|
| 23 |
+
dmipy-tract==0.1.0
|
space/app.py
CHANGED
|
@@ -31,11 +31,14 @@ from dataclasses import replace
|
|
| 31 |
|
| 32 |
from . import pipeline as P
|
| 33 |
from . import sources
|
|
|
|
| 34 |
from . import viewers as V
|
| 35 |
|
| 36 |
MAX_SHELLS = 4
|
| 37 |
CUSTOM = "custom shells"
|
| 38 |
UPLOADED = "uploaded scheme"
|
|
|
|
|
|
|
| 39 |
PER_SHELL = 7 # on, timing class, b, directions, delta, Delta, TE
|
| 40 |
NO_KNOB = P.NO_KNOB
|
| 41 |
IDEAL = P.IDEAL
|
|
@@ -44,9 +47,12 @@ SAMPLE = 10_000 # streamlines kept in t
|
|
| 44 |
OUTPUTS = ("result", "headline", "dwi_view", "tract_view", "mats", "timings", "tck", "volumes", "z_slider", "m_slider",
|
| 45 |
"tract_view_b", "mats_b", "b_row", "floor_view", "accuracy", "spread_view",
|
| 46 |
"explore_view", "metric_view", "ingredient_pool", "ingredient_contact", "ingredient_field", "layer_table", "layer_choice",
|
| 47 |
-
"ez_slider", "em_slider", "truth_view", "fractions_view", "lobar_view", "roundtrip", "response_view"
|
|
|
|
| 48 |
EXPLORE_MODES = ("signal", "minus the previous layer", "B − A", "(B − A) ÷ floor")
|
| 49 |
-
METRICS = ("DWI", "MD (µm²/ms)", "FA")
|
|
|
|
|
|
|
| 50 |
_state = {"source": None, "error": None}
|
| 51 |
_lock = threading.Lock()
|
| 52 |
_RUNS = tempfile.mkdtemp(prefix="disco-runs-") # one directory per run tag, replaced on every run
|
|
@@ -104,9 +110,11 @@ def gradient_text(cfg, protocol, shapes, scanner):
|
|
| 104 |
"""The per-shell gradient table as Markdown, and whether every shell is playable on the menu's ``scanner``."""
|
| 105 |
rows = P.playable(protocol, shapes, P.machine(cfg, scanner))
|
| 106 |
lines = ["| shell | b (s/mm²) | timing | needs | limit |", "|---|---|---|---|---|"]
|
|
|
|
| 107 |
for name, b, delta, Delta, G, G_max, ok in rows:
|
| 108 |
limit = "any" if G_max is None else f"{G_max * 1e3:.0f} mT/m {'✓' if ok else '✗ cannot play'}"
|
| 109 |
-
|
|
|
|
| 110 |
return "\n".join(lines), all(r[-1] for r in rows)
|
| 111 |
|
| 112 |
|
|
@@ -196,8 +204,20 @@ def _fill(source, runs, ladder_on, prepared):
|
|
| 196 |
return out, computed, time.perf_counter() - t0
|
| 197 |
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
def _result_state(res, sample, load_seconds):
|
| 200 |
-
"""What the results tab needs, kept per session: float32 volumes, the peaks, the streamline sample, the numbers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
pk, amp = P.peaks(res.sh)
|
| 202 |
fr = res.extras.get("fractions")
|
| 203 |
return dict(dwi=res.dwi.astype(np.float32), floor=res.floor.astype(np.float32), floor_median=P.floor_stats(res)["median"], meas=res.meas,
|
|
@@ -222,11 +242,15 @@ def _explorer_state(results, ladder, ingredients, knob, source):
|
|
| 222 |
layers = list(ladder) + [(name_a, ra.clean)] + ([(name_b, results["B"].clean)] if "B" in results else [])
|
| 223 |
metrics = {}
|
| 224 |
for label, vol in layers:
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
return dict(layers=[(label, vol.astype(np.float16)) for label, vol in layers], metrics=metrics, floor=ra.floor.astype(np.float32),
|
| 231 |
meas=ra.meas, mask=mask, ingredients=source.ingredient_layers(ingredients), differences=P.layer_differences(layers, ra.meas, mask),
|
| 232 |
snr=ra.snr, physics=ra.physics)
|
|
@@ -255,6 +279,10 @@ def _write_files(tag, res, source):
|
|
| 255 |
sample as .tck, the DWI and FOD volumes, the source's own files; ``(sample, {"tck": [...], "volumes": [...]})``."""
|
| 256 |
out = os.path.join(_RUNS, tag); shutil.rmtree(out, ignore_errors=True); os.makedirs(out)
|
| 257 |
stem = f"{source.describe(source.cfg)['files']}_{tag}_{res.protocol.name.replace(' ', '_')}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
tck = os.path.join(out, f"{stem}.tck"); res.tractogram.to_tck(tck)
|
| 259 |
sample = P.sample_tractogram(res.tractogram, SAMPLE)
|
| 260 |
sample_path = os.path.join(out, f"{stem}_sample{SAMPLE // 1000}k.tck"); sample.to_tck(sample_path)
|
|
@@ -262,24 +290,36 @@ def _write_files(tag, res, source):
|
|
| 262 |
return sample, dict(tck=[tck, sample_path], volumes=[vols["dwi"], vols["bvals"], vols["bvecs"], vols["fod"]] + source.extra_files(res, out, stem))
|
| 263 |
|
| 264 |
|
| 265 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
"""The explorer's map: ``layer`` of the state's layers, in ``mode`` (the signal, its difference to the previous
|
| 267 |
-
layer, B minus A, or that over the replay floor), as the DWI at measurement ``m``
|
|
|
|
| 268 |
if not ex:
|
| 269 |
return None
|
| 270 |
labels = [l for l, _ in ex["layers"]]; vols = dict(ex["layers"])
|
| 271 |
if layer not in vols:
|
| 272 |
layer = labels[-1]
|
| 273 |
-
z = int(z); m =
|
| 274 |
k = METRICS.index(metric) if metric in METRICS else 0
|
| 275 |
|
| 276 |
def field(label):
|
| 277 |
if k == 0:
|
| 278 |
return np.asarray(vols[label], np.float32)[..., m]
|
| 279 |
-
|
| 280 |
-
v = md if k == 1 else fa
|
| 281 |
return None if v is None else np.asarray(v, np.float32)
|
| 282 |
-
|
|
|
|
|
|
|
| 283 |
x = field(layer)
|
| 284 |
if x is None:
|
| 285 |
return None
|
|
@@ -355,7 +395,7 @@ def compute(prepared, preset, n_b0, snr_on, snr, density, max_angle, step_mm, ke
|
|
| 355 |
yield (f"**Replay DWI Explorer · the ingredient maps** … ({time.perf_counter() - t0:.0f} s so far)", 0.85)
|
| 356 |
t = time.perf_counter(); ingredients = source.ingredients(meas_a, physics_a, prepared["runs"]["A"]); seconds["explorer · ingredients"] = time.perf_counter() - t
|
| 357 |
spread = None
|
| 358 |
-
if int(n_keys) > 1: # A's tracking repeated over further keys: the tractogram's own spread
|
| 359 |
keys = [int(key) + 1 + i for i in range(int(n_keys) - 1)]
|
| 360 |
mats = [results["A"].matrix]; scores = [results["A"].score]
|
| 361 |
for i, (k, M, sc, secs) in enumerate(P.repeat_tracking(results["A"], source, tracking, keys)):
|
|
@@ -397,9 +437,11 @@ def present(payload, state):
|
|
| 397 |
post["page · states"] = time.perf_counter() - t; t = time.perf_counter()
|
| 398 |
labels = [l for l, _ in ex["layers"]]; name_a = labels[-2] if "B" in results else labels[-1]
|
| 399 |
ra = rs["A"]; rb = rs.get("B")
|
| 400 |
-
|
|
|
|
|
|
|
| 401 |
if rb:
|
| 402 |
-
headline += "<br>" +
|
| 403 |
if spread:
|
| 404 |
headline += (f"<br>**A over {spread['n']} tracker keys: {spread['key']} {spread['pearson_mean']:.3f} ± {spread['pearson_std']:.3f}**, "
|
| 405 |
f"median pair count CV {spread['cv_median']:.2f}, {spread['pairs_always']} pairs in every run, {spread['pairs_any']} in any.")
|
|
@@ -413,26 +455,49 @@ def present(payload, state):
|
|
| 413 |
rs["explorer"] = ex # the page state: A, B and the explorer's layers
|
| 414 |
box = V.grid_box(ra["shape"], source.affine) if d["views"]["truth"] else ra["shape"]
|
| 415 |
pa = source.cached(results["A"].meas, results["A"].physics)
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
|
|
|
|
|
|
| 419 |
timings, [t_ for f in files.values() for t_ in f["tck"]], [v for f in files.values() for v in f["volumes"]],
|
| 420 |
_slider_update(z0, ra["dwi"].shape[2] - 1), _slider_update(m0, results["A"].protocol.n_meas - 1),
|
| 421 |
-
V.tractogram3d(rb["tractogram"], regions, box, total=rb["n_streamlines"]) if rb else None,
|
| 422 |
-
source.matrices(results["B"].matrix, results["B"].score, results["B"].reference) if rb else None,
|
| 423 |
-
gr.update(visible=rb is not None),
|
| 424 |
V.floor_slice(ra["floor"], z0, ra["floor_median"], label="A: ") if d["views"]["floor"] else None, source.accuracy(results["A"]),
|
| 425 |
V.spread_matrices(spread) if spread else None,
|
| 426 |
explore(ex, name_a, "minus the previous layer" if len(labels) > 1 else "signal", METRICS[0], z0, m0),
|
| 427 |
-
explore(ex, name_a, "signal", METRICS[2], z0, m0), *ingredient_views(ex, z0), layer_table(ex),
|
| 428 |
gr.update(choices=labels, value=name_a), _slider_update(z0, ra["dwi"].shape[2] - 1), _slider_update(m0, results["A"].protocol.n_meas - 1),
|
| 429 |
source.truth_view(ra["dwi"], ra["meas"], z0, m0), source.fractions_view(ra["extras"], z0),
|
| 430 |
-
source.lobar(results["A"].matrix, results["A"].score, results["A"].reference)
|
| 431 |
-
source.
|
|
|
|
|
|
|
| 432 |
timings.append(["page · figures", f"{time.perf_counter() - t:.2f}"])
|
| 433 |
return out
|
| 434 |
|
| 435 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
def run_pipeline(compute_fn, *args, progress=None):
|
| 437 |
"""The run button, a generator: the source's share of the runs the page has cached (:func:`prepare_runs`, a
|
| 438 |
lookup), then the stage texts of ``compute_fn`` (:func:`compute`, or it wrapped for a GPU pool) into the headline
|
|
@@ -473,13 +538,14 @@ def estimated_seconds(preset, n_b0, snr_on, snr, density, max_angle, step_mm, ke
|
|
| 473 |
(:meth:`~space.pipeline.Source.estimated_seconds`) with the source's share the call computes because the page has
|
| 474 |
it not cached (:func:`uncached_responses`). The pool refuses a request above the visitor's daily quota
|
| 475 |
(:data:`GPU_TIERS`) and kills a run that outlives its reservation, so this is the measured cost with its margin,
|
| 476 |
-
not a generous one; 480 when the inputs make no protocol
|
|
|
|
| 477 |
try:
|
| 478 |
cfg = P.config()
|
| 479 |
S = sources.source_class(cfg)
|
| 480 |
n = len(S.panel(cfg).fields)
|
| 481 |
protocol = _protocol_from_inputs(S, cfg, preset, n_b0, *rest[n:], scheme=scheme_file, full=S.mode == "full")
|
| 482 |
-
except
|
| 483 |
return 480
|
| 484 |
responses = uncached_responses(preset, n_b0, snr_on, snr, density, max_angle, step_mm, key, scheme_file, knob, scanner, n_keys, ladder_on, *rest)
|
| 485 |
try:
|
|
@@ -523,19 +589,63 @@ def _slider_update(value, maximum):
|
|
| 523 |
return gr.update(value=int(value), maximum=int(maximum))
|
| 524 |
|
| 525 |
|
| 526 |
-
def redraw_explorer(rs, layer, mode, metric, z, m):
|
| 527 |
ex = rs and rs.get("explorer")
|
| 528 |
-
|
|
|
|
| 529 |
|
| 530 |
|
| 531 |
-
def redraw_slice(rs, z, m, overlay, which):
|
| 532 |
if not rs or which not in rs:
|
| 533 |
-
return None, None, None, None
|
| 534 |
r = rs[which]; source = _load()["source"]
|
| 535 |
views = source.describe(source.cfg)["views"]
|
| 536 |
-
|
|
|
|
| 537 |
V.floor_slice(r["floor"], int(z), r["floor_median"], label=f"{which}: ") if views["floor"] else None,
|
| 538 |
-
source.truth_view(r["dwi"], r["meas"], int(z),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
|
| 540 |
|
| 541 |
def ground_truth_views():
|
|
@@ -563,13 +673,26 @@ def _widget(c):
|
|
| 563 |
raise ValueError(f"unknown control kind {c.kind!r}")
|
| 564 |
|
| 565 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
def build(runner=None, cfg=None):
|
| 567 |
-
"""The Blocks of the configured source (``cfg``, else :func:`space.pipeline.config`)
|
|
|
|
| 568 |
:func:`compute` for the run button (the ZeroGPU entry passes ``spaces.GPU(...)``): the device part of a run; the
|
| 569 |
page looks up its cache before it and draws from its payload after it, in this process."""
|
| 570 |
import gradio as gr
|
| 571 |
cfg = cfg or P.config()
|
| 572 |
S = sources.source_class(cfg)
|
|
|
|
|
|
|
|
|
|
| 573 |
d = S.describe(cfg)
|
| 574 |
full = S.mode == "full"
|
| 575 |
shapes = S.shapes_of(cfg); presets = S.presets(cfg) + [CUSTOM] + ([UPLOADED] if full else [])
|
|
@@ -624,7 +747,9 @@ def build(runner=None, cfg=None):
|
|
| 624 |
physics_inputs = [widgets[name] for name in panel.fields]
|
| 625 |
with gr.Row():
|
| 626 |
snr_on = gr.Checkbox(value=True, label="add Rician noise")
|
| 627 |
-
snr = gr.Slider(5, 100, value=
|
|
|
|
|
|
|
| 628 |
density = gr.Slider(tc["density"][0], tc["density"][1], value=tc["density"][2], step=tc["density"][3], label=tc["density"][4])
|
| 629 |
max_angle = gr.Slider(tc["max_angle"][0], tc["max_angle"][1], value=tc["max_angle"][2], step=tc["max_angle"][3], label=tc["max_angle"][4])
|
| 630 |
step_mm = gr.Slider(tc["step"][0], tc["step"][1], value=tc["step"][2], step=tc["step"][3], label=tc["step"][4])
|
|
@@ -636,7 +761,7 @@ def build(runner=None, cfg=None):
|
|
| 636 |
scanner_terms = gr.Markdown(P.scanner_text(cfg, IDEAL, S.refused_machines(cfg)))
|
| 637 |
gradients = gr.Markdown()
|
| 638 |
n_keys = gr.Slider(1, 8, value=1, step=1, label="repeat A's tracking over N keys (the tractogram's own spread)")
|
| 639 |
-
ladder_on = gr.Checkbox(value=
|
| 640 |
go = gr.Button(d["run_label"], variant="primary")
|
| 641 |
gpu_text = gr.Markdown(visible=runner is not None)
|
| 642 |
headline = gr.Markdown()
|
|
@@ -657,6 +782,7 @@ def build(runner=None, cfg=None):
|
|
| 657 |
with gr.Row():
|
| 658 |
ez_slider = gr.Slider(0, 39, value=20, step=1, label="axial slice z")
|
| 659 |
em_slider = gr.Slider(0, 363, value=0, step=1, label="measurement (DWI only)")
|
|
|
|
| 660 |
with gr.Row():
|
| 661 |
explore_view = gr.Image(label="the chosen layer and mode", type="pil")
|
| 662 |
metric_view = gr.Image(label="the same for FA (or the chosen metric)", type="pil")
|
|
@@ -671,17 +797,25 @@ def build(runner=None, cfg=None):
|
|
| 671 |
m_slider = gr.Slider(0, 363, value=0, step=1, label="measurement")
|
| 672 |
overlay = gr.Checkbox(value=True, label="FOD principal directions")
|
| 673 |
which = gr.Radio(["A", "B"], value="A", label="run")
|
|
|
|
| 674 |
with gr.Column(scale=1, visible=d["views"]["truth"]):
|
| 675 |
truth_view = gr.Image(label="the same slice with the input FOD's principal directions", type="pil")
|
| 676 |
with gr.Column(scale=1):
|
| 677 |
tract_view = gr.Plot(label="tractogram A")
|
| 678 |
fractions_view = gr.Image(label="recovered vs input fractions", type="pil", visible=d["views"]["fractions"])
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 685 |
roundtrip = gr.Dataframe(headers=["what", "value"], label="the round trip: the reconstruction and the connectome against the input",
|
| 686 |
interactive=False, wrap=True, visible=d["views"]["roundtrip"])
|
| 687 |
with gr.Accordion("accuracy: what this replay is an approximation of", open=False):
|
|
@@ -699,6 +833,7 @@ def build(runner=None, cfg=None):
|
|
| 699 |
def run_with_progress(*args, progress=gr.Progress()):
|
| 700 |
yield from run_pipeline(compute_fn, *args, progress=progress)
|
| 701 |
tissue_numbers = [widgets[name] for name in panel.catalogue] + [catalogue_note]
|
|
|
|
| 702 |
field_preset.change(lambda name: [field_presets[name]] + S.catalogue_numbers(cfg, field_presets[name]), inputs=field_preset,
|
| 703 |
outputs=[widgets["field_T"]] + tissue_numbers, show_progress="hidden")
|
| 704 |
reset.click(lambda f: S.catalogue_numbers(cfg, f), inputs=widgets["field_T"], outputs=tissue_numbers, show_progress="hidden")
|
|
@@ -725,9 +860,11 @@ def build(runner=None, cfg=None):
|
|
| 725 |
floor_view=floor_view, accuracy=accuracy, spread_view=spread_view, explore_view=explore_view, metric_view=metric_view,
|
| 726 |
ingredient_pool=ingredient_pool, ingredient_contact=ingredient_contact, ingredient_field=ingredient_field,
|
| 727 |
layer_table=layer_tbl, layer_choice=layer_choice, ez_slider=ez_slider, em_slider=em_slider, truth_view=truth_view,
|
| 728 |
-
fractions_view=fractions_view, lobar_view=lobar_view, roundtrip=roundtrip, response_view=response_view
|
| 729 |
-
|
| 730 |
-
|
|
|
|
|
|
|
| 731 |
outputs=[explore_view, metric_view, ingredient_pool, ingredient_contact, ingredient_field], show_progress="hidden")
|
| 732 |
run_inputs = [preset, n_b0, snr_on, snr, density, max_angle, step_mm, key, scheme_file, knob, scanner, n_keys, ladder_on, *physics_inputs, *shell_inputs]
|
| 733 |
if runner is not None: # the pool: what the run reserves, live as the inputs change
|
|
@@ -736,8 +873,13 @@ def build(runner=None, cfg=None):
|
|
| 736 |
outputs=[outputs[name] for name in OUTPUTS], concurrency_limit=1, api_name="run_pipeline") # the endpoint tools/live.py drives
|
| 737 |
if runner is not None: # the run's responses are now cached on the page: the reservation falls
|
| 738 |
run_event.then(gpu_seconds_text, inputs=run_inputs, outputs=gpu_text, show_progress="hidden")
|
| 739 |
-
for ctl in (z_slider, m_slider, overlay, which):
|
| 740 |
-
ctl.change(redraw_slice, inputs=[result, z_slider, m_slider, overlay, which
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
gt_tab.select(ground_truth_views, outputs=[strands_view, gt_matrix])
|
| 742 |
demo.load(lambda: (_load().get("error") and f"**the source did not load:** {_load()['error']}") or "", outputs=headline)
|
| 743 |
return demo
|
|
|
|
| 31 |
|
| 32 |
from . import pipeline as P
|
| 33 |
from . import sources
|
| 34 |
+
from . import tensor as T
|
| 35 |
from . import viewers as V
|
| 36 |
|
| 37 |
MAX_SHELLS = 4
|
| 38 |
CUSTOM = "custom shells"
|
| 39 |
UPLOADED = "uploaded scheme"
|
| 40 |
+
SNR_NOTE = ("µFA is noise-limited: the b-tensor preset starts at SNR 100, because at SNR 30 with the tissue at 3 T the "
|
| 41 |
+
"spherical encoding's contrast in the densest strand voxels is below the noise and µFA reads 0 there.")
|
| 42 |
PER_SHELL = 7 # on, timing class, b, directions, delta, Delta, TE
|
| 43 |
NO_KNOB = P.NO_KNOB
|
| 44 |
IDEAL = P.IDEAL
|
|
|
|
| 47 |
OUTPUTS = ("result", "headline", "dwi_view", "tract_view", "mats", "timings", "tck", "volumes", "z_slider", "m_slider",
|
| 48 |
"tract_view_b", "mats_b", "b_row", "floor_view", "accuracy", "spread_view",
|
| 49 |
"explore_view", "metric_view", "ingredient_pool", "ingredient_contact", "ingredient_field", "layer_table", "layer_choice",
|
| 50 |
+
"ez_slider", "em_slider", "truth_view", "fractions_view", "lobar_view", "roundtrip", "response_view",
|
| 51 |
+
"tensor_row", "tensor_view", "tensor_pick", "powder_view", "tensor_view_b", "connectome_row") # the run button's outputs, in order
|
| 52 |
EXPLORE_MODES = ("signal", "minus the previous layer", "B − A", "(B − A) ÷ floor")
|
| 53 |
+
METRICS = ("DWI", "MD (µm²/ms)", "FA", "µFA") # µFA on a b-tensor run (the linear vs spherical powder signal)
|
| 54 |
+
ROWS = ("every row", "LTE", "STE") # a b-tensor run's DWI rows: all, or one encoding's (the measurement slider counts within it)
|
| 55 |
+
PICK = 8 # pixels per voxel of the clickable µFA slice
|
| 56 |
_state = {"source": None, "error": None}
|
| 57 |
_lock = threading.Lock()
|
| 58 |
_RUNS = tempfile.mkdtemp(prefix="disco-runs-") # one directory per run tag, replaced on every run
|
|
|
|
| 110 |
"""The per-shell gradient table as Markdown, and whether every shell is playable on the menu's ``scanner``."""
|
| 111 |
rows = P.playable(protocol, shapes, P.machine(cfg, scanner))
|
| 112 |
lines = ["| shell | b (s/mm²) | timing | needs | limit |", "|---|---|---|---|---|"]
|
| 113 |
+
from . import classes as C
|
| 114 |
for name, b, delta, Delta, G, G_max, ok in rows:
|
| 115 |
limit = "any" if G_max is None else f"{G_max * 1e3:.0f} mT/m {'✓' if ok else '✗ cannot play'}"
|
| 116 |
+
timing = C.describe(shapes[name]) if shapes.get(name, {}).get("kind") == "ogse" else f"δ {delta * 1e3:g} / Δ {Delta * 1e3:g} ms"
|
| 117 |
+
lines.append(f"| {name} | {b:g} | {timing} | {G * 1e3:.0f} mT/m | {limit} |")
|
| 118 |
return "\n".join(lines), all(r[-1] for r in rows)
|
| 119 |
|
| 120 |
|
|
|
|
| 204 |
return out, computed, time.perf_counter() - t0
|
| 205 |
|
| 206 |
|
| 207 |
+
def _tensor_state(tm):
|
| 208 |
+
"""A b-tensor run's maps for the page: FA, MD and µFA in float32, the powder averages per (encoding, b), the b-values."""
|
| 209 |
+
return dict(fa=np.asarray(tm["fa"], np.float32), md=np.asarray(tm["md"], np.float32), ufa=np.asarray(tm["ufa"], np.float32),
|
| 210 |
+
powder={k: np.asarray(v, np.float32) for k, v in tm["powder"].items()}, bvals=list(tm["bvals"]))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
def _result_state(res, sample, load_seconds):
|
| 214 |
+
"""What the results tab needs, kept per session: float32 volumes, the peaks, the streamline sample, the numbers; on
|
| 215 |
+
a b-tensor run the maps instead of the peaks, the connectome and the streamlines."""
|
| 216 |
+
if res.protocol.tensor:
|
| 217 |
+
return dict(dwi=res.dwi.astype(np.float32), floor=res.floor.astype(np.float32), floor_median=P.floor_stats(res)["median"], meas=res.meas,
|
| 218 |
+
peaks=None, peak_amp=None, matrix=None, score=None, seconds=res.seconds, load_seconds=load_seconds, tractogram=None,
|
| 219 |
+
n_streamlines=0, shape=res.dwi.shape[:3], name=res.protocol.name, extras=dict(fractions=None),
|
| 220 |
+
tensor=_tensor_state(res.extras["tensor"]))
|
| 221 |
pk, amp = P.peaks(res.sh)
|
| 222 |
fr = res.extras.get("fractions")
|
| 223 |
return dict(dwi=res.dwi.astype(np.float32), floor=res.floor.astype(np.float32), floor_median=P.floor_stats(res)["median"], meas=res.meas,
|
|
|
|
| 242 |
layers = list(ladder) + [(name_a, ra.clean)] + ([(name_b, results["B"].clean)] if "B" in results else [])
|
| 243 |
metrics = {}
|
| 244 |
for label, vol in layers:
|
| 245 |
+
ufa = None
|
| 246 |
+
if ra.protocol.tensor: # the b-tensor maps of the noise-free layer: DTI on the linear rows, the powder fit
|
| 247 |
+
tm = T.maps(vol, ra.meas, mask); md, fa, ufa = tm["md"], tm["fa"], tm["ufa"]
|
| 248 |
+
else:
|
| 249 |
+
try:
|
| 250 |
+
md, fa = P.dti(vol, ra.meas, mask)
|
| 251 |
+
except ValueError: # too few low-b rows for a tensor: no metric maps
|
| 252 |
+
md = fa = None
|
| 253 |
+
metrics[label] = tuple(None if x is None else np.asarray(x).astype(np.float16) for x in (md, fa, ufa))
|
| 254 |
return dict(layers=[(label, vol.astype(np.float16)) for label, vol in layers], metrics=metrics, floor=ra.floor.astype(np.float32),
|
| 255 |
meas=ra.meas, mask=mask, ingredients=source.ingredient_layers(ingredients), differences=P.layer_differences(layers, ra.meas, mask),
|
| 256 |
snr=ra.snr, physics=ra.physics)
|
|
|
|
| 279 |
sample as .tck, the DWI and FOD volumes, the source's own files; ``(sample, {"tck": [...], "volumes": [...]})``."""
|
| 280 |
out = os.path.join(_RUNS, tag); shutil.rmtree(out, ignore_errors=True); os.makedirs(out)
|
| 281 |
stem = f"{source.describe(source.cfg)['files']}_{tag}_{res.protocol.name.replace(' ', '_')}"
|
| 282 |
+
if res.protocol.tensor: # a b-tensor run: the DWI with its table and encodings, and the maps
|
| 283 |
+
import re
|
| 284 |
+
stem = re.sub(r"[^A-Za-z0-9_.+-]", "_", stem)
|
| 285 |
+
return None, dict(tck=[], volumes=P.write_tensor_volumes(res, out, prefix=stem, affine=source.affine))
|
| 286 |
tck = os.path.join(out, f"{stem}.tck"); res.tractogram.to_tck(tck)
|
| 287 |
sample = P.sample_tractogram(res.tractogram, SAMPLE)
|
| 288 |
sample_path = os.path.join(out, f"{stem}_sample{SAMPLE // 1000}k.tck"); sample.to_tck(sample_path)
|
|
|
|
| 290 |
return sample, dict(tck=[tck, sample_path], volumes=[vols["dwi"], vols["bvals"], vols["bvecs"], vols["fod"]] + source.extra_files(res, out, stem))
|
| 291 |
|
| 292 |
|
| 293 |
+
def row_of(meas, rows, m):
|
| 294 |
+
"""The measurement index the slider's ``m`` names: the ``m``-th row of the encoding ``rows`` (``LTE`` / ``STE``) on
|
| 295 |
+
a b-tensor run, else row ``m``; clipped to the rows there are."""
|
| 296 |
+
enc = getattr(meas, "encoding", None)
|
| 297 |
+
if enc is None or rows not in ROWS[1:]:
|
| 298 |
+
return min(int(m), len(meas.bvals) - 1)
|
| 299 |
+
idx = np.flatnonzero(np.asarray(enc) == rows)
|
| 300 |
+
return int(idx[min(int(m), len(idx) - 1)])
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def explore(ex, layer, mode, metric, z, m, rows=ROWS[0]):
|
| 304 |
"""The explorer's map: ``layer`` of the state's layers, in ``mode`` (the signal, its difference to the previous
|
| 305 |
+
layer, B minus A, or that over the replay floor), as the DWI at measurement ``m`` (on a b-tensor run the ``m``-th
|
| 306 |
+
row of the encoding ``rows``) or a metric (MD, FA, and on a b-tensor run µFA), slice ``z``."""
|
| 307 |
if not ex:
|
| 308 |
return None
|
| 309 |
labels = [l for l, _ in ex["layers"]]; vols = dict(ex["layers"])
|
| 310 |
if layer not in vols:
|
| 311 |
layer = labels[-1]
|
| 312 |
+
z = int(z); m = row_of(ex["meas"], rows, m)
|
| 313 |
k = METRICS.index(metric) if metric in METRICS else 0
|
| 314 |
|
| 315 |
def field(label):
|
| 316 |
if k == 0:
|
| 317 |
return np.asarray(vols[label], np.float32)[..., m]
|
| 318 |
+
v = ex["metrics"][label][k - 1]
|
|
|
|
| 319 |
return None if v is None else np.asarray(v, np.float32)
|
| 320 |
+
enc = getattr(ex["meas"], "encoding", None)
|
| 321 |
+
tag = "" if enc is None else f" ({enc[m]})"
|
| 322 |
+
what = f"{metric} of {layer}" if k else f"{layer}: b = {ex['meas'].bvals[m]:g} s/mm²{tag}, measurement {m}"
|
| 323 |
x = field(layer)
|
| 324 |
if x is None:
|
| 325 |
return None
|
|
|
|
| 395 |
yield (f"**Replay DWI Explorer · the ingredient maps** … ({time.perf_counter() - t0:.0f} s so far)", 0.85)
|
| 396 |
t = time.perf_counter(); ingredients = source.ingredients(meas_a, physics_a, prepared["runs"]["A"]); seconds["explorer · ingredients"] = time.perf_counter() - t
|
| 397 |
spread = None
|
| 398 |
+
if int(n_keys) > 1 and not results["A"].protocol.tensor: # A's tracking repeated over further keys: the tractogram's own spread
|
| 399 |
keys = [int(key) + 1 + i for i in range(int(n_keys) - 1)]
|
| 400 |
mats = [results["A"].matrix]; scores = [results["A"].score]
|
| 401 |
for i, (k, M, sc, secs) in enumerate(P.repeat_tracking(results["A"], source, tracking, keys)):
|
|
|
|
| 437 |
post["page · states"] = time.perf_counter() - t; t = time.perf_counter()
|
| 438 |
labels = [l for l, _ in ex["layers"]]; name_a = labels[-2] if "B" in results else labels[-1]
|
| 439 |
ra = rs["A"]; rb = rs.get("B")
|
| 440 |
+
tensor = results["A"].protocol.tensor
|
| 441 |
+
text = (lambda tag, r: tensor_text(tag, r)) if tensor else source.score_text
|
| 442 |
+
headline = text("A", results["A"])
|
| 443 |
if rb:
|
| 444 |
+
headline += "<br>" + text("B", results["B"]) + ("" if tensor else "<br>" + source.compare_text(source.compare(results["A"], results["B"]), knob))
|
| 445 |
if spread:
|
| 446 |
headline += (f"<br>**A over {spread['n']} tracker keys: {spread['key']} {spread['pearson_mean']:.3f} ± {spread['pearson_std']:.3f}**, "
|
| 447 |
f"median pair count CV {spread['cv_median']:.2f}, {spread['pairs_always']} pairs in every run, {spread['pairs_any']} in any.")
|
|
|
|
| 455 |
rs["explorer"] = ex # the page state: A, B and the explorer's layers
|
| 456 |
box = V.grid_box(ra["shape"], source.affine) if d["views"]["truth"] else ra["shape"]
|
| 457 |
pa = source.cached(results["A"].meas, results["A"].physics)
|
| 458 |
+
connectome = not tensor
|
| 459 |
+
t_a, t_pick, t_b = tensor_views(rs, z0)
|
| 460 |
+
out = (rs, headline, V.dwi_slice(ra["dwi"], ra["meas"], z0, m0, peaks=ra["peaks"], peak_amp=ra["peak_amp"], overlay=connectome, label="A: "),
|
| 461 |
+
V.tractogram3d(ra["tractogram"], regions, box, total=ra["n_streamlines"]) if connectome else None,
|
| 462 |
+
source.matrices(results["A"].matrix, results["A"].score, results["A"].reference) if connectome else None,
|
| 463 |
timings, [t_ for f in files.values() for t_ in f["tck"]], [v for f in files.values() for v in f["volumes"]],
|
| 464 |
_slider_update(z0, ra["dwi"].shape[2] - 1), _slider_update(m0, results["A"].protocol.n_meas - 1),
|
| 465 |
+
V.tractogram3d(rb["tractogram"], regions, box, total=rb["n_streamlines"]) if rb and connectome else None,
|
| 466 |
+
source.matrices(results["B"].matrix, results["B"].score, results["B"].reference) if rb and connectome else None,
|
| 467 |
+
gr.update(visible=rb is not None and connectome),
|
| 468 |
V.floor_slice(ra["floor"], z0, ra["floor_median"], label="A: ") if d["views"]["floor"] else None, source.accuracy(results["A"]),
|
| 469 |
V.spread_matrices(spread) if spread else None,
|
| 470 |
explore(ex, name_a, "minus the previous layer" if len(labels) > 1 else "signal", METRICS[0], z0, m0),
|
| 471 |
+
explore(ex, name_a, "signal", METRICS[3] if tensor else METRICS[2], z0, m0), *ingredient_views(ex, z0), layer_table(ex),
|
| 472 |
gr.update(choices=labels, value=name_a), _slider_update(z0, ra["dwi"].shape[2] - 1), _slider_update(m0, results["A"].protocol.n_meas - 1),
|
| 473 |
source.truth_view(ra["dwi"], ra["meas"], z0, m0), source.fractions_view(ra["extras"], z0),
|
| 474 |
+
source.lobar(results["A"].matrix, results["A"].score, results["A"].reference) if connectome else None,
|
| 475 |
+
source.roundtrip_rows(results["A"]) if connectome else None,
|
| 476 |
+
source.response_view(pa, results["A"].extras) if connectome else None,
|
| 477 |
+
gr.update(visible=tensor), t_a, t_pick, None, t_b, gr.update(visible=connectome))
|
| 478 |
timings.append(["page · figures", f"{time.perf_counter() - t:.2f}"])
|
| 479 |
return out
|
| 480 |
|
| 481 |
|
| 482 |
+
def tensor_text(tag, res):
|
| 483 |
+
"""The headline of a b-tensor run: the maps' medians over the voxels the ground truth's strands cross, and over the
|
| 484 |
+
densest quarter of them (strand length in the upper quartile) by their crossing count, the run's settings."""
|
| 485 |
+
tm = res.extras["tensor"]; physics = res.physics
|
| 486 |
+
n_dirs, length = _strands()
|
| 487 |
+
sig = np.isfinite(res.dwi[..., 0]); strand = sig & (length > 0)
|
| 488 |
+
dense = strand & (length >= np.quantile(length[strand], 0.75)) if strand.any() else strand
|
| 489 |
+
med = lambda x, m: float(np.nanmedian(np.asarray(x)[m])) if np.any(m) else float("nan")
|
| 490 |
+
snr = "" if res.snr is None else f", SNR {res.snr:g}"
|
| 491 |
+
by = ", ".join(f"{k}{'+' if k == 3 else ''} directions {med(tm['ufa'], dense & ((n_dirs >= 3) if k == 3 else (n_dirs == k))):.2f} / "
|
| 492 |
+
f"{med(tm['fa'], dense & ((n_dirs >= 3) if k == 3 else (n_dirs == k))):.2f}" for k in (1, 2, 3))
|
| 493 |
+
return (f"**{tag}: in the voxels the strands cross, µFA median {med(tm['ufa'], strand):.3f}, FA {med(tm['fa'], strand):.3f}, MD "
|
| 494 |
+
f"{med(tm['md'], strand):.3f} µm²/ms**; the densest quarter by its crossing count, µFA / FA: {by} "
|
| 495 |
+
f"({res.protocol.n_meas} measurements: linear and spherical b-tensors at b {', '.join(f'{b:g}' for b in tm['bvals'])} s/mm², "
|
| 496 |
+
f"{physics.label() if physics else 'bare diffusion'}{snr}, {res.seconds['total']:.1f} s; replay floor median "
|
| 497 |
+
f"{P.floor_stats(res)['median']:.4f}). µFA is the anisotropy the spherical encoding removes and the orientation "
|
| 498 |
+
"dispersion does not: high where FA is low in crossings. Click the µFA slice for a voxel's powder curves.")
|
| 499 |
+
|
| 500 |
+
|
| 501 |
def run_pipeline(compute_fn, *args, progress=None):
|
| 502 |
"""The run button, a generator: the source's share of the runs the page has cached (:func:`prepare_runs`, a
|
| 503 |
lookup), then the stage texts of ``compute_fn`` (:func:`compute`, or it wrapped for a GPU pool) into the headline
|
|
|
|
| 538 |
(:meth:`~space.pipeline.Source.estimated_seconds`) with the source's share the call computes because the page has
|
| 539 |
it not cached (:func:`uncached_responses`). The pool refuses a request above the visitor's daily quota
|
| 540 |
(:data:`GPU_TIERS`) and kills a run that outlives its reservation, so this is the measured cost with its margin,
|
| 541 |
+
not a generous one; 480 when the inputs make no protocol (the refusal is a ``ValueError``; any other error is a
|
| 542 |
+
defect and raises)."""
|
| 543 |
try:
|
| 544 |
cfg = P.config()
|
| 545 |
S = sources.source_class(cfg)
|
| 546 |
n = len(S.panel(cfg).fields)
|
| 547 |
protocol = _protocol_from_inputs(S, cfg, preset, n_b0, *rest[n:], scheme=scheme_file, full=S.mode == "full")
|
| 548 |
+
except ValueError:
|
| 549 |
return 480
|
| 550 |
responses = uncached_responses(preset, n_b0, snr_on, snr, density, max_angle, step_mm, key, scheme_file, knob, scanner, n_keys, ladder_on, *rest)
|
| 551 |
try:
|
|
|
|
| 589 |
return gr.update(value=int(value), maximum=int(maximum))
|
| 590 |
|
| 591 |
|
| 592 |
+
def redraw_explorer(rs, layer, mode, metric, z, m, rows=ROWS[0]):
|
| 593 |
ex = rs and rs.get("explorer")
|
| 594 |
+
second = (METRICS[3] if ex and ex["meas"].encoding is not None else METRICS[2]) if metric == METRICS[0] else metric
|
| 595 |
+
return (explore(ex, layer, mode, metric, z, m, rows), explore(ex, layer, mode, second, z, m, rows), *ingredient_views(ex, z))
|
| 596 |
|
| 597 |
|
| 598 |
+
def redraw_slice(rs, z, m, overlay, which, rows=ROWS[0]):
|
| 599 |
if not rs or which not in rs:
|
| 600 |
+
return None, None, None, None, None, None, None
|
| 601 |
r = rs[which]; source = _load()["source"]
|
| 602 |
views = source.describe(source.cfg)["views"]
|
| 603 |
+
m = row_of(r["meas"], rows, m)
|
| 604 |
+
return (V.dwi_slice(r["dwi"], r["meas"], int(z), m, peaks=r["peaks"], peak_amp=r["peak_amp"], overlay=bool(overlay) and r["peaks"] is not None, label=f"{which}: "),
|
| 605 |
V.floor_slice(r["floor"], int(z), r["floor_median"], label=f"{which}: ") if views["floor"] else None,
|
| 606 |
+
source.truth_view(r["dwi"], r["meas"], int(z), m), source.fractions_view(r["extras"], int(z)), *tensor_views(rs, int(z)))
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def tensor_views(rs, z):
|
| 610 |
+
"""A b-tensor run's maps at slice ``z`` for A (with the ground truth's crossings outlined) and B, and the clickable
|
| 611 |
+
µFA slice of A; Nones on any other run."""
|
| 612 |
+
if not rs or "tensor" not in rs.get("A", {}) or rs["A"]["tensor"] is None:
|
| 613 |
+
return None, None, None
|
| 614 |
+
crossing = _crossings()
|
| 615 |
+
a = V.tensor_maps(rs["A"]["tensor"], crossing, z, label="A: ")
|
| 616 |
+
b = V.tensor_maps(rs["B"]["tensor"], crossing, z, label="B: ") if rs.get("B") and rs["B"].get("tensor") else None
|
| 617 |
+
return a, V.map_picker(rs["A"]["tensor"]["ufa"][:, :, z], PICK, vmin=0.0, vmax=1.0), b
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def pick_voxel(rs, z, evt):
|
| 621 |
+
"""The powder curve (linear and spherical, A and dashed B) of the voxel clicked on the µFA slice: ``evt.index`` is
|
| 622 |
+
the click's pixel ``(x, y)``, a voxel ``PICK`` pixels wide, the slice drawn with y up."""
|
| 623 |
+
if not rs or not rs.get("A", {}).get("tensor"):
|
| 624 |
+
return None
|
| 625 |
+
tm = rs["A"]["tensor"]; nx, ny = tm["ufa"].shape[:2]
|
| 626 |
+
x, y = (int(v) for v in evt.index)
|
| 627 |
+
i, j = min(x // PICK, nx - 1), min(ny - 1 - y // PICK, ny - 1)
|
| 628 |
+
other = rs["B"]["tensor"] if rs.get("B") and rs["B"].get("tensor") else None
|
| 629 |
+
return V.powder_curve(tm, (i, j, int(z)), other=other)
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
_CROSSINGS = {}
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
def _crossings():
|
| 636 |
+
"""The voxels the ground truth's strands cross in two or more directions (the source's strand configuration),
|
| 637 |
+
computed once per process."""
|
| 638 |
+
return _strands()[0] >= 2
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
def _strands():
|
| 642 |
+
"""``(n_dirs, length)`` per voxel of the ground truth's strands (the source's strand configuration), computed once
|
| 643 |
+
per process."""
|
| 644 |
+
if "c" not in _CROSSINGS:
|
| 645 |
+
from .sources.disco import strand_configuration
|
| 646 |
+
n_dirs, _, length = strand_configuration(shape=_load()["source"].mask.shape)
|
| 647 |
+
_CROSSINGS["c"] = (np.asarray(n_dirs), np.asarray(length))
|
| 648 |
+
return _CROSSINGS["c"]
|
| 649 |
|
| 650 |
|
| 651 |
def ground_truth_views():
|
|
|
|
| 673 |
raise ValueError(f"unknown control kind {c.kind!r}")
|
| 674 |
|
| 675 |
|
| 676 |
+
def unavailable_page(title, text):
|
| 677 |
+
"""The Blocks of a source whose inputs cannot be read: its text (:meth:`space.pipeline.Source.unavailable`) and nothing
|
| 678 |
+
to run."""
|
| 679 |
+
import gradio as gr
|
| 680 |
+
with gr.Blocks(title=title) as demo:
|
| 681 |
+
gr.Markdown(text)
|
| 682 |
+
return demo
|
| 683 |
+
|
| 684 |
+
|
| 685 |
def build(runner=None, cfg=None):
|
| 686 |
+
"""The Blocks of the configured source (``cfg``, else :func:`space.pipeline.config`), or the page that says why the
|
| 687 |
+
source cannot be served (:func:`unavailable_page`). ``runner`` wraps
|
| 688 |
:func:`compute` for the run button (the ZeroGPU entry passes ``spaces.GPU(...)``): the device part of a run; the
|
| 689 |
page looks up its cache before it and draws from its payload after it, in this process."""
|
| 690 |
import gradio as gr
|
| 691 |
cfg = cfg or P.config()
|
| 692 |
S = sources.source_class(cfg)
|
| 693 |
+
refusal = S.unavailable(cfg)
|
| 694 |
+
if refusal is not None:
|
| 695 |
+
return unavailable_page(*refusal)
|
| 696 |
d = S.describe(cfg)
|
| 697 |
full = S.mode == "full"
|
| 698 |
shapes = S.shapes_of(cfg); presets = S.presets(cfg) + [CUSTOM] + ([UPLOADED] if full else [])
|
|
|
|
| 747 |
physics_inputs = [widgets[name] for name in panel.fields]
|
| 748 |
with gr.Row():
|
| 749 |
snr_on = gr.Checkbox(value=True, label="add Rician noise")
|
| 750 |
+
snr = gr.Slider(5, 100, value=P.preset_snr(cfg, presets[0]), step=1, label="SNR at M0 (a full water voxel before relaxation; each voxel's b = 0 SNR follows its tissue)")
|
| 751 |
+
if any("tensor" in p for p in (cfg.get("presets") or {}).values()):
|
| 752 |
+
gr.Markdown(SNR_NOTE)
|
| 753 |
density = gr.Slider(tc["density"][0], tc["density"][1], value=tc["density"][2], step=tc["density"][3], label=tc["density"][4])
|
| 754 |
max_angle = gr.Slider(tc["max_angle"][0], tc["max_angle"][1], value=tc["max_angle"][2], step=tc["max_angle"][3], label=tc["max_angle"][4])
|
| 755 |
step_mm = gr.Slider(tc["step"][0], tc["step"][1], value=tc["step"][2], step=tc["step"][3], label=tc["step"][4])
|
|
|
|
| 761 |
scanner_terms = gr.Markdown(P.scanner_text(cfg, IDEAL, S.refused_machines(cfg)))
|
| 762 |
gradients = gr.Markdown()
|
| 763 |
n_keys = gr.Slider(1, 8, value=1, step=1, label="repeat A's tracking over N keys (the tractogram's own spread)")
|
| 764 |
+
ladder_on = gr.Checkbox(value=False, visible=not full, label=d["ladder"])
|
| 765 |
go = gr.Button(d["run_label"], variant="primary")
|
| 766 |
gpu_text = gr.Markdown(visible=runner is not None)
|
| 767 |
headline = gr.Markdown()
|
|
|
|
| 782 |
with gr.Row():
|
| 783 |
ez_slider = gr.Slider(0, 39, value=20, step=1, label="axial slice z")
|
| 784 |
em_slider = gr.Slider(0, 363, value=0, step=1, label="measurement (DWI only)")
|
| 785 |
+
erows = gr.Radio(list(ROWS), value=ROWS[0], label="rows (a b-tensor run: the measurement counts within the encoding)")
|
| 786 |
with gr.Row():
|
| 787 |
explore_view = gr.Image(label="the chosen layer and mode", type="pil")
|
| 788 |
metric_view = gr.Image(label="the same for FA (or the chosen metric)", type="pil")
|
|
|
|
| 797 |
m_slider = gr.Slider(0, 363, value=0, step=1, label="measurement")
|
| 798 |
overlay = gr.Checkbox(value=True, label="FOD principal directions")
|
| 799 |
which = gr.Radio(["A", "B"], value="A", label="run")
|
| 800 |
+
rows_radio = gr.Radio(list(ROWS), value=ROWS[0], label="rows (b-tensor runs)")
|
| 801 |
with gr.Column(scale=1, visible=d["views"]["truth"]):
|
| 802 |
truth_view = gr.Image(label="the same slice with the input FOD's principal directions", type="pil")
|
| 803 |
with gr.Column(scale=1):
|
| 804 |
tract_view = gr.Plot(label="tractogram A")
|
| 805 |
fractions_view = gr.Image(label="recovered vs input fractions", type="pil", visible=d["views"]["fractions"])
|
| 806 |
+
with gr.Column(visible=False) as tensor_row: # a b-tensor run: the maps and a voxel's powder curves
|
| 807 |
+
tensor_view = gr.Image(label="b-tensor maps of A: FA, µFA, MD (the slice above)", type="pil")
|
| 808 |
+
with gr.Row():
|
| 809 |
+
tensor_pick = gr.Image(label="µFA of A: click a voxel", type="pil", interactive=False)
|
| 810 |
+
powder_view = gr.Image(label="the clicked voxel's powder signal: linear and spherical, A and B (dashed)", type="pil")
|
| 811 |
+
tensor_view_b = gr.Image(label="b-tensor maps of B", type="pil")
|
| 812 |
+
with gr.Column() as connectome_row:
|
| 813 |
+
mats = gr.Image(label="connectome A vs its truth", type="pil")
|
| 814 |
+
lobar_view = gr.Image(label="connectome A vs its truth over the lobar groups", type="pil", visible=d["views"]["lobar"])
|
| 815 |
+
with gr.Row(visible=False) as b_row:
|
| 816 |
+
tract_view_b = gr.Plot(label="tractogram B")
|
| 817 |
+
mats_b = gr.Image(label="connectome B vs its truth", type="pil")
|
| 818 |
+
spread_view = gr.Image(label="A over N tracker keys: mean and spread per pair", type="pil")
|
| 819 |
roundtrip = gr.Dataframe(headers=["what", "value"], label="the round trip: the reconstruction and the connectome against the input",
|
| 820 |
interactive=False, wrap=True, visible=d["views"]["roundtrip"])
|
| 821 |
with gr.Accordion("accuracy: what this replay is an approximation of", open=False):
|
|
|
|
| 833 |
def run_with_progress(*args, progress=gr.Progress()):
|
| 834 |
yield from run_pipeline(compute_fn, *args, progress=progress)
|
| 835 |
tissue_numbers = [widgets[name] for name in panel.catalogue] + [catalogue_note]
|
| 836 |
+
preset.change(lambda name: P.preset_snr(cfg, name), inputs=preset, outputs=snr, show_progress="hidden")
|
| 837 |
field_preset.change(lambda name: [field_presets[name]] + S.catalogue_numbers(cfg, field_presets[name]), inputs=field_preset,
|
| 838 |
outputs=[widgets["field_T"]] + tissue_numbers, show_progress="hidden")
|
| 839 |
reset.click(lambda f: S.catalogue_numbers(cfg, f), inputs=widgets["field_T"], outputs=tissue_numbers, show_progress="hidden")
|
|
|
|
| 860 |
floor_view=floor_view, accuracy=accuracy, spread_view=spread_view, explore_view=explore_view, metric_view=metric_view,
|
| 861 |
ingredient_pool=ingredient_pool, ingredient_contact=ingredient_contact, ingredient_field=ingredient_field,
|
| 862 |
layer_table=layer_tbl, layer_choice=layer_choice, ez_slider=ez_slider, em_slider=em_slider, truth_view=truth_view,
|
| 863 |
+
fractions_view=fractions_view, lobar_view=lobar_view, roundtrip=roundtrip, response_view=response_view,
|
| 864 |
+
tensor_row=tensor_row, tensor_view=tensor_view, tensor_pick=tensor_pick, powder_view=powder_view,
|
| 865 |
+
tensor_view_b=tensor_view_b, connectome_row=connectome_row)
|
| 866 |
+
for ctl in (layer_choice, mode, metric, ez_slider, em_slider, erows):
|
| 867 |
+
ctl.change(redraw_explorer, inputs=[result, layer_choice, mode, metric, ez_slider, em_slider, erows],
|
| 868 |
outputs=[explore_view, metric_view, ingredient_pool, ingredient_contact, ingredient_field], show_progress="hidden")
|
| 869 |
run_inputs = [preset, n_b0, snr_on, snr, density, max_angle, step_mm, key, scheme_file, knob, scanner, n_keys, ladder_on, *physics_inputs, *shell_inputs]
|
| 870 |
if runner is not None: # the pool: what the run reserves, live as the inputs change
|
|
|
|
| 873 |
outputs=[outputs[name] for name in OUTPUTS], concurrency_limit=1, api_name="run_pipeline") # the endpoint tools/live.py drives
|
| 874 |
if runner is not None: # the run's responses are now cached on the page: the reservation falls
|
| 875 |
run_event.then(gpu_seconds_text, inputs=run_inputs, outputs=gpu_text, show_progress="hidden")
|
| 876 |
+
for ctl in (z_slider, m_slider, overlay, which, rows_radio):
|
| 877 |
+
ctl.change(redraw_slice, inputs=[result, z_slider, m_slider, overlay, which, rows_radio],
|
| 878 |
+
outputs=[dwi_view, floor_view, truth_view, fractions_view, tensor_view, tensor_pick, tensor_view_b], show_progress="hidden")
|
| 879 |
+
|
| 880 |
+
def on_pick(rs, z, evt: gr.SelectData):
|
| 881 |
+
return pick_voxel(rs, z, evt)
|
| 882 |
+
tensor_pick.select(on_pick, inputs=[result, z_slider], outputs=powder_view, show_progress="hidden")
|
| 883 |
gt_tab.select(ground_truth_views, outputs=[strands_view, gt_matrix])
|
| 884 |
demo.load(lambda: (_load().get("error") and f"**the source did not load:** {_load()['error']}") or "", outputs=headline)
|
| 885 |
return demo
|
space/brain.toml
CHANGED
|
@@ -89,7 +89,7 @@ m0_gm_knob = [0.7, 1.0]
|
|
| 89 |
|
| 90 |
# The scanner menu after "ideal": catalogue machines whose every catalogued term the brain applies, the head centre at
|
| 91 |
# isocentre (docs/scanner.md). Each voxel is binned into an encoding class by what the machine delivers there, to
|
| 92 |
-
# `tolerance` of b (
|
| 93 |
# packs. `refused` are the machines kept off the menu, each shown under it with the reason (Brain.refused_machines):
|
| 94 |
# the Swoop, whose own gradient makes the head thousands of encoding classes (docs/scanner.md).
|
| 95 |
[scanners]
|
|
@@ -103,7 +103,7 @@ refused = [
|
|
| 103 |
tolerance = 0.01
|
| 104 |
|
| 105 |
# The pack menu per tissue (the first is the default): a label and a pack URI (hf:// or a local path, as
|
| 106 |
-
#
|
| 107 |
# (Brain.pack); a local path or a one-window pack loads whole. SubstrateCommons family records feed this later (disco-space#8, decision 5).
|
| 108 |
[packs]
|
| 109 |
wm = [
|
|
@@ -159,7 +159,7 @@ margin = 1.3
|
|
| 159 |
# of 100 / 204 ms (ReplayPack.prefix per shard: 1,303 / 2,656 saves), three runs, the third adding the 85 ms prefix
|
| 160 |
# (1,107 saves) and playing the d17-D30 class instead of the scan's. With the field
|
| 161 |
# tier off the slope is 0.0008-0.0016 s per save; the field states' slopes (-0.0015 to +0.0062) are within the
|
| 162 |
-
#
|
| 163 |
# slope. fixed and per_meas were fit on the 1,002-save pack, so its saves are priced twice: 1.6 s of margin.
|
| 164 |
response_cold = { fixed = 22.0, per_meas = 0.07, per_save = 0.0016 }
|
| 165 |
response_warm = { fixed = 2.5, per_meas = 0.04, per_save = 0.0016 }
|
|
@@ -173,3 +173,7 @@ response_per_class = 5.6
|
|
| 173 |
1 = 1.5 # 36,605 seeds: 0.7 s
|
| 174 |
2 = 3.5 # 292,840 seeds: 1.9-2.1 s
|
| 175 |
4 = 35.0 # 2,342,720 seeds: 13.6-23.2 s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
# The scanner menu after "ideal": catalogue machines whose every catalogued term the brain applies, the head centre at
|
| 91 |
# isocentre (docs/scanner.md). Each voxel is binned into an encoding class by what the machine delivers there, to
|
| 92 |
+
# `tolerance` of b (dmipy_replay.phantom.bore.encoding_classes), and every class's played acquisition is expanded by the
|
| 93 |
# packs. `refused` are the machines kept off the menu, each shown under it with the reason (Brain.refused_machines):
|
| 94 |
# the Swoop, whose own gradient makes the head thousands of encoding classes (docs/scanner.md).
|
| 95 |
[scanners]
|
|
|
|
| 103 |
tolerance = 0.01
|
| 104 |
|
| 105 |
# The pack menu per tissue (the first is the default): a label and a pack URI (hf:// or a local path, as
|
| 106 |
+
# dmipy_replay.phantom.PackSubstrate resolves it). A Hub pack declaring several windows loads the windows a run reaches
|
| 107 |
# (Brain.pack); a local path or a one-window pack loads whole. SubstrateCommons family records feed this later (disco-space#8, decision 5).
|
| 108 |
[packs]
|
| 109 |
wm = [
|
|
|
|
| 159 |
# of 100 / 204 ms (ReplayPack.prefix per shard: 1,303 / 2,656 saves), three runs, the third adding the 85 ms prefix
|
| 160 |
# (1,107 saves) and playing the d17-D30 class instead of the scan's. With the field
|
| 161 |
# tier off the slope is 0.0008-0.0016 s per save; the field states' slopes (-0.0015 to +0.0062) are within the
|
| 162 |
+
# the host's noise, and their save-dependent work is the same expansion, so the three take the largest no_field
|
| 163 |
# slope. fixed and per_meas were fit on the 1,002-save pack, so its saves are priced twice: 1.6 s of margin.
|
| 164 |
response_cold = { fixed = 22.0, per_meas = 0.07, per_save = 0.0016 }
|
| 165 |
response_warm = { fixed = 2.5, per_meas = 0.04, per_save = 0.0016 }
|
|
|
|
| 173 |
1 = 1.5 # 36,605 seeds: 0.7 s
|
| 174 |
2 = 3.5 # 292,840 seeds: 1.9-2.1 s
|
| 175 |
4 = 35.0 # 2,342,720 seeds: 13.6-23.2 s
|
| 176 |
+
|
| 177 |
+
# The noise a run starts with: the SNR at M0 every preset starts at.
|
| 178 |
+
[noise]
|
| 179 |
+
snr = 30
|
space/classes.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The timing classes as waveforms: each stored class of the time-segment layout is ONE waveform shape (a square-pulse
|
| 2 |
+
PGSE, a stimulated echo, a cosine OGSE with its edges ramped), and a shell on it plays that shape at the amplitude
|
| 3 |
+
its b-value needs, along each of its directions. The shape is built once from the configuration's ``[shapes]`` entry
|
| 4 |
+
by dmipy-replay's sequence builders (:func:`template`), on the layout's grid (TE 53.5 ms, 1000 samples), and checked
|
| 5 |
+
against the layout's stored partition when the layout loads; :func:`play` scales it to the rows. A class has a fixed
|
| 6 |
+
shape: an OGSE built at b = 1000 s/mm^2 with 200 T/m/s edges plays the same edge durations at every other b (the
|
| 7 |
+
layout stores that shape, nothing else)."""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import functools
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
KINDS = ("pgse", "pgste", "ogse", "tensor")
|
| 15 |
+
N_T = 1000 # the layout's grid: 1000 samples over the echo time
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def kind(sh):
|
| 19 |
+
"""The class's pulse kind: ``pgse`` (the default), ``pgste`` or ``ogse``."""
|
| 20 |
+
k = sh.get("kind", "pgse")
|
| 21 |
+
if k not in KINDS:
|
| 22 |
+
raise ValueError(f"a timing class's kind is one of {KINDS}, got {k!r}")
|
| 23 |
+
return k
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _key(sh):
|
| 27 |
+
return tuple(sorted((k, v) for k, v in sh.items() if k != "label"))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@functools.lru_cache(maxsize=None)
|
| 31 |
+
def _template(key):
|
| 32 |
+
from dmipy_replay.sequences import ogse, pgse, pgste
|
| 33 |
+
sh = dict(key)
|
| 34 |
+
k = kind(sh)
|
| 35 |
+
if k == "pgse":
|
| 36 |
+
return pgse([[0.0, 0.0, 1.0]], float(sh["delta"]), float(sh["Delta"]), gradient_strengths=0.1, TE=float(sh["TE"]),
|
| 37 |
+
n_t=N_T, slew_rate=np.inf)
|
| 38 |
+
if k == "pgste": # TM = Delta - delta between the 90s; TE = 2 delta + TM
|
| 39 |
+
return pgste([[0.0, 0.0, 1.0]], float(sh["delta"]), float(sh["Delta"]) - float(sh["delta"]), gradient_strengths=0.1,
|
| 40 |
+
n_t=N_T, slew_rate=np.inf, ste_flip_angles=(90.0, 90.0, 90.0))
|
| 41 |
+
return ogse([[0.0, 0.0, 1.0]], float(sh["frequency"]), float(sh["duration"]), shape="cosine",
|
| 42 |
+
bvalues=[float(sh["b_built"]) * 1e6], TE=float(sh["TE"]), n_t=N_T, slew_rate=float(sh["slew_rate"]))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def template(sh):
|
| 46 |
+
"""The class's waveform along z, one row (a ScannerSequence); a ``tensor`` class is its three shapes, one row each
|
| 47 |
+
along its own axis (:func:`space.tensor.template`): ``pgse`` square lobes ``delta`` wide ``Delta`` apart
|
| 48 |
+
at ``TE``; ``pgste`` square lobes storing for ``TM = Delta - delta``; ``ogse`` a cosine of ``frequency`` (Hz) over
|
| 49 |
+
``duration`` (s) on each side of the 180, built at ``b_built`` (s/mm^2) with ``slew_rate`` (T/m/s) edges."""
|
| 50 |
+
if kind(sh) == "tensor":
|
| 51 |
+
from . import tensor as T
|
| 52 |
+
return T.template(sh)
|
| 53 |
+
return _template(_key(sh))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def tensor_rows(sh, encodings, bvals, dirs):
|
| 57 |
+
"""The rows of a ``tensor`` class: each row's encoding (``b0``, ``LTE``, ``STE``) at its b (s/mm^2) about its axis,
|
| 58 |
+
the physical gradient ``(n, 1000, 3)`` (T/m) -- the class's waveform times the row's level matrix
|
| 59 |
+
(:func:`space.tensor.levels`); a b = 0 row plays nothing."""
|
| 60 |
+
from . import tensor as T
|
| 61 |
+
W = T.waveform(sh)
|
| 62 |
+
G = np.zeros((len(encodings), N_T, 3))
|
| 63 |
+
for i, (e, b, d) in enumerate(zip(encodings, bvals, dirs)):
|
| 64 |
+
if e != "b0" and float(b) > 0:
|
| 65 |
+
G[i] = W @ T.levels(sh, str(e), float(b) * 1e6, d)
|
| 66 |
+
return G
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def tensor_peak(sh, encoding, b, n_dirs):
|
| 70 |
+
"""The largest per-axis gradient (T/m) of a tensor shell's rows (``n_dirs`` axes of :func:`space.tensor.fibonacci`)."""
|
| 71 |
+
from . import tensor as T
|
| 72 |
+
return float(np.abs(tensor_rows(sh, [encoding] * int(n_dirs), [b] * int(n_dirs), T.fibonacci(int(n_dirs)))).max())
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def unit_b(sh):
|
| 76 |
+
"""``(profile (n_t,), b per (T/m)^2)``: the class's physical gradient along its axis normalised to a peak of 1, and
|
| 77 |
+
the b-value (s/m^2) of that shape at a peak amplitude of 1 T/m."""
|
| 78 |
+
t = template(sh)
|
| 79 |
+
G = np.asarray(t.G, np.float64)[0, :, 2]
|
| 80 |
+
peak = float(np.abs(G).max())
|
| 81 |
+
return G / peak, float(t.b()[0]) / peak ** 2
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def amplitude(sh, b):
|
| 85 |
+
"""The peak gradient (T/m) the class plays to reach ``b`` (s/mm^2)."""
|
| 86 |
+
return float(np.sqrt(max(float(b), 0.0) * 1e6 / unit_b(sh)[1]))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def play(sh, bvals_SI, dirs):
|
| 90 |
+
"""The ScannerSequence of rows on the class: its shape at each row's amplitude along each row's unit direction
|
| 91 |
+
(``bvals_SI`` in s/m^2, ``dirs`` ``(n, 3)``); a b = 0 row plays nothing."""
|
| 92 |
+
from dmipy_replay.acquisition.scanner_sequence import ScannerSequence
|
| 93 |
+
t = template(sh)
|
| 94 |
+
prof, unit = unit_b(sh)
|
| 95 |
+
b = np.asarray(bvals_SI, np.float64).ravel()
|
| 96 |
+
u = np.asarray(dirs, np.float64).reshape(-1, 3)
|
| 97 |
+
n = np.linalg.norm(u, axis=1)
|
| 98 |
+
u = np.where(n[:, None] > 0, u / np.where(n > 0, n, 1.0)[:, None], 0.0)
|
| 99 |
+
amp = np.sqrt(np.maximum(b, 0.0) / unit)
|
| 100 |
+
G = prof[None, :, None] * (amp[:, None] * u)[:, None, :]
|
| 101 |
+
return ScannerSequence(G=G.astype(np.float32), dt=t.dt, rf=t.rf, readout=t.readout)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def describe(sh):
|
| 105 |
+
"""The class's timing in a few words, for the gradient table."""
|
| 106 |
+
k = kind(sh)
|
| 107 |
+
if k == "tensor":
|
| 108 |
+
return "b-tensor class (three shapes: linear, planar, spherical at any rotation)"
|
| 109 |
+
if k == "ogse":
|
| 110 |
+
return f"cosine {float(sh['frequency']):g} Hz, {float(sh['duration']) * 1e3:g} ms per side"
|
| 111 |
+
if k == "pgste":
|
| 112 |
+
return f"STE δ {float(sh['delta']) * 1e3:g} / Δ {float(sh['Delta']) * 1e3:g} ms"
|
| 113 |
+
return f"δ {float(sh['delta']) * 1e3:g} / Δ {float(sh['Delta']) * 1e3:g} ms"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def n_terms(sh):
|
| 117 |
+
"""The class's stored terms: a PGSE, stimulated-echo or OGSE class two (one per encoding window), the b-tensor class
|
| 118 |
+
six (its three gradient shapes in both windows)."""
|
| 119 |
+
return 6 if kind(sh) == "tensor" else 2
|
space/config.toml
CHANGED
|
@@ -3,14 +3,20 @@
|
|
| 3 |
source = "disco" # space/sources/disco.py: the DiSCo phantom (demo: its layout, full: its columnar pack)
|
| 4 |
|
| 5 |
[data]
|
| 6 |
-
repo = "SubstrateCommons/disco-
|
| 7 |
-
|
| 8 |
-
revision = "
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
# The timing classes ("shapes")
|
| 12 |
-
#
|
| 13 |
-
#
|
|
|
|
|
|
|
|
|
|
| 14 |
[shapes."d10.2-D16.7"]
|
| 15 |
label = "DiSCo δ 10.2 / Δ 16.7 ms (shells b = 0, 1000, 1925)"
|
| 16 |
delta = 0.0102
|
|
@@ -49,6 +55,31 @@ delta = 0.0076
|
|
| 49 |
Delta = 0.0459
|
| 50 |
TE = 0.0535
|
| 51 |
kind = "pgste"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
# DiSCo's own protocol: which timing class each shell of the dataset's gradient table plays (b in s/mm^2, the
|
| 54 |
# table's rows are assigned to the nearest b here).
|
|
@@ -61,20 +92,35 @@ shells = [
|
|
| 61 |
{ b = 13183, shape = "d17.7-D35.8" },
|
| 62 |
]
|
| 63 |
|
| 64 |
-
# Presets the app offers besides "DiSCo 364" (which comes from the dataset's table)
|
|
|
|
|
|
|
| 65 |
[presets."clinical b1000 x 30"]
|
| 66 |
-
|
| 67 |
-
|
| 68 |
[presets."research 3-shell x 90"]
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
{ shape = "d17-D30", b = 1000, n_dirs = 90 },
|
| 72 |
-
{ shape = "d17-D30", b = 2000, n_dirs = 90 },
|
| 73 |
-
{ shape = "d17-D30", b = 3000, n_dirs = 90 },
|
| 74 |
-
]
|
| 75 |
[presets."Connectome 2.0 b6000 x 60"]
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
# The tissue and the scanner: the field presets the page offers (T), the default field,
|
| 80 |
# and whether a run starts with the tiers on. Tissue defaults come from dmipy-sim's white-matter catalogue at the
|
|
@@ -85,10 +131,10 @@ default_field = 3.0
|
|
| 85 |
default_on = true
|
| 86 |
pools = ["intra", "extra"] # the pack's seeded pools (its spec also names a myelin pool nobody was seeded in)
|
| 87 |
|
| 88 |
-
# The scanner menu after "ideal": catalogue machines of dmipy-
|
| 89 |
# its entry carries (docs/scanner.md); the phantom sits at a distance from isocentre along a scanner axis (the panel's
|
| 90 |
# placement, at most 7.9 cm: the whole 1 mm cube inside the Swoop's 8 cm anchor). The Swoop's own gradient needs the
|
| 91 |
-
# layout's background
|
| 92 |
[scanners]
|
| 93 |
machines = [
|
| 94 |
{ label = "Hyperfine Swoop 64 mT (permanent magnet, field across the patient)", key = "hyperfine_swoop_64mT" },
|
|
@@ -99,8 +145,8 @@ default_offset_cm = 7.9
|
|
| 99 |
max_offset_cm = 7.9
|
| 100 |
delivery_per_meas = 0.0036 # s per measurement per replay: bore.delivered_moments over DiSCo's 31,802 live voxels, 1.3 s at 364 (a CPU host)
|
| 101 |
|
| 102 |
-
# The mode: demo = the
|
| 103 |
-
#
|
| 104 |
# DISCO_COLUMNS (a directory with the columns, or the Hub at 45 MB/s). DISCO_MODE in the environment overrides.
|
| 105 |
[mode]
|
| 106 |
mode = "demo"
|
|
@@ -108,11 +154,12 @@ columns = "hf://SubstrateCommons/disco-replay/disco"
|
|
| 108 |
workers = 16
|
| 109 |
|
| 110 |
# The compute backend: jax (the reference; the Docker Space) or torch (Hugging Face's shared GPU pool, disco-space#2);
|
| 111 |
-
# DISCO_BACKEND in the environment overrides. resident = false
|
| 112 |
-
# (a pool that drops the device between calls
|
|
|
|
| 113 |
[compute]
|
| 114 |
backend = "jax"
|
| 115 |
-
resident =
|
| 116 |
|
| 117 |
[tracking]
|
| 118 |
density = 4 # seeds per region voxel = density^3 (4 -> 659,840 seeds, the DiSCo score's setting)
|
|
@@ -124,7 +171,8 @@ max_steps = 500
|
|
| 124 |
[gate]
|
| 125 |
pearson_count_min = 0.90
|
| 126 |
missed_pairs_max = 0
|
| 127 |
-
reference_max_abs_diff = 1e-3 # the replay of
|
|
|
|
| 128 |
|
| 129 |
# The reconstruction: DiSCo is single-fibre white matter, so its CSD is the single-tissue Tournier 2007 one on the
|
| 130 |
# response white_matter_response_tournier07 estimates from the volume. DISCO_RECONSTRUCTION in the environment overrides.
|
|
|
|
| 3 |
source = "disco" # space/sources/disco.py: the DiSCo phantom (demo: its layout, full: its columnar pack)
|
| 4 |
|
| 5 |
[data]
|
| 6 |
+
repo = "SubstrateCommons/disco-time-segments" # the public time-segment layout of the DiSCo v2 walk
|
| 7 |
+
layout = "disco" # the layout's prefix in the dataset (dmipy_replay.replay.time_segments, format time_segments/2)
|
| 8 |
+
revision = "a38679f061f1487b6688ebb37b7ee949da000ea3" # the dataset revision the layout is read at (the OGSE class cos-f50-T20 and the b-tensor class qte-now-80)
|
| 9 |
+
manifest_sha256 = "0fe044fd6963de74c6174276e35dd2ddc219d9d4ef2a476df0150ada48e23ef9" # the layout manifest the page was accepted on (checked at load)
|
| 10 |
+
pack = "hf://SubstrateCommons/disco-replay/disco" # the replay pack the layout was contracted from: the acceptance test's reference
|
| 11 |
+
pack_revision = "b06eb4179a99ef885af73de1ddfbc2f3c230de37"
|
| 12 |
+
strands = "substrate/DiSCo_Strands_Trajectories.tck" # the ground truth's strand centrelines (CC BY 4.0) in the replay dataset, read at pack_revision
|
| 13 |
|
| 14 |
+
# The timing classes ("shapes"), each ONE waveform the layout stores (space/classes.py builds it with dmipy-replay's
|
| 15 |
+
# sequence builders on the layout's grid, TE = 53.5 ms, 1000 samples; checked against the layout's partition at load): square
|
| 16 |
+
# pulses (slew_rate = inf), PGSE unless kind = "pgste" (a stimulated echo storing for TM = Delta - delta) or "ogse"
|
| 17 |
+
# (a cosine of `frequency` Hz over `duration` s on each side of the 180, its edges ramped at `slew_rate` T/m/s as built
|
| 18 |
+
# at `b_built` s/mm^2: the class stores that one shape at every b). delta / Delta / TE in seconds (an OGSE's delta /
|
| 19 |
+
# Delta are the scheme's nominal block and block separation); label is what the app shows.
|
| 20 |
[shapes."d10.2-D16.7"]
|
| 21 |
label = "DiSCo δ 10.2 / Δ 16.7 ms (shells b = 0, 1000, 1925)"
|
| 22 |
delta = 0.0102
|
|
|
|
| 55 |
Delta = 0.0459
|
| 56 |
TE = 0.0535
|
| 57 |
kind = "pgste"
|
| 58 |
+
# The paper's cosine OGSE (experiments/disco/disco_acq.py "ogse"): 50 Hz, one 20 ms period each side of the 180.
|
| 59 |
+
[shapes."cos-f50-T20"]
|
| 60 |
+
label = "cosine OGSE 50 Hz, one 20 ms period each side of the 180 (edges 200 T/m/s, as built at b = 1000)"
|
| 61 |
+
kind = "ogse"
|
| 62 |
+
frequency = 50.0
|
| 63 |
+
duration = 0.020
|
| 64 |
+
slew_rate = 200.0
|
| 65 |
+
b_built = 1000.0
|
| 66 |
+
delta = 0.019975
|
| 67 |
+
Delta = 0.020029
|
| 68 |
+
TE = 0.0535
|
| 69 |
+
|
| 70 |
+
# The b-tensor class: three gradient shapes on the spin echo, each in both encoding windows (six stored terms), so one
|
| 71 |
+
# class plays every linear, planar and spherical encoding of them at any rotation and b (space/tensor.py). The shapes
|
| 72 |
+
# are the three axes of NOW's maximum-b spherical encoding (Sjolund et al. 2015) at TE 53.5 ms designed at 80 mT/m and
|
| 73 |
+
# 200 T/m/s per axis (a 4 ms window for the 180), the physical gradient in `waveform` (data/); rotated, a linear or
|
| 74 |
+
# spherical row at b 2000 needs about 124 mT/m on one axis: Connectome-class hardware. delta / Delta are the scheme's
|
| 75 |
+
# nominal encoding windows (the CSD and the scheme never read them: a b-tensor run fits maps, not FODs).
|
| 76 |
+
[shapes."qte-now-80"]
|
| 77 |
+
label = "b-tensor class qte-now-80: NOW spherical encoding at TE 53.5 ms, 80 mT/m and 200 T/m/s per axis (Connectome-class when rotated)"
|
| 78 |
+
kind = "tensor"
|
| 79 |
+
waveform = "classes/qte-now-80.npy"
|
| 80 |
+
delta = 0.0229
|
| 81 |
+
Delta = 0.0253
|
| 82 |
+
TE = 0.0535
|
| 83 |
|
| 84 |
# DiSCo's own protocol: which timing class each shell of the dataset's gradient table plays (b in s/mm^2, the
|
| 85 |
# table's rows are assigned to the nearest b here).
|
|
|
|
| 92 |
{ b = 13183, shape = "d17.7-D35.8" },
|
| 93 |
]
|
| 94 |
|
| 95 |
+
# Presets the app offers besides "DiSCo 364" (which comes from the dataset's table): the paper's flagship
|
| 96 |
+
# acquisitions (experiments/disco/disco_acq.py), each its gradient table in data/protocols/ (the same files the paper
|
| 97 |
+
# replays: b = 0 rows first, then every shell's directions spread by electrostatic repulsion) on one stored class.
|
| 98 |
[presets."clinical b1000 x 30"]
|
| 99 |
+
table = "protocols/clinical"
|
| 100 |
+
shape = "d12-D24"
|
| 101 |
[presets."research 3-shell x 90"]
|
| 102 |
+
table = "protocols/research"
|
| 103 |
+
shape = "d17-D30"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
[presets."Connectome 2.0 b6000 x 60"]
|
| 105 |
+
table = "protocols/c2"
|
| 106 |
+
shape = "d8-D20"
|
| 107 |
+
[presets."cosine OGSE 50 Hz b1000 x 60"]
|
| 108 |
+
table = "protocols/ogse"
|
| 109 |
+
shape = "cos-f50-T20"
|
| 110 |
+
# Tensor-valued encoding: linear and spherical b-tensors interleaved at four b-values on the b-tensor class, the
|
| 111 |
+
# b-values and direction counts of Szczepankiewicz et al. 2019's clinical protocol (6, 6, 10, 16 per encoding); the
|
| 112 |
+
# same axes for both encodings at each b; one b = 0 row. Its run fits FA (DTI on the linear rows), MD and the
|
| 113 |
+
# microscopic FA (the powder averages), not a connectome.
|
| 114 |
+
[presets."b-tensor: linear + spherical (Szczepankiewicz 2019), b 100-2000 s/mm²"]
|
| 115 |
+
shape = "qte-now-80"
|
| 116 |
+
n_b0 = 1
|
| 117 |
+
snr = 100 # its own starting SNR: µFA is noise-limited (the other presets start at [noise] snr)
|
| 118 |
+
tensor = { bvalues = [100, 700, 1400, 2000], directions = [6, 6, 10, 16], encodings = ["LTE", "STE"] }
|
| 119 |
+
|
| 120 |
+
# The noise a run starts with: the SNR at M0 (a full water voxel before relaxation) every preset starts at unless it
|
| 121 |
+
# states its own.
|
| 122 |
+
[noise]
|
| 123 |
+
snr = 30
|
| 124 |
|
| 125 |
# The tissue and the scanner: the field presets the page offers (T), the default field,
|
| 126 |
# and whether a run starts with the tiers on. Tissue defaults come from dmipy-sim's white-matter catalogue at the
|
|
|
|
| 131 |
default_on = true
|
| 132 |
pools = ["intra", "extra"] # the pack's seeded pools (its spec also names a myelin pool nobody was seeded in)
|
| 133 |
|
| 134 |
+
# The scanner menu after "ideal": catalogue machines of dmipy-replay (ScannerLimits.of(key)), each applied with every term
|
| 135 |
# its entry carries (docs/scanner.md); the phantom sits at a distance from isocentre along a scanner axis (the panel's
|
| 136 |
# placement, at most 7.9 cm: the whole 1 mm cube inside the Swoop's 8 cm anchor). The Swoop's own gradient needs the
|
| 137 |
+
# layout's background vectors (segment_rows(background=True)); a layout without them refuses it by name.
|
| 138 |
[scanners]
|
| 139 |
machines = [
|
| 140 |
{ label = "Hyperfine Swoop 64 mT (permanent magnet, field across the patient)", key = "hyperfine_swoop_64mT" },
|
|
|
|
| 145 |
max_offset_cm = 7.9
|
| 146 |
delivery_per_meas = 0.0036 # s per measurement per replay: bore.delivered_moments over DiSCo's 31,802 live voxels, 1.3 s at 364 (a CPU host)
|
| 147 |
|
| 148 |
+
# The mode: demo = the time-segment layout (the hosted Spaces; seconds; the stored waveforms), full = the columnar
|
| 149 |
+
# replay pack itself (minutes per volume; any timing; the scheme upload), read from
|
| 150 |
# DISCO_COLUMNS (a directory with the columns, or the Hub at 45 MB/s). DISCO_MODE in the environment overrides.
|
| 151 |
[mode]
|
| 152 |
mode = "demo"
|
|
|
|
| 154 |
workers = 16
|
| 155 |
|
| 156 |
# The compute backend: jax (the reference; the Docker Space) or torch (Hugging Face's shared GPU pool, disco-space#2);
|
| 157 |
+
# DISCO_BACKEND in the environment overrides. resident = false streams the layout's packed words to the device a
|
| 158 |
+
# chunk at a time on every image (nothing kept: a pool that drops the device between calls, a GPU smaller than one
|
| 159 |
+
# class's columns, a CPU host); true keeps the columns an image reads on the device across calls.
|
| 160 |
[compute]
|
| 161 |
backend = "jax"
|
| 162 |
+
resident = false
|
| 163 |
|
| 164 |
[tracking]
|
| 165 |
density = 4 # seeds per region voxel = density^3 (4 -> 659,840 seeds, the DiSCo score's setting)
|
|
|
|
| 171 |
[gate]
|
| 172 |
pearson_count_min = 0.90
|
| 173 |
missed_pairs_max = 0
|
| 174 |
+
reference_max_abs_diff = 1e-3 # the layout's signal against the replay pack's own replay of the same rows at the gate's voxels, over the bare b = 0 signal
|
| 175 |
+
reference_voxels = 32 # voxels spread over the pack's index the reference is replayed at
|
| 176 |
|
| 177 |
# The reconstruction: DiSCo is single-fibre white matter, so its CSD is the single-tissue Tournier 2007 one on the
|
| 178 |
# response white_matter_response_tournier07 estimates from the volume. DISCO_RECONSTRUCTION in the environment overrides.
|
space/limits.py
CHANGED
|
@@ -52,7 +52,7 @@ def cap_threads(quota=None):
|
|
| 52 |
def cap_affinity(quota=None):
|
| 53 |
"""Restricts this process to the first ``quota`` CPUs it may run on (``os.sched_setaffinity``; ``quota`` overrides
|
| 54 |
the cgroup read), so that every pool that sizes itself on the schedulable CPUs (XLA's, and torch's) matches the
|
| 55 |
-
quota; returns the CPUs it
|
| 56 |
than it. Children (the warm-up's process, the forked GPU worker) inherit it."""
|
| 57 |
n = cpu_quota() if quota is None else quota
|
| 58 |
allowed = sorted(os.sched_getaffinity(0))
|
|
|
|
| 52 |
def cap_affinity(quota=None):
|
| 53 |
"""Restricts this process to the first ``quota`` CPUs it may run on (``os.sched_setaffinity``; ``quota`` overrides
|
| 54 |
the cgroup read), so that every pool that sizes itself on the schedulable CPUs (XLA's, and torch's) matches the
|
| 55 |
+
quota; returns the CPUs it runs on, or None when there is no quota or the process already runs on no more
|
| 56 |
than it. Children (the warm-up's process, the forked GPU worker) inherit it."""
|
| 57 |
n = cpu_quota() if quota is None else quota
|
| 58 |
allowed = sorted(os.sched_getaffinity(0))
|
space/pipeline.py
CHANGED
|
@@ -97,13 +97,15 @@ def backend(cfg):
|
|
| 97 |
class Shell:
|
| 98 |
"""One shell: the timing class it plays, its b-value (s/mm^2) and its direction count. A shell made by
|
| 99 |
:meth:`free` carries its own pulse timing (``delta``, ``Delta``, ``TE`` in seconds) instead of a stored class,
|
| 100 |
-
and ``shape`` names it.
|
|
|
|
| 101 |
shape: str
|
| 102 |
b: float
|
| 103 |
n_dirs: int
|
| 104 |
delta: Optional[float] = None
|
| 105 |
Delta: Optional[float] = None
|
| 106 |
TE: Optional[float] = None
|
|
|
|
| 107 |
|
| 108 |
@classmethod
|
| 109 |
def free(cls, b, n_dirs, delta, Delta, TE):
|
|
@@ -145,16 +147,26 @@ class Protocol:
|
|
| 145 |
def n_meas(self):
|
| 146 |
return self.n_b0 + sum(s.n_dirs for s in self.shells)
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
@dataclass(frozen=True)
|
| 150 |
class Measurements:
|
| 151 |
-
"""Per row: b (s/mm^2), unit direction, timing-class name, delta / Delta / TE (s)
|
|
|
|
| 152 |
bvals: np.ndarray
|
| 153 |
dirs: np.ndarray
|
| 154 |
shape: np.ndarray
|
| 155 |
delta: np.ndarray
|
| 156 |
Delta: np.ndarray
|
| 157 |
TE: np.ndarray
|
|
|
|
| 158 |
|
| 159 |
@property
|
| 160 |
def b0(self):
|
|
@@ -185,7 +197,10 @@ def measurements(protocol, shapes):
|
|
| 185 |
b = np.asarray(protocol.bvals, np.float64)
|
| 186 |
delta = np.array([timing[n]["delta"] for n in names]); Delta = np.array([timing[n]["Delta"] for n in names])
|
| 187 |
TE = np.array([timing[n]["TE"] for n in names])
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
|
| 191 |
def protocol_from_rows(bvals, dirs, group, make_shell, *, name):
|
|
@@ -230,15 +245,41 @@ def protocol_from_scheme(path, *, name="uploaded scheme"):
|
|
| 230 |
return protocol_from_rows(b, np.asarray(enc.gradient_directions, np.float64), group.ravel(), make_shell, name=name)[0]
|
| 231 |
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
def preset_protocol(cfg, name):
|
| 234 |
-
"""A ``[presets]`` entry of the config as a Protocol.
|
|
|
|
|
|
|
| 235 |
p = cfg["presets"][name]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
return Protocol(tuple(Shell(s["shape"], float(s["b"]), int(s["n_dirs"])) for s in p["shells"]), n_b0=int(p["n_b0"]), name=name)
|
| 237 |
|
| 238 |
|
| 239 |
def retime(protocol, shape):
|
| 240 |
"""``protocol`` with every shell on the timing class ``shape`` (b-values, directions and counts kept): the A/B
|
| 241 |
-
knob that swaps a PGSE class for its stimulated-echo twin, or one δ / Δ for another.
|
|
|
|
|
|
|
|
|
|
| 242 |
return Protocol(tuple(Shell(shape, s.b, s.n_dirs) for s in protocol.shells), n_b0=protocol.n_b0,
|
| 243 |
directions=protocol.directions, bvals=protocol.bvals, name=f"{protocol.name} on {shape}")
|
| 244 |
|
|
@@ -258,6 +299,26 @@ def write_volumes(res, out_dir, *, prefix, affine):
|
|
| 258 |
return paths
|
| 259 |
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
# ---- the physics: tissue and scanner --------------------------------------------------------------------------------
|
| 262 |
CATALOGUE_FIELDS = (1.5, 3.0, 7.0) # where dmipy-sim's white-matter catalogue has cited relaxation
|
| 263 |
CATALOGUE_POOLS = ("intra", "extra", "myelin") # the pools the catalogue has T2 and T1 for
|
|
@@ -295,7 +356,7 @@ class Physics:
|
|
| 295 |
"""The tissue and the scanner a replay is evaluated at: the field (T) and its direction in the substrate frame,
|
| 296 |
T2 and T1 per seeded pool (s), the walls' surface relaxivity ``rho2`` (m/s), the sheath's ``chi_iso`` and
|
| 297 |
``chi_aniso`` (SI, the field source); ``relaxation`` / ``contact`` / ``field`` switch the three tiers, so a tier
|
| 298 |
-
is a knob the page can turn off one at a time. :meth:`tissue` is the :class:`
|
| 299 |
the replay (None when every tier is off: bare diffusion). ``scanner`` is the machine (a catalogue key; None: the
|
| 300 |
ideal scanner), whose field the physics is at, and ``offset_m`` where the phantom's centre sits from isocentre
|
| 301 |
(metres, the scanner's frame): together they say what gradient every voxel is delivered (docs/scanner.md)."""
|
|
@@ -353,7 +414,7 @@ class Physics:
|
|
| 353 |
def tissue(self, unseeded=()):
|
| 354 |
"""The Tissue for the replay; ``unseeded`` names the substrate's pools no walker was seeded in, which the
|
| 355 |
library's mapping still wants a value for: they take the first seeded pool's (nothing reads it)."""
|
| 356 |
-
from
|
| 357 |
if self.bare:
|
| 358 |
return None
|
| 359 |
first = self.pools[0]
|
|
@@ -462,7 +523,8 @@ class Source:
|
|
| 462 |
The page's side: :meth:`describe` (the title and every text that names the source), :meth:`presets` and
|
| 463 |
:meth:`protocol` (the acquisitions offered), :meth:`panel` (the tissue-and-scanner inputs),
|
| 464 |
:meth:`catalogue_numbers`, :meth:`physics_from`, :meth:`knobs` and :meth:`apply_knob` (B, one knob away from A),
|
| 465 |
-
:meth:`refused_machines` (the catalogued machines kept off the scanner menu, and why),
|
|
|
|
| 466 |
:meth:`tracking_controls` and :meth:`estimated_seconds` (the GPU seconds a run reserves on a shared pool).
|
| 467 |
|
| 468 |
The run's side: :attr:`regions` and :attr:`affine`, :meth:`validate`, :meth:`prepare` (the share of a replay
|
|
@@ -488,6 +550,12 @@ class Source:
|
|
| 488 |
page shows under the menu. None here."""
|
| 489 |
return {}
|
| 490 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
@classmethod
|
| 492 |
def describe(cls, cfg):
|
| 493 |
"""The texts that name the source: ``title``, ``heading`` (the page's first paragraph), ``acquisition``
|
|
@@ -785,7 +853,7 @@ def csd(dwi, meas, mask, backend="jax"):
|
|
| 785 |
t0 = time.perf_counter()
|
| 786 |
data = np.nan_to_num(dwi, nan=0.0)
|
| 787 |
sch = scheme(meas)
|
| 788 |
-
S0, response, _ = white_matter_response_tournier07(sch, data[mask])
|
| 789 |
mc = MultiCompartmentSphericalHarmonicsModel(models=[response], sh_order=SH_ORDER)
|
| 790 |
fitted = mc.fit(sch, data, mask=mask, solver=f"csd_tournier07_{backend}", verbose=False)
|
| 791 |
return np.asarray(fitted.fitted_parameters["sh_coeff"], np.float64), time.perf_counter() - t0, (S0, response)
|
|
@@ -983,7 +1051,7 @@ def b0_snr(res):
|
|
| 983 |
def gradient_needed(b, delta, Delta):
|
| 984 |
"""The square-pulse PGSE amplitude (T/m) that gives ``b`` (s/mm^2) at ``delta`` / ``Delta`` (s):
|
| 985 |
b = γ² G² δ² (Δ − δ/3)."""
|
| 986 |
-
from
|
| 987 |
return float(np.sqrt(b * 1e6 / (GAMMA ** 2 * delta ** 2 * (Delta - delta / 3.0))))
|
| 988 |
|
| 989 |
|
|
@@ -992,7 +1060,7 @@ IDEAL = "ideal: no scanner terms, any gradient amplitude"
|
|
| 992 |
|
| 993 |
def machines(cfg):
|
| 994 |
"""The configuration's scanner menu after :data:`IDEAL`: ``{label: catalogue key}`` (``[scanners] machines``), each a
|
| 995 |
-
machine of dmipy-
|
| 996 |
return {m["label"]: m["key"] for m in (cfg.get("scanners") or {}).get("machines", [])}
|
| 997 |
|
| 998 |
|
|
@@ -1007,21 +1075,26 @@ def machine(cfg, label):
|
|
| 1007 |
|
| 1008 |
|
| 1009 |
def limits(key):
|
| 1010 |
-
"""The :class:`
|
| 1011 |
-
from
|
| 1012 |
return ScannerLimits.of(key)
|
| 1013 |
|
| 1014 |
|
| 1015 |
def playable(protocol, shapes, scanner=None):
|
| 1016 |
"""Per shell, the gradient it needs and whether ``scanner`` (a catalogue key, or None for no limit) can play it:
|
| 1017 |
rows of ``(shell, b, delta, Delta, G_needed, G_max or None, ok)`` in SI. A stimulated-echo class needs the same
|
| 1018 |
-
amplitude as its PGSE twin (the same δ and diffusion time)
|
|
|
|
|
|
|
| 1019 |
G_max = float(limits(scanner).G_max) if scanner else None
|
| 1020 |
rows = []
|
| 1021 |
for s in protocol.shells:
|
| 1022 |
t = dict(delta=s.delta, Delta=s.Delta) if s.free_timing else shapes[s.shape]
|
| 1023 |
delta, Delta = float(t["delta"]), float(t["Delta"])
|
| 1024 |
-
|
|
|
|
|
|
|
|
|
|
| 1025 |
rows.append((s.shape, s.b, delta, Delta, G, G_max, G_max is None or G <= G_max))
|
| 1026 |
return rows
|
| 1027 |
|
|
@@ -1041,7 +1114,7 @@ def scanner_terms(key):
|
|
| 1041 |
"""The rows of a machine's term table: ``(term, applied, where the number comes from)``. ``applied`` is whether
|
| 1042 |
the catalogue carries the term (the page applies every one it carries); the source is the catalogue leaves'
|
| 1043 |
confidence as the page names it (:data:`CONFIDENCE`) with their citation keys, or why the term is absent."""
|
| 1044 |
-
from
|
| 1045 |
L = limits(key)
|
| 1046 |
_key, entry, _kind = scc.resolve(key)
|
| 1047 |
rows = []
|
|
@@ -1120,6 +1193,9 @@ class Result:
|
|
| 1120 |
extras: dict = field(default_factory=dict)
|
| 1121 |
|
| 1122 |
|
|
|
|
|
|
|
|
|
|
| 1123 |
def run_stages(source, protocol, *, snr=None, tracking=Tracking(), noise_seed=0, physics=None, prepared=None):
|
| 1124 |
"""The pipeline as a generator: before each of the source's ``STAGES`` it yields ``(stage, k, n)`` (so a page
|
| 1125 |
can show where the run is, from inside any worker), and last the :class:`Result`. ``physics`` is the tissue and
|
|
@@ -1131,10 +1207,20 @@ def run_stages(source, protocol, *, snr=None, tracking=Tracking(), noise_seed=0,
|
|
| 1131 |
def at(stage):
|
| 1132 |
print(f"[pipeline] {stage} at +{time.perf_counter() - t_run:.1f} s", flush=True) # the server log shows where a run is
|
| 1133 |
return stage, stages.index(stage), len(stages)
|
|
|
|
|
|
|
| 1134 |
meas = source.validate(protocol, physics)
|
| 1135 |
yield at("replay"); dwi, floor, s0_factor, t_replay = source.replay(meas, physics, prepared)
|
| 1136 |
yield at("noise"); t0 = time.perf_counter(); noisy = add_noise(dwi, snr, s0_factor, seed=noise_seed, backend=source.backend); t_noise = time.perf_counter() - t0
|
| 1137 |
signal = np.isfinite(dwi[..., 0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1138 |
yield at("csd"); sh, t_csd, extras = source.reconstruct(noisy, s0_factor, meas, signal)
|
| 1139 |
yield at("track"); t0 = time.perf_counter(); fld, seeds = source.tracking_inputs(sh, tracking.density); tg, _ = track(fld, seeds, tracking, source.backend); t_track = time.perf_counter() - t0
|
| 1140 |
seconds = dict(replay=t_replay, noise=t_noise, **t_csd, track=t_track)
|
|
|
|
| 97 |
class Shell:
|
| 98 |
"""One shell: the timing class it plays, its b-value (s/mm^2) and its direction count. A shell made by
|
| 99 |
:meth:`free` carries its own pulse timing (``delta``, ``Delta``, ``TE`` in seconds) instead of a stored class,
|
| 100 |
+
and ``shape`` names it. On a b-tensor class (:mod:`space.tensor`) ``encoding`` is the b-tensor's shape, ``LTE``
|
| 101 |
+
(linear) or ``STE`` (spherical), and a direction is the encoding's axis."""
|
| 102 |
shape: str
|
| 103 |
b: float
|
| 104 |
n_dirs: int
|
| 105 |
delta: Optional[float] = None
|
| 106 |
Delta: Optional[float] = None
|
| 107 |
TE: Optional[float] = None
|
| 108 |
+
encoding: Optional[str] = None
|
| 109 |
|
| 110 |
@classmethod
|
| 111 |
def free(cls, b, n_dirs, delta, Delta, TE):
|
|
|
|
| 147 |
def n_meas(self):
|
| 148 |
return self.n_b0 + sum(s.n_dirs for s in self.shells)
|
| 149 |
|
| 150 |
+
@property
|
| 151 |
+
def tensor(self):
|
| 152 |
+
"""Whether the shells are b-tensor encodings (every shell names one; a protocol does not mix them)."""
|
| 153 |
+
enc = [s.encoding is not None for s in self.shells]
|
| 154 |
+
if any(enc) and not all(enc):
|
| 155 |
+
raise ValueError("a protocol's shells are all b-tensor encodings or none")
|
| 156 |
+
return all(enc)
|
| 157 |
+
|
| 158 |
|
| 159 |
@dataclass(frozen=True)
|
| 160 |
class Measurements:
|
| 161 |
+
"""Per row: b (s/mm^2), unit direction, timing-class name, delta / Delta / TE (s), and on a b-tensor protocol the
|
| 162 |
+
encoding (``b0``, ``LTE``, ``STE``; None otherwise)."""
|
| 163 |
bvals: np.ndarray
|
| 164 |
dirs: np.ndarray
|
| 165 |
shape: np.ndarray
|
| 166 |
delta: np.ndarray
|
| 167 |
Delta: np.ndarray
|
| 168 |
TE: np.ndarray
|
| 169 |
+
encoding: Optional[np.ndarray] = None
|
| 170 |
|
| 171 |
@property
|
| 172 |
def b0(self):
|
|
|
|
| 197 |
b = np.asarray(protocol.bvals, np.float64)
|
| 198 |
delta = np.array([timing[n]["delta"] for n in names]); Delta = np.array([timing[n]["Delta"] for n in names])
|
| 199 |
TE = np.array([timing[n]["TE"] for n in names])
|
| 200 |
+
enc = None
|
| 201 |
+
if protocol.tensor:
|
| 202 |
+
enc = np.asarray(["b0"] * protocol.n_b0 + [s.encoding for s in protocol.shells for _ in range(s.n_dirs)])
|
| 203 |
+
return Measurements(b, dirs, np.asarray(names), delta, Delta, TE, enc)
|
| 204 |
|
| 205 |
|
| 206 |
def protocol_from_rows(bvals, dirs, group, make_shell, *, name):
|
|
|
|
| 245 |
return protocol_from_rows(b, np.asarray(enc.gradient_directions, np.float64), group.ravel(), make_shell, name=name)[0]
|
| 246 |
|
| 247 |
|
| 248 |
+
def preset_snr(cfg, name):
|
| 249 |
+
"""The SNR a preset starts at: its own ``snr`` when the preset states one (the b-tensor preset: µFA is
|
| 250 |
+
noise-limited), else ``[noise] snr``."""
|
| 251 |
+
p = cfg.get("presets", {}).get(name) or {}
|
| 252 |
+
return float(p.get("snr", cfg["noise"]["snr"]))
|
| 253 |
+
|
| 254 |
+
|
| 255 |
def preset_protocol(cfg, name):
|
| 256 |
+
"""A ``[presets]`` entry of the config as a Protocol: a stored gradient table (``table``: ``data/<table>.bvals`` and
|
| 257 |
+
``.bvecs``, every row's own b and direction, each shell its rounded b, all on the class ``shape``), or ``shells``
|
| 258 |
+
of Fibonacci directions after ``n_b0`` b = 0 rows."""
|
| 259 |
p = cfg["presets"][name]
|
| 260 |
+
if "tensor" in p: # b-tensor encodings interleaved per b, the same axes for each
|
| 261 |
+
from . import tensor as T
|
| 262 |
+
t = p["tensor"]
|
| 263 |
+
shells, dirs = [], [np.tile([0.0, 0.0, 1.0], (int(p.get("n_b0", 1)), 1))]
|
| 264 |
+
for b, n in zip(t["bvalues"], t["directions"]):
|
| 265 |
+
for e in t["encodings"]:
|
| 266 |
+
shells.append(Shell(p["shape"], float(b), int(n), encoding=e)); dirs.append(T.fibonacci(int(n)))
|
| 267 |
+
return Protocol(tuple(shells), n_b0=int(p.get("n_b0", 1)), directions=np.concatenate(dirs), name=name)
|
| 268 |
+
if "table" in p:
|
| 269 |
+
bv = np.loadtxt(os.path.join(DATA_DIR, p["table"] + ".bvals")).ravel()
|
| 270 |
+
dirs = np.loadtxt(os.path.join(DATA_DIR, p["table"] + ".bvecs"))
|
| 271 |
+
dirs = dirs.T if dirs.shape[0] == 3 else dirs
|
| 272 |
+
shell = np.round(bv, -1)
|
| 273 |
+
return protocol_from_rows(bv, dirs, shell, lambda g, rows: Shell(p["shape"], float(g), int(len(rows))), name=name)[0]
|
| 274 |
return Protocol(tuple(Shell(s["shape"], float(s["b"]), int(s["n_dirs"])) for s in p["shells"]), n_b0=int(p["n_b0"]), name=name)
|
| 275 |
|
| 276 |
|
| 277 |
def retime(protocol, shape):
|
| 278 |
"""``protocol`` with every shell on the timing class ``shape`` (b-values, directions and counts kept): the A/B
|
| 279 |
+
knob that swaps a PGSE class for its stimulated-echo twin, or one δ / Δ for another. A b-tensor protocol's shells
|
| 280 |
+
are encodings of its class, not timings: it is refused by name."""
|
| 281 |
+
if protocol.tensor:
|
| 282 |
+
raise ValueError("a b-tensor acquisition's shells are encodings of its one class: a pulse-timing knob does not apply")
|
| 283 |
return Protocol(tuple(Shell(shape, s.b, s.n_dirs) for s in protocol.shells), n_b0=protocol.n_b0,
|
| 284 |
directions=protocol.directions, bvals=protocol.bvals, name=f"{protocol.name} on {shape}")
|
| 285 |
|
|
|
|
| 299 |
return paths
|
| 300 |
|
| 301 |
|
| 302 |
+
def write_tensor_volumes(res, out_dir, *, prefix, affine):
|
| 303 |
+
"""A b-tensor run's files: the DWI as NIfTI with its bvals (s/mm^2), bvecs (each row's encoding axis, 3 x N) and
|
| 304 |
+
``.btensor`` (each row's encoding, ``b0`` / ``LTE`` / ``STE``, one per line), and the FA, MD (µm²/ms) and µFA maps as
|
| 305 |
+
NIfTI (float32 on ``affine``, NaN as 0); returns the paths."""
|
| 306 |
+
import nibabel as nib
|
| 307 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 308 |
+
aff = np.asarray(affine, np.float64); tm = res.extras["tensor"]; out = []
|
| 309 |
+
p = os.path.join(out_dir, f"{prefix}_dwi.nii.gz"); nib.save(nib.Nifti1Image(np.nan_to_num(res.dwi).astype(np.float32), aff), p); out.append(p)
|
| 310 |
+
p = os.path.join(out_dir, f"{prefix}.bvals"); np.savetxt(p, res.meas.bvals[None, :], fmt="%.1f"); out.append(p)
|
| 311 |
+
p = os.path.join(out_dir, f"{prefix}.bvecs"); np.savetxt(p, res.meas.dirs.T, fmt="%.8f"); out.append(p)
|
| 312 |
+
p = os.path.join(out_dir, f"{prefix}.btensor")
|
| 313 |
+
with open(p, "w") as f:
|
| 314 |
+
f.write("\n".join(str(e) for e in res.meas.encoding) + "\n")
|
| 315 |
+
out.append(p)
|
| 316 |
+
for key in ("fa", "md", "ufa"):
|
| 317 |
+
p = os.path.join(out_dir, f"{prefix}_{key}.nii.gz")
|
| 318 |
+
nib.save(nib.Nifti1Image(np.nan_to_num(np.asarray(tm[key], np.float64)).astype(np.float32), aff), p); out.append(p)
|
| 319 |
+
return out
|
| 320 |
+
|
| 321 |
+
|
| 322 |
# ---- the physics: tissue and scanner --------------------------------------------------------------------------------
|
| 323 |
CATALOGUE_FIELDS = (1.5, 3.0, 7.0) # where dmipy-sim's white-matter catalogue has cited relaxation
|
| 324 |
CATALOGUE_POOLS = ("intra", "extra", "myelin") # the pools the catalogue has T2 and T1 for
|
|
|
|
| 356 |
"""The tissue and the scanner a replay is evaluated at: the field (T) and its direction in the substrate frame,
|
| 357 |
T2 and T1 per seeded pool (s), the walls' surface relaxivity ``rho2`` (m/s), the sheath's ``chi_iso`` and
|
| 358 |
``chi_aniso`` (SI, the field source); ``relaxation`` / ``contact`` / ``field`` switch the three tiers, so a tier
|
| 359 |
+
is a knob the page can turn off one at a time. :meth:`tissue` is the :class:`dmipy_replay.spec.tissue.Tissue` for
|
| 360 |
the replay (None when every tier is off: bare diffusion). ``scanner`` is the machine (a catalogue key; None: the
|
| 361 |
ideal scanner), whose field the physics is at, and ``offset_m`` where the phantom's centre sits from isocentre
|
| 362 |
(metres, the scanner's frame): together they say what gradient every voxel is delivered (docs/scanner.md)."""
|
|
|
|
| 414 |
def tissue(self, unseeded=()):
|
| 415 |
"""The Tissue for the replay; ``unseeded`` names the substrate's pools no walker was seeded in, which the
|
| 416 |
library's mapping still wants a value for: they take the first seeded pool's (nothing reads it)."""
|
| 417 |
+
from dmipy_replay.spec.tissue import Tissue
|
| 418 |
if self.bare:
|
| 419 |
return None
|
| 420 |
first = self.pools[0]
|
|
|
|
| 523 |
The page's side: :meth:`describe` (the title and every text that names the source), :meth:`presets` and
|
| 524 |
:meth:`protocol` (the acquisitions offered), :meth:`panel` (the tissue-and-scanner inputs),
|
| 525 |
:meth:`catalogue_numbers`, :meth:`physics_from`, :meth:`knobs` and :meth:`apply_knob` (B, one knob away from A),
|
| 526 |
+
:meth:`refused_machines` (the catalogued machines kept off the scanner menu, and why), :meth:`unavailable` (the
|
| 527 |
+
page's text when the inputs the configuration names cannot be read),
|
| 528 |
:meth:`tracking_controls` and :meth:`estimated_seconds` (the GPU seconds a run reserves on a shared pool).
|
| 529 |
|
| 530 |
The run's side: :attr:`regions` and :attr:`affine`, :meth:`validate`, :meth:`prepare` (the share of a replay
|
|
|
|
| 550 |
page shows under the menu. None here."""
|
| 551 |
return {}
|
| 552 |
|
| 553 |
+
@classmethod
|
| 554 |
+
def unavailable(cls, cfg):
|
| 555 |
+
"""``(title, text)`` when the inputs the configuration names cannot be read, the text naming each and why, else
|
| 556 |
+
None: the Space then serves that text as its page. Read at startup, before the page is built. None here."""
|
| 557 |
+
return None
|
| 558 |
+
|
| 559 |
@classmethod
|
| 560 |
def describe(cls, cfg):
|
| 561 |
"""The texts that name the source: ``title``, ``heading`` (the page's first paragraph), ``acquisition``
|
|
|
|
| 853 |
t0 = time.perf_counter()
|
| 854 |
data = np.nan_to_num(dwi, nan=0.0)
|
| 855 |
sch = scheme(meas)
|
| 856 |
+
S0, response, _ = white_matter_response_tournier07(sch, data[mask], backend=backend)
|
| 857 |
mc = MultiCompartmentSphericalHarmonicsModel(models=[response], sh_order=SH_ORDER)
|
| 858 |
fitted = mc.fit(sch, data, mask=mask, solver=f"csd_tournier07_{backend}", verbose=False)
|
| 859 |
return np.asarray(fitted.fitted_parameters["sh_coeff"], np.float64), time.perf_counter() - t0, (S0, response)
|
|
|
|
| 1051 |
def gradient_needed(b, delta, Delta):
|
| 1052 |
"""The square-pulse PGSE amplitude (T/m) that gives ``b`` (s/mm^2) at ``delta`` / ``Delta`` (s):
|
| 1053 |
b = γ² G² δ² (Δ − δ/3)."""
|
| 1054 |
+
from dmipy_analytic.constants import GAMMA
|
| 1055 |
return float(np.sqrt(b * 1e6 / (GAMMA ** 2 * delta ** 2 * (Delta - delta / 3.0))))
|
| 1056 |
|
| 1057 |
|
|
|
|
| 1060 |
|
| 1061 |
def machines(cfg):
|
| 1062 |
"""The configuration's scanner menu after :data:`IDEAL`: ``{label: catalogue key}`` (``[scanners] machines``), each a
|
| 1063 |
+
machine of dmipy-replay's catalogue whose every catalogued term the page applies (docs/scanner.md)."""
|
| 1064 |
return {m["label"]: m["key"] for m in (cfg.get("scanners") or {}).get("machines", [])}
|
| 1065 |
|
| 1066 |
|
|
|
|
| 1075 |
|
| 1076 |
|
| 1077 |
def limits(key):
|
| 1078 |
+
"""The :class:`dmipy_replay.acquisition.scanners.ScannerLimits` of a catalogue key."""
|
| 1079 |
+
from dmipy_replay.acquisition.scanners import ScannerLimits
|
| 1080 |
return ScannerLimits.of(key)
|
| 1081 |
|
| 1082 |
|
| 1083 |
def playable(protocol, shapes, scanner=None):
|
| 1084 |
"""Per shell, the gradient it needs and whether ``scanner`` (a catalogue key, or None for no limit) can play it:
|
| 1085 |
rows of ``(shell, b, delta, Delta, G_needed, G_max or None, ok)`` in SI. A stimulated-echo class needs the same
|
| 1086 |
+
amplitude as its PGSE twin (the same δ and diffusion time); an OGSE class the peak of its own waveform
|
| 1087 |
+
(:func:`space.classes.amplitude`; its delta / Delta are the scheme's nominal block and separation)."""
|
| 1088 |
+
from . import classes as C
|
| 1089 |
G_max = float(limits(scanner).G_max) if scanner else None
|
| 1090 |
rows = []
|
| 1091 |
for s in protocol.shells:
|
| 1092 |
t = dict(delta=s.delta, Delta=s.Delta) if s.free_timing else shapes[s.shape]
|
| 1093 |
delta, Delta = float(t["delta"]), float(t["Delta"])
|
| 1094 |
+
if t.get("kind") == "tensor":
|
| 1095 |
+
G = C.tensor_peak(t, s.encoding, s.b, s.n_dirs) # per axis, over the shell's rotations
|
| 1096 |
+
else:
|
| 1097 |
+
G = C.amplitude(t, s.b) if t.get("kind") == "ogse" else gradient_needed(s.b, delta, Delta)
|
| 1098 |
rows.append((s.shape, s.b, delta, Delta, G, G_max, G_max is None or G <= G_max))
|
| 1099 |
return rows
|
| 1100 |
|
|
|
|
| 1114 |
"""The rows of a machine's term table: ``(term, applied, where the number comes from)``. ``applied`` is whether
|
| 1115 |
the catalogue carries the term (the page applies every one it carries); the source is the catalogue leaves'
|
| 1116 |
confidence as the page names it (:data:`CONFIDENCE`) with their citation keys, or why the term is absent."""
|
| 1117 |
+
from dmipy_replay.acquisition import scanner_constants as scc
|
| 1118 |
L = limits(key)
|
| 1119 |
_key, entry, _kind = scc.resolve(key)
|
| 1120 |
rows = []
|
|
|
|
| 1193 |
extras: dict = field(default_factory=dict)
|
| 1194 |
|
| 1195 |
|
| 1196 |
+
TENSOR_STAGES = ("replay", "noise", "maps") # a b-tensor protocol's stages
|
| 1197 |
+
|
| 1198 |
+
|
| 1199 |
def run_stages(source, protocol, *, snr=None, tracking=Tracking(), noise_seed=0, physics=None, prepared=None):
|
| 1200 |
"""The pipeline as a generator: before each of the source's ``STAGES`` it yields ``(stage, k, n)`` (so a page
|
| 1201 |
can show where the run is, from inside any worker), and last the :class:`Result`. ``physics`` is the tissue and
|
|
|
|
| 1207 |
def at(stage):
|
| 1208 |
print(f"[pipeline] {stage} at +{time.perf_counter() - t_run:.1f} s", flush=True) # the server log shows where a run is
|
| 1209 |
return stage, stages.index(stage), len(stages)
|
| 1210 |
+
if protocol.tensor: # a b-tensor protocol: microstructure maps, not a connectome
|
| 1211 |
+
stages = TENSOR_STAGES
|
| 1212 |
meas = source.validate(protocol, physics)
|
| 1213 |
yield at("replay"); dwi, floor, s0_factor, t_replay = source.replay(meas, physics, prepared)
|
| 1214 |
yield at("noise"); t0 = time.perf_counter(); noisy = add_noise(dwi, snr, s0_factor, seed=noise_seed, backend=source.backend); t_noise = time.perf_counter() - t0
|
| 1215 |
signal = np.isfinite(dwi[..., 0])
|
| 1216 |
+
if protocol.tensor:
|
| 1217 |
+
from . import tensor as T
|
| 1218 |
+
yield at("maps"); t0 = time.perf_counter(); tmaps = T.maps(noisy, meas, signal)
|
| 1219 |
+
seconds = dict(replay=t_replay, noise=t_noise, maps=time.perf_counter() - t0)
|
| 1220 |
+
seconds["total"] = sum(seconds.values())
|
| 1221 |
+
yield Result(protocol, meas, noisy, floor, s0_factor, None, None, None, None, None, snr=snr, physics=physics, clean=dwi,
|
| 1222 |
+
seconds=seconds, extras=dict(tensor=tmaps))
|
| 1223 |
+
return
|
| 1224 |
yield at("csd"); sh, t_csd, extras = source.reconstruct(noisy, s0_factor, meas, signal)
|
| 1225 |
yield at("track"); t0 = time.perf_counter(); fld, seeds = source.tracking_inputs(sh, tracking.density); tg, _ = track(fld, seeds, tracking, source.backend); t_track = time.perf_counter() - t0
|
| 1226 |
seconds = dict(replay=t_replay, noise=t_noise, **t_csd, track=t_track)
|
space/reference.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The acceptance gate's reference: the replay pack the layout was contracted from, replayed walker by walker at a set
|
| 2 |
+
of voxels (only those voxels' rows are read, by range, from the Hub or a local copy). This is the full replay, not a
|
| 3 |
+
rendering: every stored band, every field mode, the contact, the pools; the layout's image of the same rows must equal
|
| 4 |
+
it to ``[gate] reference_max_abs_diff`` (S/S0)."""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def open_pack(uri, workers=8):
|
| 11 |
+
"""The columnar replay pack at ``uri`` (``hf://owner/name/prefix`` or a directory)."""
|
| 12 |
+
from dmipy_replay.replay.columnar import ColumnarPack
|
| 13 |
+
return ColumnarPack(uri, workers=workers)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def gate_voxels(pack, n):
|
| 17 |
+
"""``n`` voxels spread evenly over the pack's index (its (voxel, pool) rows in order), as ijk triples: the spot
|
| 18 |
+
check's rule."""
|
| 19 |
+
keys = sorted({tuple(r["ijk"]) for r in pack.index["rows"]})
|
| 20 |
+
pick = np.unique(np.linspace(0, len(keys) - 1, min(int(n), len(keys))).round().astype(int))
|
| 21 |
+
return [keys[i] for i in pick]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def replay_at(pack, voxels, sequence, tissue=None, scanner=None):
|
| 25 |
+
"""``(n_voxels, n_meas)``: each voxel's weighted ensemble magnitude under ``sequence`` (normalised by its walkers'
|
| 26 |
+
weight, as the layout's image is), from the pack's own rows at ``voxels`` (every band; the field modes and the
|
| 27 |
+
contact read when the tissue needs them)."""
|
| 28 |
+
from dmipy_replay.phantom.grid import Grid
|
| 29 |
+
grid = Grid.from_meta(pack.index["grid"])
|
| 30 |
+
need_field = tissue is not None and (getattr(tissue, "chi_iso", None) or getattr(tissue, "chi_aniso", None))
|
| 31 |
+
view = pack.view(voxels=voxels, contact=tissue is not None,
|
| 32 |
+
modes=(pack.path_groups[-1] if need_field and pack.path_groups else 0))
|
| 33 |
+
w, ew, E = view.walker_signals(sequence, tissue=tissue, scanner=scanner, tol=None)
|
| 34 |
+
ijk, _ = grid.bin(view.r0)
|
| 35 |
+
v = np.ravel_multi_index(ijk.T, tuple(grid.shape))
|
| 36 |
+
flat = np.array([np.ravel_multi_index(k, tuple(grid.shape)) for k in voxels])
|
| 37 |
+
pos = {int(f): i for i, f in enumerate(flat)}
|
| 38 |
+
idx = np.array([pos[int(x)] for x in v])
|
| 39 |
+
num = np.zeros((len(voxels), E.shape[1]), complex)
|
| 40 |
+
np.add.at(num, idx, np.asarray(ew)[:, None] * np.asarray(E))
|
| 41 |
+
den = np.bincount(idx, np.asarray(w, np.float64), minlength=len(voxels))
|
| 42 |
+
return np.abs(num / den[:, None])
|
space/sources/brain.py
CHANGED
|
@@ -4,14 +4,14 @@ replay packs, replayed on the device, reconstructed, tracked and scored against
|
|
| 4 |
The input is an **asset** (the directory layout of ``SubstrateCommons/masivar-brain``: ``manifest.json``,
|
| 5 |
``fod_wm.npy``, ``fractions.npy``, ``mask.npy``, ``labels.npy``, ``regions.json``, ``stop_mask.npy``,
|
| 6 |
``mean_b0.npy``): the multi-tissue CSD of a real scan, made once offline, with its parcellation. The replay is the
|
| 7 |
-
kernel route of dmipy-
|
| 8 |
|
| 9 |
S = | f_wm m0_wm FOD . C_wm + f_gm m0_gm E_gm + f_csf m0_csf E_csf |
|
| 10 |
|
| 11 |
where ``C_wm`` is the WM pack's pose response contracted to the FOD's harmonics
|
| 12 |
-
(:meth:`
|
| 13 |
``E_gm`` the GM pack's response to an isotropic orientation distribution, ``E_csf`` free water in closed form
|
| 14 |
-
(:class:`
|
| 15 |
relaxivity, the myelin sheath's susceptibility at the scanner's field and its direction), and ``m0`` the proton
|
| 16 |
density per tissue. The pose responses (:meth:`Brain.prepare`: seconds on the CPU) are cached in the page's process per
|
| 17 |
pack, protocol, tissue and field; a run looks them up on the page and computes the missing ones inside the GPU call,
|
|
@@ -46,15 +46,22 @@ N_COEF = 45
|
|
| 46 |
REACH_MM = 250.0 # the longest streamline the tracker must be able to reach
|
| 47 |
PROFILE_ANGLES = np.linspace(0.0, 90.0, 19) # degrees between the gradient and a fibre, for the response plot
|
| 48 |
C00 = 1.0 / (2.0 * np.sqrt(np.pi)) # the l = 0 coefficient of a unit-integral density
|
|
|
|
| 49 |
#: why a magnet with its own gradient (a field law: the Swoop) is not on the brain's scanner menu, as the page says it
|
| 50 |
FIELD_LAW_REFUSAL = ("Its own gradient (the permanent magnet's field law, 1.4 mT/m at 8 cm) is on through every pulse and dead "
|
| 51 |
-
"time, so every voxel plays a two-direction waveform of its own. dmipy-
|
| 52 |
"waveform exactly; the cost keeps it off this menu. Binned to the menu's 1 % of b, this head needs 9,752 "
|
| 53 |
"encoding classes for the scan's protocol (665 at 3 %), and one class's pose responses take 2-3 minutes "
|
| 54 |
"(WM 104-166 s, GM 4-21 s at 485 measurements on a GH200), so one protocol at one tissue and field is about "
|
| 55 |
"two weeks of one device. The DiSCo Space plays the Swoop exactly.")
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
# ---- the asset ---------------------------------------------------------------------------------------------------------
|
| 59 |
|
| 60 |
def asset_location(cfg):
|
|
@@ -94,7 +101,8 @@ def manifest(where):
|
|
| 94 |
def declared_segments(uri):
|
| 95 |
"""The segment table ``{n, n_t, T}`` (RPK.md 4.3) a pack declares, read without its arrays: a Hub pack's
|
| 96 |
record in its dataset's ``manifest.json`` (``hf://owner/name/path``), a local pack's header."""
|
| 97 |
-
from
|
|
|
|
| 98 |
if is_hub_uri(uri):
|
| 99 |
from huggingface_hub import hf_hub_download
|
| 100 |
repo, path = parse_uri(uri)
|
|
@@ -111,9 +119,61 @@ def declared_segments(uri):
|
|
| 111 |
return dict(n=int(seg["n"]), n_t=int(seg["n_t"]), T=float(seg["T"]))
|
| 112 |
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
def image_rotation(man):
|
| 115 |
-
"""``R`` (image -> scanner) of the asset's grid (:meth:`
|
| 116 |
-
from
|
| 117 |
_, R = Grid.from_oblique_affine(np.asarray(man["grid"]["affine"], np.float64), tuple(man["grid"]["shape"]))
|
| 118 |
return np.asarray(R, np.float64)
|
| 119 |
|
|
@@ -169,11 +229,11 @@ class Asset:
|
|
| 169 |
mask = np.load(os.path.join(d, "mask.npy")).astype(bool)
|
| 170 |
if fod.shape != shape + (N_COEF,) or mask.shape != shape:
|
| 171 |
raise ValueError(f"the asset's FOD {fod.shape} and mask {mask.shape} are not on its grid {shape} with {N_COEF} coefficients")
|
| 172 |
-
from
|
| 173 |
basis = str(man["fod"]["basis"]).split()[0]
|
| 174 |
if SH_BASES.get(basis) != ("tournier07", False) or int(man["fod"]["lmax"]) != LMAX or man["fod"]["frame"] != "image":
|
| 175 |
-
raise ValueError(f"the asset's FOD is {man['fod']}: this source reads dmipy-
|
| 176 |
-
f"name, the first word,
|
| 177 |
f"tournier07, mrtrix3, dmipy-fit) at lmax {LMAX} in the image frame")
|
| 178 |
if list(man["fractions"]) != list(TISSUES):
|
| 179 |
raise ValueError(f"the asset's fractions are {man['fractions']}, this source reads {list(TISSUES)}")
|
|
@@ -231,7 +291,7 @@ class BrainPhysics:
|
|
| 231 |
``chi_iso`` / ``chi_aniso`` the myelin sheath's susceptibility (SI, the WM pack's field source), ``D_csf`` free
|
| 232 |
water's diffusivity (m^2/s), ``m0`` the proton density per tissue relative to CSF, ``wm_pack`` / ``gm_pack`` the
|
| 233 |
packs by their configuration label; ``relaxation``, ``contact`` and ``field`` switch the three tiers; ``scanner``
|
| 234 |
-
the machine (a key of dmipy-
|
| 235 |
the physics', and every voxel is played the gradient it delivers there with the head centre at isocentre
|
| 236 |
(:meth:`Brain.prepare`); None is the ideal scanner, ideal pulses with vertical ramps. All off is bare diffusion at
|
| 237 |
the same proton densities and packs."""
|
|
@@ -284,7 +344,7 @@ class BrainPhysics:
|
|
| 284 |
(vertical ramps) on the ideal scanner."""
|
| 285 |
if self.scanner is None:
|
| 286 |
return np.inf
|
| 287 |
-
from
|
| 288 |
return float(ScannerLimits.of(self.scanner).slew_max)
|
| 289 |
|
| 290 |
def label(self):
|
|
@@ -296,13 +356,13 @@ class BrainPhysics:
|
|
| 296 |
return f"{self.field_T:g} T along ({u[0]:.2f}, {u[1]:.2f}, {u[2]:.2f}), tiers {tiers}{grad}"
|
| 297 |
|
| 298 |
def tissues(self, wm_pack, gm_pack):
|
| 299 |
-
"""``(wm, gm, csf)`` :class:`
|
| 300 |
pools with their T2 / T1, the walls' rho2 and, on a WM pack whose spec declares its susceptibility field
|
| 301 |
present (a magnetic myelin pool), the sheath's susceptibility; the GM pack's pools at grey matter's T2 / T1;
|
| 302 |
free water's D, T2 and T1. A tier off leaves its values out (``None`` for a pack with nothing on: its bare
|
| 303 |
diffusion). A WM pack whose field is absent has no sheath, so its tissue carries no susceptibility and its
|
| 304 |
field tier is zero at any field."""
|
| 305 |
-
from
|
| 306 |
wm_pools, gm_pools = Brain.pools(wm_pack), Brain.pools(gm_pack)
|
| 307 |
relax, contact = self.relaxation, self.contact
|
| 308 |
field = self.field and wm_pack.susceptibility_field == "present"
|
|
@@ -335,7 +395,7 @@ def specimen_pose(R, b0_direction):
|
|
| 335 |
image -> scanner rotation and ``W`` the rotation taking the field's scanner-frame direction onto z (the bore's
|
| 336 |
field axis), so the packs see the field at ``R.T b0_direction`` in the image frame. A sequence composed at this
|
| 337 |
pose carries the gradients ``pose @ g`` (:meth:`Brain.sequence`)."""
|
| 338 |
-
from
|
| 339 |
return rotation_of(np.asarray(b0_direction, np.float64)).T @ np.asarray(R, np.float64)
|
| 340 |
|
| 341 |
|
|
@@ -503,6 +563,27 @@ class Brain(P.Source):
|
|
| 503 |
out[m["label"]] = FIELD_LAW_REFUSAL
|
| 504 |
return out
|
| 505 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
@classmethod
|
| 507 |
def pack_menu(cls, cfg, tissue):
|
| 508 |
"""The configuration's packs for ``tissue`` (``wm`` / ``gm``): ``{label: uri}`` in the menu's order."""
|
|
@@ -520,7 +601,7 @@ class Brain(P.Source):
|
|
| 520 |
"""``{class: k}``: per timing class of ``meas``, the number of the pack's windows its acquisition reaches.
|
| 521 |
``segments`` is a declared table ``{n, n_t, T}`` (:func:`declared_segments`) or a pack's meta (its
|
| 522 |
``walk_params.segments``). The acquisition lasts ``T_acq = (G.shape[1] - 1) dt`` of the class's sequence,
|
| 523 |
-
as :meth:`
|
| 524 |
first save sits at or beyond ``T_acq`` is not reached, so the save at a window's end belongs to that window
|
| 525 |
alone. A pack of one window (``n = 1``) is reached as one window whatever the class; ``k``
|
| 526 |
above ``n`` is an acquisition beyond the walk (:meth:`windows_needed` refuses it)."""
|
|
@@ -554,12 +635,12 @@ class Brain(P.Source):
|
|
| 554 |
|
| 555 |
def pack(self, tissue, label, k):
|
| 556 |
"""The pack ``label`` of ``tissue``'s menu holding at least its first ``k`` windows: a Hub URI whose
|
| 557 |
-
declared table has ``n > 1`` loads ``windows=range(k)`` (:class:`
|
| 558 |
records them as ``windows_present``); a local path or a one-window pack loads whole. A later request for
|
| 559 |
more windows than it holds reloads with the larger range (the library fetches only the new ranges) and
|
| 560 |
replaces the cached pack."""
|
| 561 |
-
from
|
| 562 |
-
from
|
| 563 |
key = (tissue, label)
|
| 564 |
seg = self.segments(tissue, label)
|
| 565 |
if key in self.packs and self.windows_held(self.packs[key], seg) >= int(k):
|
|
@@ -640,7 +721,7 @@ class Brain(P.Source):
|
|
| 640 |
"the head's own susceptibility and RF effects are not modelled",
|
| 641 |
"the head centre at the scanner's isocentre (a machine of the scanner menu plays every voxel the gradient it delivers there; docs/scanner.md)",
|
| 642 |
"no per-voxel split-half floor: the packs' certified floors are the accuracy"],
|
| 643 |
-
accuracy=("A brain replay is exact composition (the tests check it against dmipy-
|
| 644 |
"the packs' responses, so its accuracy is the packs' certified Monte-Carlo floors; there is no per-voxel split-half "
|
| 645 |
"floor, because every voxel reads the same two walks."),
|
| 646 |
ingredients=("proton density: Σ f·M0 (relative to CSF)", "relaxation: the b = 0 signal's T2 / T1 weight at the echo",
|
|
@@ -840,16 +921,16 @@ class Brain(P.Source):
|
|
| 840 |
the image-frame directions turned by ``pose``, trapezoid pulses at the class's delta / Delta / TE with ramps at
|
| 841 |
``slew_rate`` (vertical at the default, infinite), a stimulated echo storing for TM = Delta - delta when the
|
| 842 |
class is one."""
|
| 843 |
-
|
| 844 |
names = np.unique(meas.shape[rows])
|
| 845 |
if len(names) != 1:
|
| 846 |
raise ValueError(f"one timing class per sequence, got {list(names)}")
|
| 847 |
t = self.shapes[str(names[0])]
|
| 848 |
dirs = (meas.dirs[rows] @ np.asarray(pose).T).tolist(); b = meas.bvals[rows] * 1e6
|
| 849 |
if t.get("kind", "pgse") == "pgste":
|
| 850 |
-
return
|
| 851 |
-
|
| 852 |
-
return
|
| 853 |
|
| 854 |
def _b0_sequence(self, name, pose, slew_rate=np.inf):
|
| 855 |
"""The class ``name``'s b = 0 measurement as a sequence at ``pose`` (:meth:`sequence`): what the b = 0 signal
|
|
@@ -895,10 +976,10 @@ class Brain(P.Source):
|
|
| 895 |
@functools.cached_property
|
| 896 |
def bore_grid(self):
|
| 897 |
"""``(grid, index)``: the brain's voxels as they sit in the bore, the head centre (the mask's centroid) at
|
| 898 |
-
isocentre: :meth:`
|
| 899 |
is :attr:`Asset.R` (the frame the page composes in), and ``index`` the brain voxels' own indices, each at
|
| 900 |
``affine @ [ijk, 1]`` in the scanner for a left- or right-handed affine."""
|
| 901 |
-
from
|
| 902 |
A = np.asarray(self.affine, np.float64)
|
| 903 |
centre = (A[:3, :3] @ self.vox.mean(0) + A[:3, 3]) * 1e-3
|
| 904 |
grid, R = Grid.from_oblique_affine(A, self.mask.shape, isocenter_m=centre)
|
|
@@ -908,9 +989,9 @@ class Brain(P.Source):
|
|
| 908 |
|
| 909 |
def encoding(self, meas, physics, rows):
|
| 910 |
"""``(class_of_voxel, played)``: the timing class ``rows`` of ``meas`` as the run's machine plays it at every brain
|
| 911 |
-
voxel, binned to ``[scanners] tolerance`` of b (:func:`
|
| 912 |
:attr:`bore_grid`), the played acquisitions in the image frame; computed once per machine, slew and rows."""
|
| 913 |
-
from
|
| 914 |
import hashlib
|
| 915 |
h = hashlib.sha256()
|
| 916 |
for x in (meas.bvals[rows], meas.dirs[rows], meas.shape[rows].astype(str)):
|
|
@@ -943,7 +1024,7 @@ class Brain(P.Source):
|
|
| 943 |
or ``cold`` (a band to compile first); ``saves`` the WM pack's saves the entry's classes span
|
| 944 |
(:meth:`saves_spanned` of :meth:`windows_needed`). ``[budget]`` prices them (``response_<state>``); on a machine
|
| 945 |
an entry counts its rows once per encoding class times ``response_per_class`` (a class's rows each play their own
|
| 946 |
-
amplitude, so the shells
|
| 947 |
out = []; keys = set(); bands = set(self._compiled)
|
| 948 |
for meas, ph in entries:
|
| 949 |
key = self.response_key(meas, ph)
|
|
@@ -961,19 +1042,19 @@ class Brain(P.Source):
|
|
| 961 |
|
| 962 |
def prepare(self, meas, physics=None):
|
| 963 |
"""The pose responses of a replay, on the CPU: per timing class of ``meas``, the WM and GM packs' pose
|
| 964 |
-
responses at the run's tissue, field and specimen pose (dmipy-
|
| 965 |
harmonics, free water's closed form; the true response curves for the explorer and the b = 0 weight of each
|
| 966 |
tier alone (both at the first class's shells, within the windows ``meas`` reaches). Loads the windows of the
|
| 967 |
chosen packs the classes reach (:meth:`windows_needed`, :meth:`pack`); computed every call and kept in this
|
| 968 |
process's cache under :meth:`response_key` (:meth:`cached` reads it)."""
|
| 969 |
-
from
|
| 970 |
-
from
|
| 971 |
t0 = time.perf_counter(); stages = {}
|
| 972 |
wm_pack = self.pack("wm", physics.wm_pack, self.windows_needed("wm", physics.wm_pack, meas))
|
| 973 |
stages["load_wm"] = time.perf_counter() - t0
|
| 974 |
gm_pack = self.pack("gm", physics.gm_pack, self.windows_needed("gm", physics.gm_pack, meas))
|
| 975 |
stages["load_gm"] = time.perf_counter() - t0 - stages["load_wm"]
|
| 976 |
-
for name in np.unique(meas.shape): # the walks and the WM pack's tiers,
|
| 977 |
self.validate(P.Protocol((P.Shell(str(name), 1.0, 1),), name="check"), physics)
|
| 978 |
t_wm, t_gm, t_csf = physics.tissues(wm_pack, gm_pack)
|
| 979 |
pose = specimen_pose(self.asset.R, physics.b0_direction)
|
|
@@ -983,7 +1064,7 @@ class Brain(P.Source):
|
|
| 983 |
# per timing class, the acquisitions the voxels are played: the commanded one, or one per encoding class of the
|
| 984 |
# machine (its delivered gradient, in the image frame, turned to the specimen's pose)
|
| 985 |
plan, seqs = [], []
|
| 986 |
-
from
|
| 987 |
for rows in classes:
|
| 988 |
if physics.scanner is None:
|
| 989 |
cls, played = np.zeros(len(self.vox), int), [self.sequence(meas, rows, pose, physics.slew_rate)]
|
|
@@ -994,9 +1075,9 @@ class Brain(P.Source):
|
|
| 994 |
prof = self._profile_sequence(meas, classes[0], pose, physics.slew_rate) # the explorer's curves ride in the same pass
|
| 995 |
batch = seqs + ([prof[0]] if prof is not None else [])
|
| 996 |
t = time.perf_counter()
|
| 997 |
-
r_wm = wm_pack.pose_responses(batch, tissue=t_wm, scanner=physics.scanner_field, pose=pose, keep=(LMAX, 0))
|
| 998 |
stages["pose_wm"] = time.perf_counter() - t; t = time.perf_counter()
|
| 999 |
-
r_gm = gm_pack.pose_responses(batch, tissue=t_gm, scanner=physics.scanner_field, pose=pose, keep=(0, 0))
|
| 1000 |
stages["pose_gm"] = time.perf_counter() - t
|
| 1001 |
free = FreeWater(m0=1.0, tissue=t_csf)
|
| 1002 |
K = len(seqs)
|
|
@@ -1044,7 +1125,7 @@ class Brain(P.Source):
|
|
| 1044 |
"""The b = 0 signal per tissue with each tier alone, over bare: ``relaxation`` (T2 at the echo, T1 over a
|
| 1045 |
stimulated echo's storage) per tissue, ``contact`` (the WM walls' survival) for WM; a tier that is off is
|
| 1046 |
absent. The b = 0 signal has no orientation, so one pose of the pack answers it."""
|
| 1047 |
-
from
|
| 1048 |
seq = self._b0_sequence(str(meas.shape[rows[0]]), pose, physics.slew_rate)
|
| 1049 |
out = {}
|
| 1050 |
alone = dict(relaxation=False, contact=False, field=False)
|
|
@@ -1148,7 +1229,7 @@ class Brain(P.Source):
|
|
| 1148 |
def tracking_inputs(self, sh, density):
|
| 1149 |
"""``(field, seeds)``: the FOD turned from the image frame into the scanner frame on the asset's affine, the
|
| 1150 |
domain the white matter and the regions (leaving it ends a streamline), ``density^3`` seeds per WM voxel."""
|
| 1151 |
-
from
|
| 1152 |
from dmipy_tract import FODField, seeds_from_mask
|
| 1153 |
a = self.asset
|
| 1154 |
world = np.zeros(np.shape(sh), np.float32)
|
|
@@ -1234,7 +1315,7 @@ class Brain(P.Source):
|
|
| 1234 |
["regions", f"{self.regions.n} ({len(self.regions.groups)} lobar groups)"],
|
| 1235 |
["the scan's timing class", self.shapes[SCAN]["label"]],
|
| 1236 |
*[list(n) for n in self.asset.notes],
|
| 1237 |
-
["replay", "exact composition of the packs' pose responses (dmipy-
|
| 1238 |
["per-voxel split-half floor", "none: every voxel reads the same two walks; the packs' certified floors below are the accuracy"]]
|
| 1239 |
for (tissue, label), pk in self.packs.items():
|
| 1240 |
seg = self.segments(tissue, label); held = self.windows_held(pk, seg)
|
|
@@ -1345,7 +1426,7 @@ def _warm_worker(cfg, where, entries, queue):
|
|
| 1345 |
"""The warm-up's process (:meth:`Brain.warm_in_background`): its own :class:`Brain` on the same configuration
|
| 1346 |
and asset, every ``(meas, physics)`` of ``entries`` computed on the CPU and put on ``queue`` as
|
| 1347 |
``(response_key, Kernels)``, then ``None``. One line per entry on stdout (the Space's run log) with its index,
|
| 1348 |
-
the seconds it took, :attr:`Kernels.stages`, the pose expansion's phases (dmipy-
|
| 1349 |
expansion joins), the process's user and system CPU seconds and page faults over the entry (a system-bound entry
|
| 1350 |
is paying the kernel, for pages or I/O, not computing) and the seconds since the start."""
|
| 1351 |
os.environ.setdefault("JAX_PLATFORMS", "cpu")
|
|
@@ -1357,11 +1438,11 @@ def _warm_worker(cfg, where, entries, queue):
|
|
| 1357 |
try:
|
| 1358 |
src = Brain(cfg, asset=where)
|
| 1359 |
print(f"warm-up: the source loaded, RSS {_rss_gb():.1f} GB ({time.perf_counter() - t0:.0f} s since the start)", flush=True)
|
| 1360 |
-
from
|
| 1361 |
import resource
|
| 1362 |
for i, (meas, ph) in enumerate(entries, 1):
|
| 1363 |
t = time.perf_counter(); r0 = resource.getrusage(resource.RUSAGE_SELF)
|
| 1364 |
-
with
|
| 1365 |
kernels = src.prepare(meas, ph)
|
| 1366 |
r1 = resource.getrusage(resource.RUSAGE_SELF)
|
| 1367 |
phases = ", ".join(f"{n} {v:.1f}" for n, v in run.phase_seconds().items())
|
|
@@ -1441,9 +1522,9 @@ def _floor(pack):
|
|
| 1441 |
@functools.lru_cache(maxsize=1)
|
| 1442 |
def axis_map():
|
| 1443 |
"""``(n_so3(8, 0), 45)``: a compact even-order density's harmonics to its SO(3) coefficients
|
| 1444 |
-
(:func:`
|
| 1445 |
response contracted to the FOD's harmonics."""
|
| 1446 |
-
from
|
| 1447 |
return np.stack([so3.axis_density_coeffs(e, LMAX, 0) for e in np.eye(N_COEF)], 1)
|
| 1448 |
|
| 1449 |
|
|
|
|
| 4 |
The input is an **asset** (the directory layout of ``SubstrateCommons/masivar-brain``: ``manifest.json``,
|
| 5 |
``fod_wm.npy``, ``fractions.npy``, ``mask.npy``, ``labels.npy``, ``regions.json``, ``stop_mask.npy``,
|
| 6 |
``mean_b0.npy``): the multi-tissue CSD of a real scan, made once offline, with its parcellation. The replay is the
|
| 7 |
+
kernel route of dmipy-replay's phantom (RPH.md 6): per voxel and measurement
|
| 8 |
|
| 9 |
S = | f_wm m0_wm FOD . C_wm + f_gm m0_gm E_gm + f_csf m0_csf E_csf |
|
| 10 |
|
| 11 |
where ``C_wm`` is the WM pack's pose response contracted to the FOD's harmonics
|
| 12 |
+
(:meth:`dmipy_replay.ReplayPack.pose_responses` at ``keep=(8, 0)`` through :func:`dmipy_replay.replay.so3.axis_density_coeffs`),
|
| 13 |
``E_gm`` the GM pack's response to an isotropic orientation distribution, ``E_csf`` free water in closed form
|
| 14 |
+
(:class:`dmipy_replay.phantom.FreeWater`), each at the run's tissue (T2 / T1 per pool at the echo, the WM walls' surface
|
| 15 |
relaxivity, the myelin sheath's susceptibility at the scanner's field and its direction), and ``m0`` the proton
|
| 16 |
density per tissue. The pose responses (:meth:`Brain.prepare`: seconds on the CPU) are cached in the page's process per
|
| 17 |
pack, protocol, tissue and field; a run looks them up on the page and computes the missing ones inside the GPU call,
|
|
|
|
| 46 |
REACH_MM = 250.0 # the longest streamline the tracker must be able to reach
|
| 47 |
PROFILE_ANGLES = np.linspace(0.0, 90.0, 19) # degrees between the gradient and a fibre, for the response plot
|
| 48 |
C00 = 1.0 / (2.0 * np.sqrt(np.pi)) # the l = 0 coefficient of a unit-integral density
|
| 49 |
+
POSE_BACKEND = "jax" # where the pose expansion's walker work runs: JAX on the CPU, which the [budget] is measured on
|
| 50 |
#: why a magnet with its own gradient (a field law: the Swoop) is not on the brain's scanner menu, as the page says it
|
| 51 |
FIELD_LAW_REFUSAL = ("Its own gradient (the permanent magnet's field law, 1.4 mT/m at 8 cm) is on through every pulse and dead "
|
| 52 |
+
"time, so every voxel plays a two-direction waveform of its own. dmipy-replay's closed form expands that "
|
| 53 |
"waveform exactly; the cost keeps it off this menu. Binned to the menu's 1 % of b, this head needs 9,752 "
|
| 54 |
"encoding classes for the scan's protocol (665 at 3 %), and one class's pose responses take 2-3 minutes "
|
| 55 |
"(WM 104-166 s, GM 4-21 s at 485 measurements on a GH200), so one protocol at one tissue and field is about "
|
| 56 |
"two weeks of one device. The DiSCo Space plays the Swoop exactly.")
|
| 57 |
|
| 58 |
|
| 59 |
+
#: what the page says when the brain cannot be composed from the inputs its configuration names
|
| 60 |
+
UNAVAILABLE = ("**The brain page is not available: its packs are being re-walked for dmipy 3.0.** It composes a brain from one "
|
| 61 |
+
"white-matter pack and one grey-matter pack and refuses to run until every input its configuration names is one "
|
| 62 |
+
"this release serves. What is absent or refused now:")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
# ---- the asset ---------------------------------------------------------------------------------------------------------
|
| 66 |
|
| 67 |
def asset_location(cfg):
|
|
|
|
| 101 |
def declared_segments(uri):
|
| 102 |
"""The segment table ``{n, n_t, T}`` (RPK.md 4.3) a pack declares, read without its arrays: a Hub pack's
|
| 103 |
record in its dataset's ``manifest.json`` (``hf://owner/name/path``), a local pack's header."""
|
| 104 |
+
from dmipy_replay.replay.hub import MANIFEST, is_hub_uri, parse_uri
|
| 105 |
+
from dmipy_sim.replay.publish import header_of
|
| 106 |
if is_hub_uri(uri):
|
| 107 |
from huggingface_hub import hf_hub_download
|
| 108 |
repo, path = parse_uri(uri)
|
|
|
|
| 119 |
return dict(n=int(seg["n"]), n_t=int(seg["n_t"]), T=float(seg["T"]))
|
| 120 |
|
| 121 |
|
| 122 |
+
def _is_404(e):
|
| 123 |
+
"""Whether ``e`` is an HTTP client error whose response says 404 -- the Hub's answer that a repository, a revision or
|
| 124 |
+
a file is not there (``urllib`` errors carry ``code``, the Hub client's carry ``response``)."""
|
| 125 |
+
return getattr(e, "code", None) == 404 or getattr(getattr(e, "response", None), "status_code", None) == 404
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def asset_refusal(where):
|
| 129 |
+
"""Why the asset at ``where`` cannot be read, or None when its manifest reads: a Hub revision (or dataset) the Hub does
|
| 130 |
+
not have, or a directory without ``manifest.json``. An error that says nothing about the asset itself (the Hub
|
| 131 |
+
unreachable) is raised, not named."""
|
| 132 |
+
hub = where.startswith("hf://")
|
| 133 |
+
try:
|
| 134 |
+
manifest(where)
|
| 135 |
+
except Exception as e:
|
| 136 |
+
if hub and _is_404(e):
|
| 137 |
+
repo, _, rev = where[len("hf://"):].partition("@")
|
| 138 |
+
return f"the Hub has no manifest.json of {repo} at revision {rev or 'main'}"
|
| 139 |
+
if not hub and isinstance(e, FileNotFoundError):
|
| 140 |
+
return f"{where} has no manifest.json"
|
| 141 |
+
raise
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def pack_refusal(uri):
|
| 146 |
+
"""Why this page cannot serve the pack at ``uri``, or None: the pack is absent from the Hub or from the disk, the reader
|
| 147 |
+
refuses its format (the reader's own message names what it does not read), or its window record is not the segment
|
| 148 |
+
table this page reads (:func:`declared_segments`). A Hub pack is read for its header and its dataset's record only;
|
| 149 |
+
an error that says nothing about the pack (the Hub unreachable) is raised, not named."""
|
| 150 |
+
from dmipy_replay import ReplayPack
|
| 151 |
+
from dmipy_replay.replay.hub import is_hub_uri
|
| 152 |
+
if is_hub_uri(uri):
|
| 153 |
+
try:
|
| 154 |
+
ReplayPack.open(uri)
|
| 155 |
+
except ValueError as e:
|
| 156 |
+
return f"the reader refuses it: {e}"
|
| 157 |
+
except Exception as e:
|
| 158 |
+
if _is_404(e):
|
| 159 |
+
return "it is not in its dataset on the Hub"
|
| 160 |
+
raise
|
| 161 |
+
elif not os.path.exists(uri):
|
| 162 |
+
return "there is no such file"
|
| 163 |
+
try:
|
| 164 |
+
declared_segments(uri)
|
| 165 |
+
except ValueError as e:
|
| 166 |
+
return f"this page cannot read its window record: {e}"
|
| 167 |
+
except Exception as e:
|
| 168 |
+
if _is_404(e):
|
| 169 |
+
return "its dataset has no manifest.json to read the window record from"
|
| 170 |
+
raise
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
|
| 174 |
def image_rotation(man):
|
| 175 |
+
"""``R`` (image -> scanner) of the asset's grid (:meth:`dmipy_replay.phantom.Grid.from_oblique_affine`)."""
|
| 176 |
+
from dmipy_replay.phantom import Grid
|
| 177 |
_, R = Grid.from_oblique_affine(np.asarray(man["grid"]["affine"], np.float64), tuple(man["grid"]["shape"]))
|
| 178 |
return np.asarray(R, np.float64)
|
| 179 |
|
|
|
|
| 229 |
mask = np.load(os.path.join(d, "mask.npy")).astype(bool)
|
| 230 |
if fod.shape != shape + (N_COEF,) or mask.shape != shape:
|
| 231 |
raise ValueError(f"the asset's FOD {fod.shape} and mask {mask.shape} are not on its grid {shape} with {N_COEF} coefficients")
|
| 232 |
+
from dmipy_replay.phantom.orientation import SH_BASES
|
| 233 |
basis = str(man["fod"]["basis"]).split()[0]
|
| 234 |
if SH_BASES.get(basis) != ("tournier07", False) or int(man["fod"]["lmax"]) != LMAX or man["fod"]["frame"] != "image":
|
| 235 |
+
raise ValueError(f"the asset's FOD is {man['fod']}: this source reads dmipy-replay's required basis (a basis whose "
|
| 236 |
+
f"name, the first word, dmipy_replay.phantom.orientation.SH_BASES maps to orthonormal tournier07: "
|
| 237 |
f"tournier07, mrtrix3, dmipy-fit) at lmax {LMAX} in the image frame")
|
| 238 |
if list(man["fractions"]) != list(TISSUES):
|
| 239 |
raise ValueError(f"the asset's fractions are {man['fractions']}, this source reads {list(TISSUES)}")
|
|
|
|
| 291 |
``chi_iso`` / ``chi_aniso`` the myelin sheath's susceptibility (SI, the WM pack's field source), ``D_csf`` free
|
| 292 |
water's diffusivity (m^2/s), ``m0`` the proton density per tissue relative to CSF, ``wm_pack`` / ``gm_pack`` the
|
| 293 |
packs by their configuration label; ``relaxation``, ``contact`` and ``field`` switch the three tiers; ``scanner``
|
| 294 |
+
the machine (a key of dmipy-replay's catalogue): its slew rate shapes every pulse's ramps, its field and direction are
|
| 295 |
the physics', and every voxel is played the gradient it delivers there with the head centre at isocentre
|
| 296 |
(:meth:`Brain.prepare`); None is the ideal scanner, ideal pulses with vertical ramps. All off is bare diffusion at
|
| 297 |
the same proton densities and packs."""
|
|
|
|
| 344 |
(vertical ramps) on the ideal scanner."""
|
| 345 |
if self.scanner is None:
|
| 346 |
return np.inf
|
| 347 |
+
from dmipy_replay.acquisition.scanners import ScannerLimits
|
| 348 |
return float(ScannerLimits.of(self.scanner).slew_max)
|
| 349 |
|
| 350 |
def label(self):
|
|
|
|
| 356 |
return f"{self.field_T:g} T along ({u[0]:.2f}, {u[1]:.2f}, {u[2]:.2f}), tiers {tiers}{grad}"
|
| 357 |
|
| 358 |
def tissues(self, wm_pack, gm_pack):
|
| 359 |
+
"""``(wm, gm, csf)`` :class:`dmipy_replay.spec.tissue.Tissue` for the packs ``wm_pack`` and ``gm_pack``: the WM pack's
|
| 360 |
pools with their T2 / T1, the walls' rho2 and, on a WM pack whose spec declares its susceptibility field
|
| 361 |
present (a magnetic myelin pool), the sheath's susceptibility; the GM pack's pools at grey matter's T2 / T1;
|
| 362 |
free water's D, T2 and T1. A tier off leaves its values out (``None`` for a pack with nothing on: its bare
|
| 363 |
diffusion). A WM pack whose field is absent has no sheath, so its tissue carries no susceptibility and its
|
| 364 |
field tier is zero at any field."""
|
| 365 |
+
from dmipy_replay.spec.tissue import Tissue
|
| 366 |
wm_pools, gm_pools = Brain.pools(wm_pack), Brain.pools(gm_pack)
|
| 367 |
relax, contact = self.relaxation, self.contact
|
| 368 |
field = self.field and wm_pack.susceptibility_field == "present"
|
|
|
|
| 395 |
image -> scanner rotation and ``W`` the rotation taking the field's scanner-frame direction onto z (the bore's
|
| 396 |
field axis), so the packs see the field at ``R.T b0_direction`` in the image frame. A sequence composed at this
|
| 397 |
pose carries the gradients ``pose @ g`` (:meth:`Brain.sequence`)."""
|
| 398 |
+
from dmipy_replay.replay.so3 import rotation_of
|
| 399 |
return rotation_of(np.asarray(b0_direction, np.float64)).T @ np.asarray(R, np.float64)
|
| 400 |
|
| 401 |
|
|
|
|
| 563 |
out[m["label"]] = FIELD_LAW_REFUSAL
|
| 564 |
return out
|
| 565 |
|
| 566 |
+
@classmethod
|
| 567 |
+
def unavailable(cls, cfg):
|
| 568 |
+
"""``(title, text)`` when the brain cannot be composed from the inputs the configuration names -- the asset's
|
| 569 |
+
manifest, or a pack of either menu, is absent, refused by the reader or not readable by this page
|
| 570 |
+
(:func:`asset_refusal`, :func:`pack_refusal`) -- the text naming each input and why; None when every input reads.
|
| 571 |
+
The Space serves this text as its page instead of failing to start."""
|
| 572 |
+
found = []
|
| 573 |
+
where = asset_location(cfg)
|
| 574 |
+
why = asset_refusal(where)
|
| 575 |
+
if why:
|
| 576 |
+
found.append(f"- the asset `{where}`: {why}")
|
| 577 |
+
for tissue in ("wm", "gm"):
|
| 578 |
+
for p in cfg["packs"][tissue]:
|
| 579 |
+
why = pack_refusal(p["uri"])
|
| 580 |
+
if why:
|
| 581 |
+
found.append(f"- the {tissue.upper()} pack \"{p['label']}\" `{p['uri']}`: {why}")
|
| 582 |
+
if not found:
|
| 583 |
+
return None
|
| 584 |
+
title = cfg["describe"]["title"]
|
| 585 |
+
return title, "\n\n".join([f"# {title}", UNAVAILABLE, "\n".join(found)])
|
| 586 |
+
|
| 587 |
@classmethod
|
| 588 |
def pack_menu(cls, cfg, tissue):
|
| 589 |
"""The configuration's packs for ``tissue`` (``wm`` / ``gm``): ``{label: uri}`` in the menu's order."""
|
|
|
|
| 601 |
"""``{class: k}``: per timing class of ``meas``, the number of the pack's windows its acquisition reaches.
|
| 602 |
``segments`` is a declared table ``{n, n_t, T}`` (:func:`declared_segments`) or a pack's meta (its
|
| 603 |
``walk_params.segments``). The acquisition lasts ``T_acq = (G.shape[1] - 1) dt`` of the class's sequence,
|
| 604 |
+
as :meth:`dmipy_replay.ReplayPack._span` reads it, and window ``i`` starts at ``i T``; a window whose
|
| 605 |
first save sits at or beyond ``T_acq`` is not reached, so the save at a window's end belongs to that window
|
| 606 |
alone. A pack of one window (``n = 1``) is reached as one window whatever the class; ``k``
|
| 607 |
above ``n`` is an acquisition beyond the walk (:meth:`windows_needed` refuses it)."""
|
|
|
|
| 635 |
|
| 636 |
def pack(self, tissue, label, k):
|
| 637 |
"""The pack ``label`` of ``tissue``'s menu holding at least its first ``k`` windows: a Hub URI whose
|
| 638 |
+
declared table has ``n > 1`` loads ``windows=range(k)`` (:class:`dmipy_replay.phantom.PackSubstrate`; the pack
|
| 639 |
records them as ``windows_present``); a local path or a one-window pack loads whole. A later request for
|
| 640 |
more windows than it holds reloads with the larger range (the library fetches only the new ranges) and
|
| 641 |
replaces the cached pack."""
|
| 642 |
+
from dmipy_replay.phantom import PackSubstrate
|
| 643 |
+
from dmipy_replay.replay.hub import is_hub_uri
|
| 644 |
key = (tissue, label)
|
| 645 |
seg = self.segments(tissue, label)
|
| 646 |
if key in self.packs and self.windows_held(self.packs[key], seg) >= int(k):
|
|
|
|
| 721 |
"the head's own susceptibility and RF effects are not modelled",
|
| 722 |
"the head centre at the scanner's isocentre (a machine of the scanner menu plays every voxel the gradient it delivers there; docs/scanner.md)",
|
| 723 |
"no per-voxel split-half floor: the packs' certified floors are the accuracy"],
|
| 724 |
+
accuracy=("A brain replay is exact composition (the tests check it against dmipy-replay's own `Phantom.compose(...).replay`) of "
|
| 725 |
"the packs' responses, so its accuracy is the packs' certified Monte-Carlo floors; there is no per-voxel split-half "
|
| 726 |
"floor, because every voxel reads the same two walks."),
|
| 727 |
ingredients=("proton density: Σ f·M0 (relative to CSF)", "relaxation: the b = 0 signal's T2 / T1 weight at the echo",
|
|
|
|
| 921 |
the image-frame directions turned by ``pose``, trapezoid pulses at the class's delta / Delta / TE with ramps at
|
| 922 |
``slew_rate`` (vertical at the default, infinite), a stimulated echo storing for TM = Delta - delta when the
|
| 923 |
class is one."""
|
| 924 |
+
from dmipy_replay.sequences import pgse, pgste
|
| 925 |
names = np.unique(meas.shape[rows])
|
| 926 |
if len(names) != 1:
|
| 927 |
raise ValueError(f"one timing class per sequence, got {list(names)}")
|
| 928 |
t = self.shapes[str(names[0])]
|
| 929 |
dirs = (meas.dirs[rows] @ np.asarray(pose).T).tolist(); b = meas.bvals[rows] * 1e6
|
| 930 |
if t.get("kind", "pgse") == "pgste":
|
| 931 |
+
return pgste(dirs, float(t["delta"]), float(t["Delta"]) - float(t["delta"]), bvalues=b, TE=float(t["TE"]), n_t=1000,
|
| 932 |
+
slew_rate=float(slew_rate), ste_flip_angles=(90.0, 90.0, 90.0))
|
| 933 |
+
return pgse(dirs, float(t["delta"]), float(t["Delta"]), bvalues=b, TE=float(t["TE"]), n_t=1000, slew_rate=float(slew_rate))
|
| 934 |
|
| 935 |
def _b0_sequence(self, name, pose, slew_rate=np.inf):
|
| 936 |
"""The class ``name``'s b = 0 measurement as a sequence at ``pose`` (:meth:`sequence`): what the b = 0 signal
|
|
|
|
| 976 |
@functools.cached_property
|
| 977 |
def bore_grid(self):
|
| 978 |
"""``(grid, index)``: the brain's voxels as they sit in the bore, the head centre (the mask's centroid) at
|
| 979 |
+
isocentre: :meth:`dmipy_replay.phantom.Grid.from_oblique_affine` of the cropped asset's affine, whose rotation
|
| 980 |
is :attr:`Asset.R` (the frame the page composes in), and ``index`` the brain voxels' own indices, each at
|
| 981 |
``affine @ [ijk, 1]`` in the scanner for a left- or right-handed affine."""
|
| 982 |
+
from dmipy_replay.phantom import Grid
|
| 983 |
A = np.asarray(self.affine, np.float64)
|
| 984 |
centre = (A[:3, :3] @ self.vox.mean(0) + A[:3, 3]) * 1e-3
|
| 985 |
grid, R = Grid.from_oblique_affine(A, self.mask.shape, isocenter_m=centre)
|
|
|
|
| 989 |
|
| 990 |
def encoding(self, meas, physics, rows):
|
| 991 |
"""``(class_of_voxel, played)``: the timing class ``rows`` of ``meas`` as the run's machine plays it at every brain
|
| 992 |
+
voxel, binned to ``[scanners] tolerance`` of b (:func:`dmipy_replay.phantom.bore.encoding_classes` on
|
| 993 |
:attr:`bore_grid`), the played acquisitions in the image frame; computed once per machine, slew and rows."""
|
| 994 |
+
from dmipy_replay.phantom.bore import encoding_classes
|
| 995 |
import hashlib
|
| 996 |
h = hashlib.sha256()
|
| 997 |
for x in (meas.bvals[rows], meas.dirs[rows], meas.shape[rows].astype(str)):
|
|
|
|
| 1024 |
or ``cold`` (a band to compile first); ``saves`` the WM pack's saves the entry's classes span
|
| 1025 |
(:meth:`saves_spanned` of :meth:`windows_needed`). ``[budget]`` prices them (``response_<state>``); on a machine
|
| 1026 |
an entry counts its rows once per encoding class times ``response_per_class`` (a class's rows each play their own
|
| 1027 |
+
amplitude, so the shells do not share the expansion's bodies)."""
|
| 1028 |
out = []; keys = set(); bands = set(self._compiled)
|
| 1029 |
for meas, ph in entries:
|
| 1030 |
key = self.response_key(meas, ph)
|
|
|
|
| 1042 |
|
| 1043 |
def prepare(self, meas, physics=None):
|
| 1044 |
"""The pose responses of a replay, on the CPU: per timing class of ``meas``, the WM and GM packs' pose
|
| 1045 |
+
responses at the run's tissue, field and specimen pose (dmipy-replay's closed form) contracted to the FOD's
|
| 1046 |
harmonics, free water's closed form; the true response curves for the explorer and the b = 0 weight of each
|
| 1047 |
tier alone (both at the first class's shells, within the windows ``meas`` reaches). Loads the windows of the
|
| 1048 |
chosen packs the classes reach (:meth:`windows_needed`, :meth:`pack`); computed every call and kept in this
|
| 1049 |
process's cache under :meth:`response_key` (:meth:`cached` reads it)."""
|
| 1050 |
+
from dmipy_replay.phantom import FreeWater
|
| 1051 |
+
from dmipy_replay.replay import so3
|
| 1052 |
t0 = time.perf_counter(); stages = {}
|
| 1053 |
wm_pack = self.pack("wm", physics.wm_pack, self.windows_needed("wm", physics.wm_pack, meas))
|
| 1054 |
stages["load_wm"] = time.perf_counter() - t0
|
| 1055 |
gm_pack = self.pack("gm", physics.gm_pack, self.windows_needed("gm", physics.gm_pack, meas))
|
| 1056 |
stages["load_gm"] = time.perf_counter() - t0 - stages["load_wm"]
|
| 1057 |
+
for name in np.unique(meas.shape): # the walks and the WM pack's tiers, loaded
|
| 1058 |
self.validate(P.Protocol((P.Shell(str(name), 1.0, 1),), name="check"), physics)
|
| 1059 |
t_wm, t_gm, t_csf = physics.tissues(wm_pack, gm_pack)
|
| 1060 |
pose = specimen_pose(self.asset.R, physics.b0_direction)
|
|
|
|
| 1064 |
# per timing class, the acquisitions the voxels are played: the commanded one, or one per encoding class of the
|
| 1065 |
# machine (its delivered gradient, in the image frame, turned to the specimen's pose)
|
| 1066 |
plan, seqs = [], []
|
| 1067 |
+
from dmipy_replay.acquisition.waveforms import rotate_waveform
|
| 1068 |
for rows in classes:
|
| 1069 |
if physics.scanner is None:
|
| 1070 |
cls, played = np.zeros(len(self.vox), int), [self.sequence(meas, rows, pose, physics.slew_rate)]
|
|
|
|
| 1075 |
prof = self._profile_sequence(meas, classes[0], pose, physics.slew_rate) # the explorer's curves ride in the same pass
|
| 1076 |
batch = seqs + ([prof[0]] if prof is not None else [])
|
| 1077 |
t = time.perf_counter()
|
| 1078 |
+
r_wm = wm_pack.pose_responses(batch, tissue=t_wm, scanner=physics.scanner_field, pose=pose, keep=(LMAX, 0), backend=POSE_BACKEND)
|
| 1079 |
stages["pose_wm"] = time.perf_counter() - t; t = time.perf_counter()
|
| 1080 |
+
r_gm = gm_pack.pose_responses(batch, tissue=t_gm, scanner=physics.scanner_field, pose=pose, keep=(0, 0), backend=POSE_BACKEND)
|
| 1081 |
stages["pose_gm"] = time.perf_counter() - t
|
| 1082 |
free = FreeWater(m0=1.0, tissue=t_csf)
|
| 1083 |
K = len(seqs)
|
|
|
|
| 1125 |
"""The b = 0 signal per tissue with each tier alone, over bare: ``relaxation`` (T2 at the echo, T1 over a
|
| 1126 |
stimulated echo's storage) per tissue, ``contact`` (the WM walls' survival) for WM; a tier that is off is
|
| 1127 |
absent. The b = 0 signal has no orientation, so one pose of the pack answers it."""
|
| 1128 |
+
from dmipy_replay.phantom import FreeWater
|
| 1129 |
seq = self._b0_sequence(str(meas.shape[rows[0]]), pose, physics.slew_rate)
|
| 1130 |
out = {}
|
| 1131 |
alone = dict(relaxation=False, contact=False, field=False)
|
|
|
|
| 1229 |
def tracking_inputs(self, sh, density):
|
| 1230 |
"""``(field, seeds)``: the FOD turned from the image frame into the scanner frame on the asset's affine, the
|
| 1231 |
domain the white matter and the regions (leaving it ends a streamline), ``density^3`` seeds per WM voxel."""
|
| 1232 |
+
from dmipy_replay.replay.so3 import rotate_sh
|
| 1233 |
from dmipy_tract import FODField, seeds_from_mask
|
| 1234 |
a = self.asset
|
| 1235 |
world = np.zeros(np.shape(sh), np.float32)
|
|
|
|
| 1315 |
["regions", f"{self.regions.n} ({len(self.regions.groups)} lobar groups)"],
|
| 1316 |
["the scan's timing class", self.shapes[SCAN]["label"]],
|
| 1317 |
*[list(n) for n in self.asset.notes],
|
| 1318 |
+
["replay", "exact composition of the packs' pose responses (dmipy-replay's phantom route), float32 on the device"],
|
| 1319 |
["per-voxel split-half floor", "none: every voxel reads the same two walks; the packs' certified floors below are the accuracy"]]
|
| 1320 |
for (tissue, label), pk in self.packs.items():
|
| 1321 |
seg = self.segments(tissue, label); held = self.windows_held(pk, seg)
|
|
|
|
| 1426 |
"""The warm-up's process (:meth:`Brain.warm_in_background`): its own :class:`Brain` on the same configuration
|
| 1427 |
and asset, every ``(meas, physics)`` of ``entries`` computed on the CPU and put on ``queue`` as
|
| 1428 |
``(response_key, Kernels)``, then ``None``. One line per entry on stdout (the Space's run log) with its index,
|
| 1429 |
+
the seconds it took, :attr:`Kernels.stages`, the pose expansion's phases (dmipy-replay's phase record, which the
|
| 1430 |
expansion joins), the process's user and system CPU seconds and page faults over the entry (a system-bound entry
|
| 1431 |
is paying the kernel, for pages or I/O, not computing) and the seconds since the start."""
|
| 1432 |
os.environ.setdefault("JAX_PLATFORMS", "cpu")
|
|
|
|
| 1438 |
try:
|
| 1439 |
src = Brain(cfg, asset=where)
|
| 1440 |
print(f"warm-up: the source loaded, RSS {_rss_gb():.1f} GB ({time.perf_counter() - t0:.0f} s since the start)", flush=True)
|
| 1441 |
+
from dmipy_replay.replay.phases import Phases
|
| 1442 |
import resource
|
| 1443 |
for i, (meas, ph) in enumerate(entries, 1):
|
| 1444 |
t = time.perf_counter(); r0 = resource.getrusage(resource.RUSAGE_SELF)
|
| 1445 |
+
with Phases("brain-warm", params=dict(entry=i)) as run: # the pose expansion's phases join this record
|
| 1446 |
kernels = src.prepare(meas, ph)
|
| 1447 |
r1 = resource.getrusage(resource.RUSAGE_SELF)
|
| 1448 |
phases = ", ".join(f"{n} {v:.1f}" for n, v in run.phase_seconds().items())
|
|
|
|
| 1522 |
@functools.lru_cache(maxsize=1)
|
| 1523 |
def axis_map():
|
| 1524 |
"""``(n_so3(8, 0), 45)``: a compact even-order density's harmonics to its SO(3) coefficients
|
| 1525 |
+
(:func:`dmipy_replay.replay.so3.axis_density_coeffs`, which is linear), so ``resp.retained(8, 0) @ axis_map()`` is the
|
| 1526 |
response contracted to the FOD's harmonics."""
|
| 1527 |
+
from dmipy_replay.replay import so3
|
| 1528 |
return np.stack([so3.axis_density_coeffs(e, LMAX, 0) for e in np.eye(N_COEF)], 1)
|
| 1529 |
|
| 1530 |
|
space/sources/disco.py
CHANGED
|
@@ -1,20 +1,22 @@
|
|
| 1 |
"""The DiSCo source: the DiSCo phantom's 40^3 grid replayed from its stored walk, tracked from the sixteen regions
|
| 2 |
and scored against the dataset's strand-count and cross-sectional-area matrices (CC BY 4.0, ``data/SOURCE.md``).
|
| 3 |
|
| 4 |
-
Two sources replay the same walk. :class:`Layout` (demo mode) is dmipy-
|
| 5 |
-
|
| 6 |
tissue and field is an elementwise kernel on device-resident rows, in seconds. :class:`Columns` (full mode) is the
|
| 7 |
columnar replay pack itself, read once per run for any pulse timing, in minutes. Both are a :class:`Disco`: the
|
| 8 |
page's texts, the tissue panel of the white-matter catalogue, the regions, the ground truth and the GPU budget.
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
| 12 |
import os
|
| 13 |
import time
|
| 14 |
|
| 15 |
import numpy as np
|
| 16 |
|
| 17 |
from .. import pipeline as P
|
|
|
|
| 18 |
|
| 19 |
N_REGIONS = 16
|
| 20 |
VOXEL_M = 25e-6 # DiSCo's voxel; the strands' coordinate unit
|
|
@@ -119,7 +121,8 @@ class Disco(P.Source):
|
|
| 119 |
truth_tab="2 · ground truth",
|
| 120 |
truth_labels=("the strands", "the ground-truth matrices"),
|
| 121 |
stages={"replay": "replaying the grid from the stored walk", "noise": "adding Rician noise", "csd": "fitting CSD (order 8)",
|
| 122 |
-
"track": "tracking from the sixteen regions", "score": "scoring the connectome"
|
|
|
|
| 123 |
fixed=["the DiSCo phantom's geometry and its one stored walk", "the sixteen regions and the ground-truth matrices",
|
| 124 |
"the pulse timing classes of the layout (demo mode)"],
|
| 125 |
accuracy=("A replay is a measured approximation of the stored walk, not a rendering: the walk's two halves are replayed "
|
|
@@ -222,7 +225,8 @@ class Disco(P.Source):
|
|
| 222 |
out[f"SNR → {v}"] = ("snr", float(v))
|
| 223 |
out["noise → off"] = ("snr", None)
|
| 224 |
for name, sh in cfg["shapes"].items():
|
| 225 |
-
|
|
|
|
| 226 |
return out
|
| 227 |
|
| 228 |
@classmethod
|
|
@@ -262,16 +266,21 @@ class Disco(P.Source):
|
|
| 262 |
|
| 263 |
@classmethod
|
| 264 |
def estimated_seconds(cls, cfg, protocol, *, density, knob, n_keys, ladder, responses=(), scanner=None):
|
| 265 |
-
"""Measured on the pool
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
|
|
|
|
|
|
| 272 |
n = protocol.n_meas
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
| 275 |
if scanner is not None:
|
| 276 |
secs += replays * float(cfg["scanners"]["delivery_per_meas"]) * n
|
| 277 |
return int(min(480, max(30, 1.3 * secs)))
|
|
@@ -299,7 +308,7 @@ class Disco(P.Source):
|
|
| 299 |
"""The ground truth tab: a sample of the strands in 3-D with the region markers, and the two matrices."""
|
| 300 |
from dmipy_sim.io.strands import read_tck, read_diameters
|
| 301 |
from .. import viewers as V
|
| 302 |
-
strands = read_tck(
|
| 303 |
diameters = read_diameters(os.path.join(P.DATA_DIR, "DiSCo_Strands_Diameters.txt"), diameter_unit_m=1e-3)
|
| 304 |
return (V.strands3d([s / VOXEL_M for s in strands], diameters, V.region_markers(self.regions), self.mask.shape),
|
| 305 |
V.ground_truth_matrix(self.gt_count, self.gt_area, P.connected_pairs(self.gt_count)))
|
|
@@ -343,6 +352,52 @@ class Disco(P.Source):
|
|
| 343 |
return [pool, contact, fld]
|
| 344 |
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
PAIRS = np.triu_indices(N_REGIONS, 1) # the 120 region pairs
|
| 347 |
|
| 348 |
|
|
@@ -363,48 +418,65 @@ def compare(a, b):
|
|
| 363 |
|
| 364 |
|
| 365 |
class Layout(Disco):
|
| 366 |
-
"""Demo mode: dmipy-
|
| 367 |
-
|
|
|
|
| 368 |
mode = "demo"
|
| 369 |
|
| 370 |
@classmethod
|
| 371 |
def load(cls, cfg, *, local=None):
|
| 372 |
-
"""The layout at ``local``, ``
|
| 373 |
-
return cls(cfg, local=local or os.environ.get("
|
| 374 |
|
| 375 |
def __init__(self, cfg, *, local=None):
|
| 376 |
-
|
|
|
|
|
|
|
| 377 |
super().__init__(cfg)
|
| 378 |
d = cfg["data"]
|
| 379 |
-
uri = local or f"hf://{d['repo']}/{d['
|
| 380 |
-
|
|
|
|
| 381 |
self.resident = P.resident(cfg)
|
| 382 |
-
want = d.get("
|
| 383 |
-
|
|
|
|
| 384 |
if want and got != want:
|
| 385 |
-
raise ValueError(f"the layout at {uri}
|
| 386 |
-
|
|
|
|
| 387 |
if missing:
|
| 388 |
-
raise ValueError(f"the layout at {uri} lacks the timing classes {missing}; it holds {self.
|
| 389 |
-
for name,
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
self.tiers
|
| 397 |
-
|
| 398 |
-
if self.tiers: # the pools with walkers are the spec's first n_pools ids
|
| 399 |
-
t = self.moments.manifest["tiers"]
|
| 400 |
spec_pools = sorted(t["substrate"]["pools"], key=lambda q: q["id"]) if t.get("substrate") else []
|
| 401 |
-
|
|
|
|
| 402 |
if set(seeded) != set(self.pools):
|
| 403 |
raise ValueError(f"the layout's seeded pools are {seeded}, the config's [physics] pools {self.pools}")
|
| 404 |
-
self.unseeded = tuple(q["name"] for q in spec_pools[
|
| 405 |
-
self.check_grid(self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
def validate(self, protocol, physics):
|
|
|
|
| 408 |
meas = P.measurements(protocol, self.shapes)
|
| 409 |
if physics and not physics.bare and not self.tiers:
|
| 410 |
raise ValueError("this layout holds bare diffusion only: switch the tissue panel off")
|
|
@@ -412,21 +484,35 @@ class Layout(Disco):
|
|
| 412 |
raise ValueError(f"the tissue names the pools {physics.pools}; this layout's are {self.pools}")
|
| 413 |
if physics and physics.scanner is not None:
|
| 414 |
L = P.limits(physics.scanner)
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
| 421 |
return meas
|
| 422 |
|
| 423 |
-
def
|
| 424 |
-
"""
|
| 425 |
-
``
|
| 426 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
if physics is None or physics.scanner is None:
|
| 428 |
return None
|
| 429 |
-
return self.
|
| 430 |
|
| 431 |
def replay(self, meas, physics=None, prepared=None):
|
| 432 |
t0 = time.perf_counter()
|
|
@@ -436,36 +522,34 @@ class Layout(Disco):
|
|
| 436 |
b0 = physics.b0_direction if physics else P.B0_ALONG_Z
|
| 437 |
for name in np.unique(meas.shape):
|
| 438 |
rows = meas.shape == name
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
self.M0 = self.moments.image(meas.shape[0], np.zeros(1), np.array([P.B0_ALONG_Z]), backend=self.backend, resident=True)[0][..., 0]
|
| 445 |
dwi, factor = P.s0_normalised(S, meas.b0, self.M0)
|
| 446 |
return dwi, floor, factor, time.perf_counter() - t0
|
| 447 |
|
| 448 |
def release(self):
|
| 449 |
if not self.resident:
|
| 450 |
-
self.
|
| 451 |
|
| 452 |
def ingredients(self, meas, physics=None, prepared=None):
|
| 453 |
"""What each tier multiplies into the sum, per voxel, for the run's first class (the layout's
|
| 454 |
-
:meth:`~
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
``
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
``D_walk`` (m^2/s)."""
|
| 462 |
if not self.tiers:
|
| 463 |
return None
|
| 464 |
tissue, scanner = P.tissue_and_scanner(physics, self.unseeded)
|
| 465 |
b0 = physics.b0_direction if physics else P.B0_ALONG_Z
|
| 466 |
-
maps = self.
|
| 467 |
contact = maps["contact"]
|
| 468 |
-
return dict(D_walk=float(self.
|
| 469 |
wall_contact_um=None if contact is None else -contact * 1e6,
|
| 470 |
contact_survival=maps["contact_weight"] if (physics and physics.contact) else None,
|
| 471 |
field_rad=maps["phase_std"] if (physics and physics.field) else None)
|
|
@@ -477,33 +561,41 @@ class Layout(Disco):
|
|
| 477 |
|
| 478 |
def warm(self):
|
| 479 |
"""DiSCo's own protocol replayed once at the default physics (every class it plays compiled at the row
|
| 480 |
-
counts the default run uses), then one direction on every other class (
|
| 481 |
-
|
| 482 |
-
padded host arrays are loaded into this process instead, so
|
| 483 |
-
transfer alone."""
|
| 484 |
-
if not self.resident:
|
| 485 |
-
self.
|
| 486 |
return
|
| 487 |
physics = self.default_physics()
|
| 488 |
disco = disco_protocol(self.cfg)[0]
|
| 489 |
self.replay(P.measurements(disco, self.shapes), physics)
|
| 490 |
-
|
|
|
|
| 491 |
if rest:
|
| 492 |
self.replay(P.measurements(P.Protocol(tuple(P.Shell(name, 1000.0, 1) for name in rest), n_b0=1, name="warm"), self.shapes), physics)
|
|
|
|
|
|
|
|
|
|
| 493 |
|
| 494 |
def accuracy(self, res=None):
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
["
|
| 501 |
-
["
|
| 502 |
-
["
|
| 503 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
["tiers stored", "pool, contact, field" if self.tiers else "none (bare diffusion only)"],
|
| 505 |
-
["background
|
| 506 |
-
if
|
| 507 |
if res is not None and res.physics is not None and res.physics.scanner is not None:
|
| 508 |
rows.append(["scanner", f"{res.physics.scanner}, the phantom's centre at "
|
| 509 |
f"({', '.join(f'{x * 100:.1f}' for x in res.physics.offset_m)}) cm from isocentre: every catalogued term "
|
|
@@ -511,22 +603,22 @@ class Layout(Disco):
|
|
| 511 |
rows += super().accuracy(res)
|
| 512 |
if res is not None:
|
| 513 |
for name in np.unique(res.meas.shape):
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
f"built at {float(sh.get('amplitude_built') or 0) * 1e3:.0f} mT/m, pathway amplitude {sh.get('pathway') or 1}"])
|
| 518 |
return rows
|
| 519 |
|
| 520 |
|
| 521 |
class Columns(Disco):
|
| 522 |
-
"""Full mode: the columnar replay pack (:class:`
|
| 523 |
-
:func:`space.pipeline.columns_uri`, read once per run: one ScannerSequence per run (one echo time, one
|
| 524 |
-
pulses), the tissue and field of the run, the field along
|
|
|
|
| 525 |
mode = "full"
|
| 526 |
RATE = {True: 45e6, False: 500e6} # bytes/s read from the Hub, from a local disk
|
| 527 |
|
| 528 |
def __init__(self, cfg, *, uri=None):
|
| 529 |
-
from
|
| 530 |
super().__init__(cfg)
|
| 531 |
self.uri = uri or P.columns_uri(cfg)
|
| 532 |
self.pack = ColumnarPack(self.uri, workers=int(cfg["mode"]["workers"]))
|
|
@@ -534,15 +626,17 @@ class Columns(Disco):
|
|
| 534 |
self.remote = self.uri.startswith("hf://")
|
| 535 |
|
| 536 |
def kinds(self, meas):
|
| 537 |
-
"""The pulse kinds (``pgse`` / ``pgste``) of the classes the rows play; a free timing is PGSE."""
|
| 538 |
-
|
|
|
|
| 539 |
|
| 540 |
def validate(self, protocol, physics):
|
| 541 |
meas = P.measurements(protocol, self.shapes)
|
| 542 |
if len(np.unique(np.round(meas.TE, 9))) != 1:
|
| 543 |
raise ValueError("full mode replays one echo time per run")
|
| 544 |
-
|
| 545 |
-
|
|
|
|
| 546 |
if physics and not physics.along_z:
|
| 547 |
raise ValueError("full mode replays the field along z only")
|
| 548 |
if physics and physics.scanner is not None:
|
|
@@ -551,15 +645,27 @@ class Columns(Disco):
|
|
| 551 |
return meas
|
| 552 |
|
| 553 |
def sequence(self, meas):
|
| 554 |
-
"""The one ScannerSequence of the measurements
|
| 555 |
-
|
| 556 |
-
|
|
|
|
|
|
|
|
|
|
| 557 |
TE = float(np.unique(np.round(meas.TE, 9))[0])
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
|
| 564 |
def plan(self, meas, physics=None):
|
| 565 |
"""What the replay will read (the pack's plan: bands, modes, tiers, rows, bytes) and ``estimated_seconds``
|
|
@@ -570,11 +676,11 @@ class Columns(Disco):
|
|
| 570 |
return plan
|
| 571 |
|
| 572 |
def replay(self, meas, physics=None, prepared=None, *, progress=None):
|
| 573 |
-
from
|
| 574 |
t0 = time.perf_counter()
|
| 575 |
tissue, scanner = P.tissue_and_scanner(physics, self.unseeded)
|
| 576 |
-
study = Study(
|
| 577 |
-
S, floor, plan = self.pack.image_study(study, tol=P.BAND_TOL, chunk_rows=1_000_000, progress=progress)
|
| 578 |
self.last_plan = plan
|
| 579 |
dwi, factor = P.s0_normalised(S[0], meas.b0) # the pack's bare weights are not read here: the factor is 1
|
| 580 |
return dwi, floor[0], factor, time.perf_counter() - t0
|
|
@@ -583,7 +689,7 @@ class Columns(Disco):
|
|
| 583 |
m = self.pack.meta
|
| 584 |
rows = [["source pack", str(m.get("id"))], ["source pack's certified median floor", f"{self.pack.floor:.4g}"],
|
| 585 |
["bands kept", str(self.pack.K)], ["band tolerance (× the pack's floor)", f"{P.BAND_TOL:g}"],
|
| 586 |
-
["pulses", "square (slew rate ∞)
|
| 587 |
plan = getattr(self, "last_plan", None)
|
| 588 |
if plan:
|
| 589 |
rows.append(["last replay read", f"{plan.get('rows', 0):,} rows, {plan.get('bytes', 0) / 1e9:.1f} GB, bands {plan.get('K')}, modes {plan.get('modes')}"])
|
|
|
|
| 1 |
"""The DiSCo source: the DiSCo phantom's 40^3 grid replayed from its stored walk, tracked from the sixteen regions
|
| 2 |
and scored against the dataset's strand-count and cross-sectional-area matrices (CC BY 4.0, ``data/SOURCE.md``).
|
| 3 |
|
| 4 |
+
Two sources replay the same walk. :class:`Layout` (demo mode) is dmipy-replay's time-segment layout: the DiSCo walk
|
| 5 |
+
contracted once per stored waveform ("class"), so a protocol on those classes at any b-values, directions,
|
| 6 |
tissue and field is an elementwise kernel on device-resident rows, in seconds. :class:`Columns` (full mode) is the
|
| 7 |
columnar replay pack itself, read once per run for any pulse timing, in minutes. Both are a :class:`Disco`: the
|
| 8 |
page's texts, the tissue panel of the white-matter catalogue, the regions, the ground truth and the GPU budget.
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import functools
|
| 13 |
import os
|
| 14 |
import time
|
| 15 |
|
| 16 |
import numpy as np
|
| 17 |
|
| 18 |
from .. import pipeline as P
|
| 19 |
+
from .. import tensor as T
|
| 20 |
|
| 21 |
N_REGIONS = 16
|
| 22 |
VOXEL_M = 25e-6 # DiSCo's voxel; the strands' coordinate unit
|
|
|
|
| 121 |
truth_tab="2 · ground truth",
|
| 122 |
truth_labels=("the strands", "the ground-truth matrices"),
|
| 123 |
stages={"replay": "replaying the grid from the stored walk", "noise": "adding Rician noise", "csd": "fitting CSD (order 8)",
|
| 124 |
+
"track": "tracking from the sixteen regions", "score": "scoring the connectome",
|
| 125 |
+
"maps": "fitting FA, MD and µFA (the b-tensor run's maps)"},
|
| 126 |
fixed=["the DiSCo phantom's geometry and its one stored walk", "the sixteen regions and the ground-truth matrices",
|
| 127 |
"the pulse timing classes of the layout (demo mode)"],
|
| 128 |
accuracy=("A replay is a measured approximation of the stored walk, not a rendering: the walk's two halves are replayed "
|
|
|
|
| 225 |
out[f"SNR → {v}"] = ("snr", float(v))
|
| 226 |
out["noise → off"] = ("snr", None)
|
| 227 |
for name, sh in cfg["shapes"].items():
|
| 228 |
+
if sh.get("kind") != "tensor": # the b-tensor class is its own acquisition (encodings, not shells)
|
| 229 |
+
out[f"every shell's pulse timing → {name} ({sh['label']})"] = ("shape", name)
|
| 230 |
return out
|
| 231 |
|
| 232 |
@classmethod
|
|
|
|
| 266 |
|
| 267 |
@classmethod
|
| 268 |
def estimated_seconds(cls, cfg, protocol, *, density, knob, n_keys, ladder, responses=(), scanner=None):
|
| 269 |
+
"""Measured on the pool (RTX PRO 6000 MIG 2g.48gb, the v2 layout streamed, every tier at 3 T, 2026-10-30): a
|
| 270 |
+
replay costs per measurement per stored term of its class (a PGSE, stimulated-echo or OGSE class 2, the
|
| 271 |
+
b-tensor class 6: :func:`space.classes.n_terms`), 0.05 s the first in a call (DiSCo 364: 32.4 s; the b-tensor
|
| 272 |
+
preset: 23.4 s) and 0.045 s each further one (the ladder is three, B one); the worker's start and the
|
| 273 |
+
payload's handoff 8 s, the stages after the replay 10 s (noise to scoring, or the b-tensor maps), B's 10 s
|
| 274 |
+
more, 10 s per extra tracker key; on a machine every replay adds its delivery, ``[scanners]
|
| 275 |
+
delivery_per_meas`` s per measurement; times 1.3, within 30 and 480 s. DiSCo prepares nothing, so
|
| 276 |
+
``responses`` is empty."""
|
| 277 |
+
from .. import classes as C
|
| 278 |
n = protocol.n_meas
|
| 279 |
+
terms = sum(C.n_terms(cfg["shapes"][s.shape]) * s.n_dirs if s.shape in cfg["shapes"] else 2 * s.n_dirs for s in protocol.shells)
|
| 280 |
+
terms += C.n_terms(cfg["shapes"][protocol.shells[0].shape]) * protocol.n_b0 if protocol.shells[0].shape in cfg["shapes"] else 2 * protocol.n_b0
|
| 281 |
+
b = knob != P.NO_KNOB
|
| 282 |
+
replays = 1 + (3 if ladder else 0) + (1 if b else 0)
|
| 283 |
+
secs = 8 + 10 + 0.05 * terms + 0.045 * terms * (replays - 1) + (10 if b else 0) + 10 * (int(n_keys) - 1)
|
| 284 |
if scanner is not None:
|
| 285 |
secs += replays * float(cfg["scanners"]["delivery_per_meas"]) * n
|
| 286 |
return int(min(480, max(30, 1.3 * secs)))
|
|
|
|
| 308 |
"""The ground truth tab: a sample of the strands in 3-D with the region markers, and the two matrices."""
|
| 309 |
from dmipy_sim.io.strands import read_tck, read_diameters
|
| 310 |
from .. import viewers as V
|
| 311 |
+
strands = read_tck(strands_path(self.cfg), coordinate_unit_m=VOXEL_M)
|
| 312 |
diameters = read_diameters(os.path.join(P.DATA_DIR, "DiSCo_Strands_Diameters.txt"), diameter_unit_m=1e-3)
|
| 313 |
return (V.strands3d([s / VOXEL_M for s in strands], diameters, V.region_markers(self.regions), self.mask.shape),
|
| 314 |
V.ground_truth_matrix(self.gt_count, self.gt_area, P.connected_pairs(self.gt_count)))
|
|
|
|
| 352 |
return [pool, contact, fld]
|
| 353 |
|
| 354 |
|
| 355 |
+
@functools.lru_cache(maxsize=2)
|
| 356 |
+
def strands_path(cfg=None):
|
| 357 |
+
"""The DiSCo strand centrelines (``.tck``, voxel units; CC BY 4.0): ``[data] strands`` in the replay dataset
|
| 358 |
+
(``[data] pack``'s repository) at ``[data] pack_revision``, fetched into the Hub cache once."""
|
| 359 |
+
from huggingface_hub import hf_hub_download
|
| 360 |
+
d = (cfg or P.config())["data"]
|
| 361 |
+
repo = "/".join(d["pack"][len("hf://"):].split("/")[:2])
|
| 362 |
+
return hf_hub_download(repo, d["strands"], repo_type="dataset", revision=d["pack_revision"])
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def strand_configuration(tck=None, shape=(40, 40, 40), *, angle_deg=30.0, share=0.10):
|
| 366 |
+
"""The ground truth's fibre configuration per voxel from the released strand trajectories (voxel units):
|
| 367 |
+
``(n_dirs, dispersion, length)`` -- the distinct strand directions crossing the voxel (the strands' length-weighted
|
| 368 |
+
mean tangents in it, clustered at ``angle_deg``, a direction counted when it carries at least ``share`` of the voxel's
|
| 369 |
+
strand length), ``1 - lambda_1 / trace`` of the length-weighted dyadic tensor of the tangents, and the strand length
|
| 370 |
+
in the voxel (voxel units)."""
|
| 371 |
+
from dmipy_sim.io.strands import read_tck
|
| 372 |
+
strands = read_tck(tck or strands_path(), coordinate_unit_m=1.0)
|
| 373 |
+
n_vox = int(np.prod(shape))
|
| 374 |
+
Tm = np.zeros((n_vox, 3, 3)); per = {}
|
| 375 |
+
for c in strands:
|
| 376 |
+
c = np.asarray(c, float)
|
| 377 |
+
if len(c) < 2:
|
| 378 |
+
continue
|
| 379 |
+
seg = np.diff(c, axis=0); mid = 0.5 * (c[1:] + c[:-1]); L = np.linalg.norm(seg, axis=1); ok = L > 0
|
| 380 |
+
t = seg[ok] / L[ok, None]; ijk = np.floor(mid[ok]).astype(int); inside = np.all((ijk >= 0) & (ijk < np.asarray(shape)), axis=1)
|
| 381 |
+
v = np.ravel_multi_index(ijk[inside].T, shape); w = L[ok][inside]; t = t[inside]
|
| 382 |
+
np.add.at(Tm, v, (t[:, :, None] * t[:, None, :]) * w[:, None, None])
|
| 383 |
+
for vox in np.unique(v):
|
| 384 |
+
k = v == vox
|
| 385 |
+
per.setdefault(int(vox), []).append((np.linalg.eigh(np.einsum("n,ni,nj->ij", w[k], t[k], t[k]))[1][:, -1], float(w[k].sum())))
|
| 386 |
+
ev = np.linalg.eigvalsh(Tm); tr = ev.sum(-1)
|
| 387 |
+
disp = np.where(tr > 0, 1.0 - ev[:, -1] / np.maximum(tr, 1e-30), np.nan)
|
| 388 |
+
ndirs = np.zeros(n_vox, int); cos = np.cos(np.radians(angle_deg))
|
| 389 |
+
for vox, items in per.items():
|
| 390 |
+
items = sorted(items, key=lambda x: -x[1]); total = sum(w for _, w in items); clusters = []
|
| 391 |
+
for d, w in items:
|
| 392 |
+
for cl in clusters:
|
| 393 |
+
if abs(d @ cl[0]) >= cos:
|
| 394 |
+
cl[1] += w; break
|
| 395 |
+
else:
|
| 396 |
+
clusters.append([d, w])
|
| 397 |
+
ndirs[vox] = sum(1 for _, w in clusters if w >= share * total)
|
| 398 |
+
return ndirs.reshape(shape), disp.reshape(shape), tr.reshape(shape)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
PAIRS = np.triu_indices(N_REGIONS, 1) # the 120 region pairs
|
| 402 |
|
| 403 |
|
|
|
|
| 418 |
|
| 419 |
|
| 420 |
class Layout(Disco):
|
| 421 |
+
"""Demo mode: dmipy-replay's time-segment layout (``dmipy_replay.replay.time_segments``) at the config's dataset revision,
|
| 422 |
+
or a local copy. Every stored class is one waveform (:mod:`space.classes`), checked at load to be the layout's
|
| 423 |
+
partition of that name; a shell plays it at its b-values and directions."""
|
| 424 |
mode = "demo"
|
| 425 |
|
| 426 |
@classmethod
|
| 427 |
def load(cls, cfg, *, local=None):
|
| 428 |
+
"""The layout at ``local``, ``DISCO_LAYOUT``, or the config's dataset revision on the Hub."""
|
| 429 |
+
return cls(cfg, local=local or os.environ.get("DISCO_LAYOUT"))
|
| 430 |
|
| 431 |
def __init__(self, cfg, *, local=None):
|
| 432 |
+
import hashlib
|
| 433 |
+
from dmipy_replay.replay.time_segments import MANIFEST, TimeSegmentLayout
|
| 434 |
+
from .. import classes as C
|
| 435 |
super().__init__(cfg)
|
| 436 |
d = cfg["data"]
|
| 437 |
+
uri = local or f"hf://{d['repo']}/{d['layout']}"
|
| 438 |
+
names = list(self.shapes)
|
| 439 |
+
self.layout = TimeSegmentLayout.open(uri, revision=d.get("revision") or "main", partitions=names)
|
| 440 |
self.resident = P.resident(cfg)
|
| 441 |
+
want = d.get("manifest_sha256")
|
| 442 |
+
with open(os.path.join(self.layout.path, MANIFEST), "rb") as f:
|
| 443 |
+
got = hashlib.sha256(f.read()).hexdigest()
|
| 444 |
if want and got != want:
|
| 445 |
+
raise ValueError(f"the layout at {uri} has manifest {got[:12]}, the config pins {want[:12]}")
|
| 446 |
+
self.manifest_sha256 = got
|
| 447 |
+
missing = [s for s in names if s not in self.layout.partitions]
|
| 448 |
if missing:
|
| 449 |
+
raise ValueError(f"the layout at {uri} lacks the timing classes {missing}; it holds {list(self.layout.partitions)}")
|
| 450 |
+
for name, sh in self.shapes.items(): # each class's waveform is the stored partition's
|
| 451 |
+
got_name, _ = self.layout.fit(C.template(sh), partition=name)
|
| 452 |
+
if not self.layout.partitions[name].is_constant and C.kind(sh) not in ("ogse", "tensor"):
|
| 453 |
+
raise ValueError(f"class {name}: the layout stores shaped terms, the config a square-pulse {C.kind(sh)}")
|
| 454 |
+
m = self.layout.manifest
|
| 455 |
+
self.tiers = bool(m.get("tiers"))
|
| 456 |
+
self.M0 = self.layout.weights.sum(1).reshape(self.layout.grid.shape) # every voxel's walker weight: the bare b = 0 signal
|
| 457 |
+
if self.tiers: # the pools with walkers: the pool ids of the rows that weigh something
|
| 458 |
+
t = m["tiers"]
|
|
|
|
|
|
|
| 459 |
spec_pools = sorted(t["substrate"]["pools"], key=lambda q: q["id"]) if t.get("substrate") else []
|
| 460 |
+
ids = self.seeded_pool_ids()
|
| 461 |
+
seeded = tuple(q["name"] for q in spec_pools if q["id"] in ids)
|
| 462 |
if set(seeded) != set(self.pools):
|
| 463 |
raise ValueError(f"the layout's seeded pools are {seeded}, the config's [physics] pools {self.pools}")
|
| 464 |
+
self.unseeded = tuple(q["name"] for q in spec_pools if q["id"] not in ids)
|
| 465 |
+
self.check_grid(self.layout.grid.shape, "the layout")
|
| 466 |
+
|
| 467 |
+
def seeded_pool_ids(self, chunk=1 << 16):
|
| 468 |
+
"""The pool ids of the layout's rows with weight (a padding row weighs nothing), read once in chunks of tiles."""
|
| 469 |
+
w = self.layout._column("w"); pool = self.layout._column("pool"); ids = set()
|
| 470 |
+
for s in range(0, self.layout.n_tiles, chunk):
|
| 471 |
+
q = np.asarray(pool[s:s + chunk]); ids |= set(np.unique(q[np.asarray(w[s:s + chunk]) > 0]).tolist())
|
| 472 |
+
return ids
|
| 473 |
+
|
| 474 |
+
def background(self, name):
|
| 475 |
+
"""Whether the class's sequence group carries the background vectors a magnet's own gradient needs."""
|
| 476 |
+
return bool(self.layout.manifest["groups"][self.layout.manifest["partitions"][name]["group"]].get("background"))
|
| 477 |
|
| 478 |
def validate(self, protocol, physics):
|
| 479 |
+
from .. import classes as C
|
| 480 |
meas = P.measurements(protocol, self.shapes)
|
| 481 |
if physics and not physics.bare and not self.tiers:
|
| 482 |
raise ValueError("this layout holds bare diffusion only: switch the tissue panel off")
|
|
|
|
| 484 |
raise ValueError(f"the tissue names the pools {physics.pools}; this layout's are {self.pools}")
|
| 485 |
if physics and physics.scanner is not None:
|
| 486 |
L = P.limits(physics.scanner)
|
| 487 |
+
shaped = [n for n in np.unique(meas.shape) if not self.layout.partitions[n].is_constant]
|
| 488 |
+
if shaped:
|
| 489 |
+
raise ValueError(f"a machine's delivered gradient is one vector per constant segment, and the classes {shaped} are "
|
| 490 |
+
"shaped waveforms (an OGSE): choose the ideal scanner for them")
|
| 491 |
+
if L.has_field_law and not all(self.background(n) for n in np.unique(meas.shape)):
|
| 492 |
+
raise ValueError(f"the {physics.scanner}'s own gradient needs the layout's background vectors, and this layout "
|
| 493 |
+
"lacks them for a class of this run")
|
| 494 |
+
for name in np.unique(meas.shape):
|
| 495 |
+
C.kind(self.shapes[name])
|
| 496 |
return meas
|
| 497 |
|
| 498 |
+
def sequence(self, name, bvals, dirs, encodings=None):
|
| 499 |
+
"""The rows on class ``name`` (b in s/mm^2) as the class's waveform at their amplitudes and directions; on a
|
| 500 |
+
b-tensor class each row's ``encodings`` entry (``b0``, ``LTE``, ``STE``) about its direction."""
|
| 501 |
+
from .. import classes as C
|
| 502 |
+
sh = self.shapes[name]
|
| 503 |
+
if C.kind(sh) == "tensor":
|
| 504 |
+
if encodings is None:
|
| 505 |
+
raise ValueError(f"class {name} is a b-tensor class: its rows name their encodings")
|
| 506 |
+
return T._sequence(C.tensor_rows(sh, encodings, bvals, dirs))
|
| 507 |
+
return C.play(sh, np.asarray(bvals, np.float64) * 1e6, dirs)
|
| 508 |
+
|
| 509 |
+
def delivered(self, name, seq, physics):
|
| 510 |
+
"""What the run's machine delivers at every voxel with rows for the class ``name`` playing ``seq``: the layout's
|
| 511 |
+
:meth:`~dmipy_replay.replay.time_segments.TimeSegmentLayout.delivery` with the grid's centre at the physics'
|
| 512 |
+
offset; None on the ideal scanner."""
|
| 513 |
if physics is None or physics.scanner is None:
|
| 514 |
return None
|
| 515 |
+
return self.layout.delivery(seq, P.limits(physics.scanner), self.layout.grid.centred_at(physics.offset_m), partition=name)
|
| 516 |
|
| 517 |
def replay(self, meas, physics=None, prepared=None):
|
| 518 |
t0 = time.perf_counter()
|
|
|
|
| 522 |
b0 = physics.b0_direction if physics else P.B0_ALONG_Z
|
| 523 |
for name in np.unique(meas.shape):
|
| 524 |
rows = meas.shape == name
|
| 525 |
+
seq = self.sequence(name, meas.bvals[rows], meas.dirs[rows], None if meas.encoding is None else meas.encoding[rows])
|
| 526 |
+
img, f = self.layout.image(seq, partition=str(name), backend=self.backend, resident=self.resident, tissue=tissue,
|
| 527 |
+
scanner=scanner, b0_direction=b0, delivered=self.delivered(str(name), seq, physics))
|
| 528 |
+
S[..., rows] = img * self.M0[..., None] # the voxel's signal: its walkers' weight times the normalised one
|
| 529 |
+
floor = np.fmax(floor, f)
|
|
|
|
| 530 |
dwi, factor = P.s0_normalised(S, meas.b0, self.M0)
|
| 531 |
return dwi, floor, factor, time.perf_counter() - t0
|
| 532 |
|
| 533 |
def release(self):
|
| 534 |
if not self.resident:
|
| 535 |
+
self.layout.release()
|
| 536 |
|
| 537 |
def ingredients(self, meas, physics=None, prepared=None):
|
| 538 |
"""What each tier multiplies into the sum, per voxel, for the run's first class (the layout's
|
| 539 |
+
:meth:`~dmipy_replay.replay.time_segments.TimeSegmentLayout.tier_maps`): ``intra_fraction`` (the walker weight in
|
| 540 |
+
the intra-axonal pool over the voxel's), ``wall_contact_um`` (the walkers' boundary local time l under the
|
| 541 |
+
class's gate, a length; the layout stores it signed as the exponent's term, -l, so the contact tier's weight is
|
| 542 |
+
exp(rho2 c / D) = exp(-rho2 l / D)), ``contact_survival`` (that factor at the run's rho2, None without the
|
| 543 |
+
contact tier), ``field_rad`` (the spread over the voxel's walkers of the dephasing phase the sheath's field
|
| 544 |
+
gives them by the echo at the run's field and direction, the exact per-walker phase the kernel applies, in
|
| 545 |
+
radians; None without the field tier), and ``D_walk`` (m^2/s)."""
|
|
|
|
| 546 |
if not self.tiers:
|
| 547 |
return None
|
| 548 |
tissue, scanner = P.tissue_and_scanner(physics, self.unseeded)
|
| 549 |
b0 = physics.b0_direction if physics else P.B0_ALONG_Z
|
| 550 |
+
maps = self.layout.tier_maps(str(meas.shape[0]), tissue, scanner, backend=self.backend, resident=self.resident, b0_direction=b0)
|
| 551 |
contact = maps["contact"]
|
| 552 |
+
return dict(D_walk=float(self.layout.manifest["tiers"]["D_walk"]), intra_fraction=maps["pool"]["intra"],
|
| 553 |
wall_contact_um=None if contact is None else -contact * 1e6,
|
| 554 |
contact_survival=maps["contact_weight"] if (physics and physics.contact) else None,
|
| 555 |
field_rad=maps["phase_std"] if (physics and physics.field) else None)
|
|
|
|
| 561 |
|
| 562 |
def warm(self):
|
| 563 |
"""DiSCo's own protocol replayed once at the default physics (every class it plays compiled at the row
|
| 564 |
+
counts the default run uses), then one direction on every other class (on a b-tensor class one row of each
|
| 565 |
+
encoding). When the columns are not kept resident
|
| 566 |
+
(a pool that drops the device between calls), the padded host arrays are loaded into this process instead, so
|
| 567 |
+
a forked worker inherits them and pays the transfer alone."""
|
| 568 |
+
if not self.resident and self.backend == "torch":
|
| 569 |
+
self.layout.preload(list(self.shapes))
|
| 570 |
return
|
| 571 |
physics = self.default_physics()
|
| 572 |
disco = disco_protocol(self.cfg)[0]
|
| 573 |
self.replay(P.measurements(disco, self.shapes), physics)
|
| 574 |
+
played = {s.shape for s in disco.shells}
|
| 575 |
+
rest = [n for n, sh in self.shapes.items() if n not in played and sh.get("kind") != "tensor"]
|
| 576 |
if rest:
|
| 577 |
self.replay(P.measurements(P.Protocol(tuple(P.Shell(name, 1000.0, 1) for name in rest), n_b0=1, name="warm"), self.shapes), physics)
|
| 578 |
+
for name in [n for n, sh in self.shapes.items() if sh.get("kind") == "tensor"]: # a b-tensor class: one row per encoding
|
| 579 |
+
shells = tuple(P.Shell(name, 1000.0, 1, encoding=e) for e in ("LTE", "STE"))
|
| 580 |
+
self.replay(P.measurements(P.Protocol(shells, n_b0=1, name="warm"), self.shapes), physics)
|
| 581 |
|
| 582 |
def accuracy(self, res=None):
|
| 583 |
+
from .. import classes as C
|
| 584 |
+
m = self.layout.manifest; src = m["source"]; cert = m["certificate"]; env = src["envelope"]
|
| 585 |
+
rows = [["source", f"the {src['kind']}'s coefficients ({len(src.get('blocks') or [])} blocks); classes appended from the "
|
| 586 |
+
"quantised pack carry its quantisation in their own term" if any(p.get("source") for p in m["partitions"].values())
|
| 587 |
+
else f"the {src['kind']}'s coefficients ({len(src.get('blocks') or [])} blocks)"],
|
| 588 |
+
["floor target (the words' budget)", f"{float(src['floor_target']):.4g}"],
|
| 589 |
+
["layout's worst signal error over its classes and tiers (err_max)", f"{float(cert['err_max']):.2e}"],
|
| 590 |
+
["envelope: gradient per axis / B0 / ρ₂/D", f"{float(env['G_max']) * 1e3:.0f} mT/m ({env.get('scanner') or env.get('bound')}) / "
|
| 591 |
+
f"{float(env['B0_max_T']):g} T / {float(env['rho2_over_D_max']):g} 1/m"],
|
| 592 |
+
["layout manifest sha256", self.manifest_sha256[:16]],
|
| 593 |
+
["layout written by", f"{(m.get('code') or {}).get('commit') or '?'} on {m.get('created', '?')}"],
|
| 594 |
+
["pulses", "one waveform per class: square lobes (slew rate ∞) for the PGSE and stimulated-echo classes, "
|
| 595 |
+
"the cosine OGSE with its 200 T/m/s edges as built at b = 1000"],
|
| 596 |
["tiers stored", "pool, contact, field" if self.tiers else "none (bare diffusion only)"],
|
| 597 |
+
["background vectors (a magnet's own gradient)", "stored, certified to {:g} mT/m".format(float(src["background_amplitude"]) * 1e3)
|
| 598 |
+
if any(g.get("background") for g in m["groups"].values()) else "not stored: a machine with its own gradient is refused"]]
|
| 599 |
if res is not None and res.physics is not None and res.physics.scanner is not None:
|
| 600 |
rows.append(["scanner", f"{res.physics.scanner}, the phantom's centre at "
|
| 601 |
f"({', '.join(f'{x * 100:.1f}' for x in res.physics.offset_m)}) cm from isocentre: every catalogued term "
|
|
|
|
| 603 |
rows += super().accuracy(res)
|
| 604 |
if res is not None:
|
| 605 |
for name in np.unique(res.meas.shape):
|
| 606 |
+
p = m["partitions"][str(name)]
|
| 607 |
+
rows.append([f"class {name}", f"{C.describe(self.shapes[str(name)])}, TE {float(self.shapes[str(name)].get('TE', 0.0535)) * 1e3:g} ms; "
|
| 608 |
+
f"{4 * int(p['n_words'])} B per walker, rounding variance {float(p['rounding']['variance']):.2e} rad²"])
|
|
|
|
| 609 |
return rows
|
| 610 |
|
| 611 |
|
| 612 |
class Columns(Disco):
|
| 613 |
+
"""Full mode: the columnar replay pack (:class:`dmipy_replay.replay.columnar.ColumnarPack`) at
|
| 614 |
+
:func:`space.pipeline.columns_uri`, read once per run: one ScannerSequence per run (one echo time, one RF schedule;
|
| 615 |
+
the stored classes' waveforms, square pulses for a free timing), the tissue and field of the run, the field along
|
| 616 |
+
the pack's z."""
|
| 617 |
mode = "full"
|
| 618 |
RATE = {True: 45e6, False: 500e6} # bytes/s read from the Hub, from a local disk
|
| 619 |
|
| 620 |
def __init__(self, cfg, *, uri=None):
|
| 621 |
+
from dmipy_replay.replay.columnar import ColumnarPack
|
| 622 |
super().__init__(cfg)
|
| 623 |
self.uri = uri or P.columns_uri(cfg)
|
| 624 |
self.pack = ColumnarPack(self.uri, workers=int(cfg["mode"]["workers"]))
|
|
|
|
| 626 |
self.remote = self.uri.startswith("hf://")
|
| 627 |
|
| 628 |
def kinds(self, meas):
|
| 629 |
+
"""The pulse kinds (``pgse`` / ``pgste`` / ``ogse``) of the classes the rows play; a free timing is PGSE."""
|
| 630 |
+
from .. import classes as C
|
| 631 |
+
return sorted({C.kind(self.shapes[n]) if n in self.shapes else "pgse" for n in np.unique(meas.shape)})
|
| 632 |
|
| 633 |
def validate(self, protocol, physics):
|
| 634 |
meas = P.measurements(protocol, self.shapes)
|
| 635 |
if len(np.unique(np.round(meas.TE, 9))) != 1:
|
| 636 |
raise ValueError("full mode replays one echo time per run")
|
| 637 |
+
kinds = self.kinds(meas)
|
| 638 |
+
if len(kinds) != 1 and not set(kinds) <= {"pgse", "ogse"}:
|
| 639 |
+
raise ValueError("full mode replays one RF schedule per run (spin echoes, or a stimulated echo)")
|
| 640 |
if physics and not physics.along_z:
|
| 641 |
raise ValueError("full mode replays the field along z only")
|
| 642 |
if physics and physics.scanner is not None:
|
|
|
|
| 645 |
return meas
|
| 646 |
|
| 647 |
def sequence(self, meas):
|
| 648 |
+
"""The one ScannerSequence of the measurements, rows in order: a stored class's rows play its waveform
|
| 649 |
+
(:mod:`space.classes`, the layout's own), a free shell square PGSE lobes at its delta / Delta, all at the rows'
|
| 650 |
+
common TE on the layout's grid."""
|
| 651 |
+
from dmipy_replay.acquisition.scanner_sequence import ScannerSequence
|
| 652 |
+
from dmipy_replay.sequences import pgse
|
| 653 |
+
from .. import classes as C
|
| 654 |
TE = float(np.unique(np.round(meas.TE, 9))[0])
|
| 655 |
+
G = np.zeros((len(meas.bvals), C.N_T, 3), np.float32); tmpl = None
|
| 656 |
+
for name in np.unique(meas.shape):
|
| 657 |
+
rows = meas.shape == name
|
| 658 |
+
if name in self.shapes and C.kind(self.shapes[name]) == "tensor":
|
| 659 |
+
seq = T._sequence(C.tensor_rows(self.shapes[name], meas.encoding[rows], meas.bvals[rows], meas.dirs[rows]))
|
| 660 |
+
elif name in self.shapes:
|
| 661 |
+
seq = C.play(self.shapes[name], meas.bvals[rows] * 1e6, meas.dirs[rows])
|
| 662 |
+
else:
|
| 663 |
+
seq = pgse(meas.dirs[rows].tolist(), meas.delta[rows], meas.Delta[rows], bvalues=meas.bvals[rows] * 1e6, TE=TE,
|
| 664 |
+
n_t=C.N_T, slew_rate=np.inf)
|
| 665 |
+
if tmpl is not None and (seq.dt != tmpl.dt or seq.rf.to_dicts() != tmpl.rf.to_dicts()):
|
| 666 |
+
raise ValueError("full mode replays one RF schedule and grid per run")
|
| 667 |
+
tmpl = seq; G[rows] = np.asarray(seq.G, np.float32)
|
| 668 |
+
return ScannerSequence(G=G, dt=tmpl.dt, rf=tmpl.rf, readout=tmpl.readout)
|
| 669 |
|
| 670 |
def plan(self, meas, physics=None):
|
| 671 |
"""What the replay will read (the pack's plan: bands, modes, tiers, rows, bytes) and ``estimated_seconds``
|
|
|
|
| 676 |
return plan
|
| 677 |
|
| 678 |
def replay(self, meas, physics=None, prepared=None, *, progress=None):
|
| 679 |
+
from dmipy_replay.replay.study import Acquisition, Study, StudyProtocol
|
| 680 |
t0 = time.perf_counter()
|
| 681 |
tissue, scanner = P.tissue_and_scanner(physics, self.unseeded)
|
| 682 |
+
study = Study(StudyProtocol([Acquisition(self.sequence(meas), name="run")]), tissues=[tissue], scanners=[scanner])
|
| 683 |
+
S, floor, plan = self.pack.image_study(study, tol=P.BAND_TOL, chunk_rows=1_000_000, backend="jax", progress=progress)
|
| 684 |
self.last_plan = plan
|
| 685 |
dwi, factor = P.s0_normalised(S[0], meas.b0) # the pack's bare weights are not read here: the factor is 1
|
| 686 |
return dwi, floor[0], factor, time.perf_counter() - t0
|
|
|
|
| 689 |
m = self.pack.meta
|
| 690 |
rows = [["source pack", str(m.get("id"))], ["source pack's certified median floor", f"{self.pack.floor:.4g}"],
|
| 691 |
["bands kept", str(self.pack.K)], ["band tolerance (× the pack's floor)", f"{P.BAND_TOL:g}"],
|
| 692 |
+
["pulses", "the stored classes' waveforms, square (slew rate ∞) for a free timing; one TE and one RF schedule per run"]]
|
| 693 |
plan = getattr(self, "last_plan", None)
|
| 694 |
if plan:
|
| 695 |
rows.append(["last replay read", f"{plan.get('rows', 0):,} rows, {plan.get('bytes', 0) / 1e9:.1f} GB, bands {plan.get('K')}, modes {plan.get('modes')}"])
|
space/tensor.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The b-tensor encoding class: three gradient shapes psi_1..3 on the spin echo's two encoding windows, stored as one
|
| 2 |
+
class of the layout (six terms: each shape before and after the 180), and the rows that play any b-tensor of them.
|
| 3 |
+
|
| 4 |
+
A row plays ``G(t) = sum_k psi_k(t) a_k``, the 3x3 level matrix ``A`` (row k the vector shape k is played along), so
|
| 5 |
+
its b-tensor is ``B = A^T M A`` with ``M`` the shapes' Gram matrix (``M_kl = int k_k k_l dt``, the moments to the
|
| 6 |
+
readout). Every encoding is a choice of ``A``: spherical ``A = sqrt(b/3) M^-1/2 R^T`` (``B = b/3 I`` at any rotation
|
| 7 |
+
``R``), planar ``A = sqrt(b/2) M^-1/2 [u; v; 0]`` (``B = b/2 (u u^T + v v^T)``), linear ``A = sqrt(b) c d^T`` with ``c``
|
| 8 |
+
the shape mix of the most b per peak gradient (``c^T M c = 1``; ``B = b d d^T``). The layout reads every row from
|
| 9 |
+
the same stored vectors, exactly as a pulse pair's rows are read: the encoding is exact in the substrate (the mean of
|
| 10 |
+
the walkers' phase factors), not a Gaussian approximation.
|
| 11 |
+
|
| 12 |
+
The waveform is ``[tensor] waveform`` in ``data/``: the physical gradient ``(1000, 3)`` (T/m) on the layout's
|
| 13 |
+
spin-echo grid (TE 53.5 ms), axis k the shape psi_k; the page checks it against the stored partition at load.
|
| 14 |
+
|
| 15 |
+
The maps: MD the first cumulant of the spherical powder average, and the microscopic fractional anisotropy from the
|
| 16 |
+
linear against the spherical powder average (Lasic et al. 2014, Front Phys 2:11; Szczepankiewicz et al. 2016,
|
| 17 |
+
NeuroImage 125:653): the gamma model fitted to the two powder signals jointly with one intercept (the b = 0 rows'
|
| 18 |
+
noise shared by every ``ln S/S0``), ``V_LTE - V_STE = (4/5) (D_iso D_Delta)^2``, and muFA is the FA of the axisymmetric
|
| 19 |
+
microscopic tensor with that ``D_iso`` and ``D_Delta``."""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import functools
|
| 23 |
+
import os
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
from . import pipeline as P
|
| 28 |
+
|
| 29 |
+
ENCODINGS = ("LTE", "PTE", "STE")
|
| 30 |
+
B_DELTA = {"LTE": 1.0, "PTE": -0.5, "STE": 0.0}
|
| 31 |
+
N_T = 1000
|
| 32 |
+
TE = 0.0535
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@functools.lru_cache(maxsize=None)
|
| 36 |
+
def _waveform(path):
|
| 37 |
+
G = np.load(path)
|
| 38 |
+
if G.shape != (N_T, 3) or not np.isfinite(G).all():
|
| 39 |
+
raise ValueError(f"the b-tensor waveform {path} is not a finite (1000, 3) gradient")
|
| 40 |
+
return np.asarray(G, np.float64)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def waveform(tc):
|
| 44 |
+
"""``(1000, 3)``: the class's physical gradient (T/m), axis k the shape psi_k at its designed amplitude."""
|
| 45 |
+
return _waveform(os.path.join(P.DATA_DIR, tc["waveform"]))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@functools.lru_cache(maxsize=None)
|
| 49 |
+
def _grid():
|
| 50 |
+
from dmipy_replay.sequences import pgse
|
| 51 |
+
return pgse([[0.0, 0.0, 1.0]], 0.0102, 0.0167, gradient_strengths=0.1, TE=TE, n_t=N_T, slew_rate=np.inf)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _sequence(G):
|
| 55 |
+
"""``G (n, 1000, 3)`` on the spin-echo grid, RF schedule and readout of the layout's group g0."""
|
| 56 |
+
from dmipy_replay.acquisition.scanner_sequence import ScannerSequence
|
| 57 |
+
g = _grid()
|
| 58 |
+
return ScannerSequence(G=np.asarray(G, np.float32), dt=g.dt, rf=g.rf, readout=g.readout)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def template(tc):
|
| 62 |
+
"""The class as the layout stores it: three rows, row k shape psi_k along axis k."""
|
| 63 |
+
P_ = waveform(tc)
|
| 64 |
+
G = np.zeros((3, N_T, 3))
|
| 65 |
+
for k in range(3):
|
| 66 |
+
G[k, :, k] = P_[:, k]
|
| 67 |
+
return _sequence(G)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def gram(tc):
|
| 71 |
+
"""``M (3, 3)`` (s/m^2 per (T/m)^2 of level): the b-tensor of ``sum_k psi_k a_k`` is ``A^T M A``."""
|
| 72 |
+
from dmipy_replay.acquisition.waveforms import btensor_from_gradient
|
| 73 |
+
g = _grid()
|
| 74 |
+
G = waveform(tc)[None] * np.asarray(g.effective_gate, np.float64)[None, :, None]
|
| 75 |
+
return btensor_from_gradient(G, g.dt)[0]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _inv_sqrt(M):
|
| 79 |
+
w, V = np.linalg.eigh(M)
|
| 80 |
+
return V @ np.diag(w ** -0.5) @ V.T
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def lte_mix(tc, n=4000):
|
| 84 |
+
"""``c (3,)`` with ``c^T M c = 1``: the shape mix whose single waveform ``sum_k c_k psi_k`` gives the most b per
|
| 85 |
+
squared peak gradient (searched over ``n`` Fibonacci directions of the sphere of mixes)."""
|
| 86 |
+
M = gram(tc); W = waveform(tc)
|
| 87 |
+
c = fibonacci(n, hemisphere=True)
|
| 88 |
+
peak = np.abs(W @ c.T).max(axis=0)
|
| 89 |
+
gain = np.einsum("nk,kl,nl->n", c, M, c) / peak ** 2
|
| 90 |
+
best = c[int(np.argmax(gain))]
|
| 91 |
+
return best / np.sqrt(best @ M @ best)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def fibonacci(n, *, hemisphere=True):
|
| 95 |
+
"""``n`` unit vectors spread on the (upper half-) sphere, deterministic."""
|
| 96 |
+
i = np.arange(n) + 0.5
|
| 97 |
+
z = 1.0 - i / n if hemisphere else 1.0 - 2.0 * i / n
|
| 98 |
+
phi = np.pi * (3.0 - np.sqrt(5.0)) * i
|
| 99 |
+
r = np.sqrt(np.clip(1.0 - z ** 2, 0.0, None))
|
| 100 |
+
return np.stack([r * np.cos(phi), r * np.sin(phi), z], axis=1)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def frame(d):
|
| 104 |
+
"""``R (3, 3)`` orthonormal with first column ``d`` (the encoding's axis), the rest completing it."""
|
| 105 |
+
d = np.asarray(d, np.float64); d = d / np.linalg.norm(d)
|
| 106 |
+
ref = np.array([1.0, 0.0, 0.0]) if abs(d[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
| 107 |
+
u = ref - (ref @ d) * d; u /= np.linalg.norm(u)
|
| 108 |
+
return np.stack([d, u, np.cross(d, u)], axis=1)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def levels(tc, encoding, b_SI, d):
|
| 112 |
+
"""``A (3, 3)``: the level matrix of one row of ``encoding`` at ``b_SI`` (s/m^2) about the axis ``d`` (the
|
| 113 |
+
direction of a linear encoding, the normal of a planar one, the first axis of a spherical one's rotation)."""
|
| 114 |
+
M = gram(tc); Mi = _inv_sqrt(M); R = frame(d)
|
| 115 |
+
if encoding == "LTE":
|
| 116 |
+
return np.sqrt(b_SI) * np.outer(lte_mix(tc), R[:, 0])
|
| 117 |
+
if encoding == "PTE":
|
| 118 |
+
return np.sqrt(b_SI / 2.0) * Mi @ np.stack([R[:, 1], R[:, 2], np.zeros(3)])
|
| 119 |
+
if encoding == "STE":
|
| 120 |
+
return np.sqrt(b_SI / 3.0) * Mi @ R.T
|
| 121 |
+
raise ValueError(f"an encoding is one of {ENCODINGS}, got {encoding!r}")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def rows(tc, bvals, n_dirs):
|
| 125 |
+
"""The acquisition: one b = 0 row, then for each b (s/mm^2) ``n_dirs`` rows of each encoding (their axes spread on
|
| 126 |
+
the hemisphere). Returns ``(sequence, encoding (n,), b (n,) s/mm^2)``."""
|
| 127 |
+
W = waveform(tc)
|
| 128 |
+
dirs = fibonacci(int(n_dirs))
|
| 129 |
+
G = [np.zeros((N_T, 3))]; enc = ["b0"]; bs = [0.0]
|
| 130 |
+
for b in bvals:
|
| 131 |
+
for e in ENCODINGS:
|
| 132 |
+
for d in dirs:
|
| 133 |
+
G.append(W @ levels(tc, e, float(b) * 1e6, d)); enc.append(e); bs.append(float(b))
|
| 134 |
+
return _sequence(np.stack(G)), np.array(enc), np.array(bs)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def check(tc, seq, enc, bs):
|
| 138 |
+
"""The rows' b-tensors as built: ``{encoding: (max |b - b_target| / b, max |b_delta - target|)}`` from
|
| 139 |
+
dmipy-replay's own b-tensor of the sequence, and the peak gradient per axis (T/m) of every row."""
|
| 140 |
+
from dmipy_replay.acquisition.waveforms import btensor_invariants
|
| 141 |
+
B = seq.btensor()
|
| 142 |
+
b, b_delta, _ = btensor_invariants(B)
|
| 143 |
+
out = {}
|
| 144 |
+
for e in ENCODINGS:
|
| 145 |
+
m = enc == e
|
| 146 |
+
if m.any():
|
| 147 |
+
out[e] = (float(np.max(np.abs(b[m] / 1e6 - bs[m]) / bs[m])), float(np.max(np.abs(b_delta[m] - B_DELTA[e]))))
|
| 148 |
+
return out, np.abs(np.asarray(seq.G, np.float64)).max(axis=(1, 2))
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def powder(S, enc, bs, bvals):
|
| 152 |
+
"""``{(encoding, b): S/S0 averaged over the encoding's rows at b}`` per voxel, ``S`` ``(..., n)`` normalised to its
|
| 153 |
+
b = 0 row."""
|
| 154 |
+
return {(e, float(b)): np.nanmean(S[..., (enc == e) & (bs == float(b))], axis=-1) for e in ENCODINGS for b in bvals}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def gamma_joint(b, yL, yS, wL, wS, w0, iters=80):
|
| 158 |
+
"""``(MD, V_L, V_S, c)`` per voxel of the gamma model fitted jointly to the linear and the spherical powder signal
|
| 159 |
+
(``ln S/S0`` ``(..., n_b)`` at ``b`` ms/um^2): one mean diffusivity (the first cumulant, the same for every b-tensor
|
| 160 |
+
of one trace), one intercept ``c`` (the log of the voxel's b = 0 signal over its measured one) and a variance per
|
| 161 |
+
encoding, ``y_e(b) = c - ln(1 + r_e b MD) / r_e``, ``V_e = r_e MD^2``; weighted least squares, each point weighted by
|
| 162 |
+
the rows it averages (``wL``, ``wS`` ``(n_b,)``) and the b = 0 point ``y = 0`` by the b = 0 rows ``w0``. The shared MD
|
| 163 |
+
and intercept leave the curvature of each curve to its variance alone: the noise of the b = 0 rows (shared by every
|
| 164 |
+
``ln S/S0``) is not read as curvature. A vectorised Gauss-Newton over every voxel, MD in [1e-4, 5] um^2/ms, r in
|
| 165 |
+
[1e-6, 50]."""
|
| 166 |
+
sh = yL.shape[:-1]; n_b = len(b)
|
| 167 |
+
Y = np.concatenate([yL.reshape(-1, n_b), yS.reshape(-1, n_b), np.zeros((int(np.prod(sh)), 1))], axis=1)
|
| 168 |
+
W = np.sqrt(np.concatenate([np.asarray(wL, float), np.asarray(wS, float), [float(w0)]]))
|
| 169 |
+
n = Y.shape[0]
|
| 170 |
+
p = np.stack([np.clip(-np.nan_to_num(0.5 * (Y[:, 0] + Y[:, n_b]) / b[0]), 1e-3, None), np.full(n, 0.1), np.full(n, 0.1),
|
| 171 |
+
np.zeros(n)], axis=1)
|
| 172 |
+
lo = np.array([1e-4, 1e-6, 1e-6, -2.0]); hi = np.array([5.0, 50.0, 50.0, 2.0])
|
| 173 |
+
|
| 174 |
+
def f(p):
|
| 175 |
+
g = lambda r: -np.log1p(r[:, None] * b[None] * p[:, 0:1]) / r[:, None]
|
| 176 |
+
return np.concatenate([p[:, 3:4] + g(p[:, 1]), p[:, 3:4] + g(p[:, 2]), p[:, 3:4]], axis=1)
|
| 177 |
+
for _ in range(int(iters)):
|
| 178 |
+
F = f(p); res = (Y - F) * W
|
| 179 |
+
J = np.empty((n, Y.shape[1], 4))
|
| 180 |
+
for k in range(4):
|
| 181 |
+
e = 1e-6 * np.maximum(np.abs(p[:, k]), 1e-3); q = p.copy(); q[:, k] += e
|
| 182 |
+
J[:, :, k] = (f(q) - F) / e[:, None] * W
|
| 183 |
+
A = np.einsum("nik,nil->nkl", J, J) + 1e-12 * np.eye(4)
|
| 184 |
+
g = np.einsum("nik,ni->nk", J, res)
|
| 185 |
+
with np.errstate(invalid="ignore"):
|
| 186 |
+
step = np.linalg.solve(A, g[..., None])[..., 0]
|
| 187 |
+
p = np.clip(p + np.nan_to_num(step), lo, hi)
|
| 188 |
+
md, rL, rS, c = (p[:, k].reshape(sh) for k in range(4))
|
| 189 |
+
return md, rL * md ** 2, rS * md ** 2, c
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def md_ufa(pw, bvals, counts=None, n_b0=1):
|
| 193 |
+
"""``(MD (um^2/ms), muFA)`` per voxel from the powder averages: the gamma model fitted jointly to the linear and the
|
| 194 |
+
spherical powder signal over the b-values with one MD and one intercept (:func:`gamma_joint`; ``counts``
|
| 195 |
+
``{(encoding, b): rows averaged}`` and ``n_b0`` the b = 0 rows weight the points, equal when None), MD the shared first cumulant,
|
| 196 |
+
``V_A = V_LTE - V_STE`` the anisotropic variance (the orientation dispersion of the microscopic tensors, ``(4/5)
|
| 197 |
+
(D_iso D_Delta)^2``), and muFA the FA of the axisymmetric microscopic tensor with ``D_iso = MD`` and that
|
| 198 |
+
``D_Delta``; 0 where ``V_A`` is not positive (a spherical signal whose variance exceeds the linear one's: on
|
| 199 |
+
restricted tissue the two waveforms' spectra differ, and the spherical one sees more of the time dependence), NaN
|
| 200 |
+
where a signal is not positive."""
|
| 201 |
+
keys = sorted(float(x) for x in bvals) # the powder averages' own keys, s/mm^2
|
| 202 |
+
b = np.asarray(keys) * 1e-3 # ms/um^2
|
| 203 |
+
cnt = (lambda e: [1.0] * len(keys)) if counts is None else (lambda e: [float(counts[(e, k)]) for k in keys])
|
| 204 |
+
with np.errstate(divide="ignore", invalid="ignore"):
|
| 205 |
+
yL = np.stack([np.log(pw[("LTE", k)]) for k in keys], axis=-1); yS = np.stack([np.log(pw[("STE", k)]) for k in keys], axis=-1)
|
| 206 |
+
bad = ~np.isfinite(np.concatenate([yL, yS], axis=-1)).all(-1)
|
| 207 |
+
md, vL, vS, _ = gamma_joint(b, np.nan_to_num(yL), np.nan_to_num(yS), cnt("LTE"), cnt("STE"), 1.0 if counts is None else n_b0)
|
| 208 |
+
x = np.clip(1.25 * (vL - vS), 0.0, None) # (D_iso D_Delta)^2, (um^2/ms)^2
|
| 209 |
+
with np.errstate(divide="ignore", invalid="ignore"):
|
| 210 |
+
ufa = np.sqrt(1.5) * np.sqrt(6.0 * x / (3.0 * md ** 2 + 6.0 * x))
|
| 211 |
+
return np.where(bad, np.nan, md), np.where(bad, np.nan, ufa)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def dti_fa(S, enc, bs, axes, b_max=1400.0):
|
| 215 |
+
"""FA per voxel of the diffusion tensor fitted log-linearly to the linear rows at b <= ``b_max`` (s/mm^2) and the
|
| 216 |
+
b = 0 rows (``S`` S/S0 ``(..., n)``, ``axes`` each row's encoding axis); NaN where a signal is not positive."""
|
| 217 |
+
m = (np.asarray(enc) == "LTE") & (np.asarray(bs) <= b_max)
|
| 218 |
+
if m.sum() < 6:
|
| 219 |
+
raise ValueError(f"a tensor fit needs six or more linear rows at b <= {b_max:g}, got {int(m.sum())}")
|
| 220 |
+
g = np.asarray(axes, np.float64)[m]; b = np.asarray(bs, np.float64)[m] * 1e-3
|
| 221 |
+
X = -b[:, None] * np.stack([g[:, 0] ** 2, g[:, 1] ** 2, g[:, 2] ** 2, 2 * g[:, 0] * g[:, 1], 2 * g[:, 0] * g[:, 2],
|
| 222 |
+
2 * g[:, 1] * g[:, 2]], 1)
|
| 223 |
+
with np.errstate(divide="ignore", invalid="ignore"):
|
| 224 |
+
y = np.log(S[..., m])
|
| 225 |
+
d = np.einsum("pk,...k->...p", np.linalg.pinv(X), y)
|
| 226 |
+
D = np.stack([np.stack([d[..., 0], d[..., 3], d[..., 4]], -1), np.stack([d[..., 3], d[..., 1], d[..., 5]], -1),
|
| 227 |
+
np.stack([d[..., 4], d[..., 5], d[..., 2]], -1)], -2)
|
| 228 |
+
ok = np.isfinite(D).all(axis=(-1, -2))
|
| 229 |
+
ev = np.full(D.shape[:-1], np.nan); ev[ok] = np.linalg.eigvalsh(D[ok])
|
| 230 |
+
md = ev.mean(-1)
|
| 231 |
+
with np.errstate(divide="ignore", invalid="ignore"):
|
| 232 |
+
return np.sqrt(1.5 * ((ev - md[..., None]) ** 2).sum(-1) / (ev ** 2).sum(-1))
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def maps(dwi, meas, signal):
|
| 236 |
+
"""What a b-tensor run reconstructs, per voxel where ``signal``: ``fa`` (:func:`dti_fa` on the linear rows), ``md``
|
| 237 |
+
and ``ufa`` (:func:`md_ufa` on the powder averages), and ``powder`` ``{(encoding, b): S/S0}`` with ``bvals``, the
|
| 238 |
+
b-values the encodings share."""
|
| 239 |
+
enc, bs = np.asarray(meas.encoding), np.asarray(meas.bvals, np.float64)
|
| 240 |
+
bvals = sorted(set(float(b) for b, e in zip(bs, enc) if e == "LTE") & set(float(b) for b, e in zip(bs, enc) if e == "STE"))
|
| 241 |
+
S = np.where(signal[..., None], dwi, np.nan)
|
| 242 |
+
pw = {(e, b): np.nanmean(S[..., (enc == e) & (bs == b)], axis=-1) for e in ("LTE", "STE") for b in bvals}
|
| 243 |
+
counts = {(e, b): int(((enc == e) & (bs == b)).sum()) for e in ("LTE", "STE") for b in bvals}
|
| 244 |
+
md, ufa = md_ufa(pw, bvals, counts, n_b0=int((enc == "b0").sum()))
|
| 245 |
+
return dict(fa=dti_fa(S, enc, bs, meas.dirs), md=md, ufa=ufa, powder=pw, bvals=bvals)
|
space/viewers.py
CHANGED
|
@@ -318,3 +318,52 @@ def response_curves(true, estimated, note=""):
|
|
| 318 |
axes[1].set_title("GM and CSF" + (f" ({note})" if note else ""), fontsize=9); axes[1].legend(fontsize=7)
|
| 319 |
fig.tight_layout()
|
| 320 |
return _image(fig)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
axes[1].set_title("GM and CSF" + (f" ({note})" if note else ""), fontsize=9); axes[1].legend(fontsize=7)
|
| 319 |
fig.tight_layout()
|
| 320 |
return _image(fig)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def tensor_maps(maps, crossing, z, label="A: "):
|
| 324 |
+
"""A b-tensor run's maps at axial slice ``z``: FA (DTI on the linear rows) beside the microscopic FA, both 0 to 1
|
| 325 |
+
with the voxels the ground truth's strands cross in two or more directions (``crossing``, boolean on the grid)
|
| 326 |
+
outlined, and MD (um^2/ms)."""
|
| 327 |
+
plt = _mpl()
|
| 328 |
+
fig, axes = plt.subplots(1, 3, figsize=(11.0, 3.6))
|
| 329 |
+
cr = np.asarray(crossing, float)[:, :, z].T
|
| 330 |
+
for ax, key, title, kw in ((axes[0], "fa", "FA (DTI, linear rows)", dict(vmin=0, vmax=1, cmap="viridis")),
|
| 331 |
+
(axes[1], "ufa", "µFA (linear vs spherical powder)", dict(vmin=0, vmax=1, cmap="viridis")),
|
| 332 |
+
(axes[2], "md", "MD (µm²/ms)", dict(vmin=0, vmax=1.0, cmap="magma"))):
|
| 333 |
+
im = ax.imshow(np.asarray(maps[key], np.float64)[:, :, z].T, origin="lower", **kw)
|
| 334 |
+
if key != "md":
|
| 335 |
+
ax.contour(cr, levels=[0.5], colors="w", linewidths=0.6)
|
| 336 |
+
ax.set_xticks([]); ax.set_yticks([]); ax.set_title(label + title, fontsize=9)
|
| 337 |
+
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
| 338 |
+
fig.suptitle("white outline: voxels the ground truth's strands cross in two or more directions", fontsize=8)
|
| 339 |
+
return _image(fig)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def powder_curve(maps, ijk, other=None, labels=("A", "B")):
|
| 343 |
+
"""The powder-averaged signal of voxel ``ijk`` against b, linear vs spherical (``other``: B's maps, dashed)."""
|
| 344 |
+
plt = _mpl()
|
| 345 |
+
fig, ax = plt.subplots(figsize=(4.6, 3.4))
|
| 346 |
+
for m, ls, tag in ((maps, "-", labels[0]), (other, "--", labels[1])):
|
| 347 |
+
if m is None:
|
| 348 |
+
continue
|
| 349 |
+
b = [0.0] + list(m["bvals"])
|
| 350 |
+
for e, c in (("LTE", "C0"), ("STE", "C3")):
|
| 351 |
+
y = [1.0] + [float(m["powder"][(e, x)][tuple(ijk)]) for x in m["bvals"]]
|
| 352 |
+
ax.plot(b, y, ls + "o", color=c, ms=3, label=f"{tag} {e}")
|
| 353 |
+
ax.set_yscale("log"); ax.set_xlabel("b (s/mm²)"); ax.set_ylabel("powder S/S0")
|
| 354 |
+
ax.set_title(f"voxel {tuple(int(x) for x in ijk)}: µFA {float(maps['ufa'][tuple(ijk)]):.2f}, FA {float(maps['fa'][tuple(ijk)]):.2f}", fontsize=9)
|
| 355 |
+
ax.legend(fontsize=7)
|
| 356 |
+
fig.tight_layout()
|
| 357 |
+
return _image(fig)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def map_picker(sl, scale, *, vmin=0.0, vmax=1.0, cmap="viridis"):
|
| 361 |
+
"""A 2-D map ``sl`` (x, y) as an image ``scale`` pixels per voxel with y up and no margins, so a click's pixel names
|
| 362 |
+
its voxel exactly (``x // scale``, ``ny - 1 - y // scale``); NaN grey."""
|
| 363 |
+
from PIL import Image
|
| 364 |
+
import matplotlib
|
| 365 |
+
a = np.asarray(sl, np.float64).T[::-1] # rows top to bottom = y from high to low
|
| 366 |
+
rgba = matplotlib.colormaps[cmap]((np.clip(np.nan_to_num(a, nan=vmin), vmin, vmax) - vmin) / (vmax - vmin))
|
| 367 |
+
rgba[~np.isfinite(a)] = (0.5, 0.5, 0.5, 1.0)
|
| 368 |
+
img = (np.repeat(np.repeat(rgba[..., :3], int(scale), axis=0), int(scale), axis=1) * 255).astype(np.uint8)
|
| 369 |
+
return Image.fromarray(img)
|
tests/test_acceptance.py
CHANGED
|
@@ -1,9 +1,20 @@
|
|
| 1 |
-
"""The gate:
|
| 2 |
-
|
| 3 |
-
truth pair missed. Prints the per-stage timings (first call and steady) and writes them to ``gate.json``.
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
import json
|
| 9 |
import os
|
|
@@ -13,95 +24,100 @@ import numpy as np
|
|
| 13 |
import pytest
|
| 14 |
|
| 15 |
from space import pipeline as P
|
|
|
|
| 16 |
from space import sources
|
| 17 |
from space.sources import disco as D
|
| 18 |
|
| 19 |
CFG = P.config()
|
| 20 |
OUT = os.environ.get("GATE_OUT", os.path.join(P.ROOT, "gate.json"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
@pytest.fixture(scope="module")
|
| 24 |
def layout():
|
| 25 |
-
import jax
|
| 26 |
-
if jax.devices()[0].platform != "gpu":
|
| 27 |
-
pytest.skip("the gate runs on a GPU")
|
| 28 |
-
pytest.importorskip("huggingface_hub")
|
| 29 |
t0 = time.perf_counter()
|
| 30 |
lay = sources.source(CFG)
|
| 31 |
-
lay.warm()
|
| 32 |
lay.load_seconds = time.perf_counter() - t0
|
| 33 |
return lay
|
| 34 |
|
| 35 |
|
| 36 |
-
@pytest.
|
| 37 |
-
def
|
| 38 |
-
""
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
physics = None if run == "bare" else P.Physics.at(float(run[:-1]))
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def test_the_noiseless_pipeline_scores_within_the_references_band(layout):
|
| 58 |
-
"""Pearson vs strand count at or above the gate's floor
|
| 59 |
-
|
| 60 |
p, _ = D.disco_protocol(CFG)
|
| 61 |
tr = P.Tracking(**{k: CFG["tracking"][k] for k in ("density", "step_mm", "max_angle", "max_steps")})
|
| 62 |
-
first = P.run(layout, p, snr=None, tracking=tr)
|
| 63 |
steady = P.run(layout, p, snr=None, tracking=tr)
|
| 64 |
s = steady.score
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
seeds=int(len(steady.seeds)), n_meas=p.n_meas)
|
| 68 |
-
print("\ngate: " + json.dumps(table, indent=1))
|
| 69 |
-
with open(OUT, "w") as f:
|
| 70 |
-
json.dump(table, f, indent=1)
|
| 71 |
assert s["pearson_count"] >= CFG["gate"]["pearson_count_min"], s
|
| 72 |
assert s["missed_pairs"] <= CFG["gate"]["missed_pairs_max"], s
|
| 73 |
-
assert first.score["pearson_count"] == s["pearson_count"]
|
| 74 |
|
| 75 |
|
| 76 |
@pytest.mark.parametrize("machine", [m["key"] for m in CFG["scanners"]["machines"]])
|
| 77 |
-
def
|
| 78 |
-
"""Each machine of the menu at 7.9 cm from isocentre along R-L on DiSCo's own protocol
|
| 79 |
-
the
|
| 80 |
-
scanner at the machine's field and direction: the delivered image is finite where the ideal one is and moves it;
|
| 81 |
-
the first and steady replay seconds of both go to ``gate_scanners.json`` beside ``gate.json``. A machine with its
|
| 82 |
-
own gradient needs the layout's background moments and is skipped by name on a layout without them."""
|
| 83 |
from dataclasses import replace
|
| 84 |
L = P.limits(machine)
|
| 85 |
-
if L.has_field_law and not layout.moments.background:
|
| 86 |
-
pytest.skip(f"{machine}'s own gradient needs the layout's background moments (shape_moments.stamp_background)")
|
| 87 |
p, _ = D.disco_protocol(CFG)
|
| 88 |
ideal = P.Physics.at(float(L.field_T), b0_direction=tuple(L.b0_axis or (0.0, 0.0, 1.0)))
|
| 89 |
far = replace(ideal, scanner=machine, offset_m=(0.079, 0.0, 0.0))
|
| 90 |
m = layout.validate(p, far)
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
for k in ("first", "steady"):
|
| 94 |
-
dwi, _, factor, s = layout.replay(m, ph)
|
| 95 |
-
secs[f"{tag}_{k}"] = s
|
| 96 |
-
if tag == "ideal":
|
| 97 |
-
ref = dwi
|
| 98 |
both = np.isfinite(ref)
|
| 99 |
assert (np.isfinite(dwi) == both).all()
|
| 100 |
moved = float(np.max(np.abs(dwi - ref)[both]))
|
| 101 |
-
|
| 102 |
assert moved > 1e-3
|
| 103 |
-
path = os.path.join(os.path.dirname(OUT), "gate_scanners.json")
|
| 104 |
-
table = json.load(open(path)) if os.path.exists(path) else {}
|
| 105 |
-
table[machine] = dict(seconds=secs, max_abs_dS=moved, n_meas=p.n_meas, offset_m=list(far.offset_m), device=str(__import__("jax").devices()[0]))
|
| 106 |
-
with open(path, "w") as f:
|
| 107 |
-
json.dump(table, f, indent=1)
|
|
|
|
| 1 |
+
"""The gate: the page's replay from the time-segment layout equals the replay pack's own replay of the same rows, and
|
| 2 |
+
the noiseless pipeline scores the connectome within the reference's band with no ground-truth pair missed.
|
|
|
|
| 3 |
|
| 4 |
+
The reference (:mod:`space.reference`) is the replay pack the layout was contracted from (``[data] pack``), replayed
|
| 5 |
+
walker by walker -- every band, every field mode, the contact -- at ``[gate] reference_voxels`` voxels spread over its
|
| 6 |
+
index; the page's signal at those voxels, over the voxel's bare b = 0 signal (the quantity both certificates bound;
|
| 7 |
+
S/S0 divides it by the relaxed b = 0 signal, down to 0.3 at 7 T, and is recorded beside it), must be within
|
| 8 |
+
``[gate] reference_max_abs_diff`` of the reference's, for DiSCo's
|
| 9 |
+
own protocol bare and in the catalogue's white matter at 3 T and 7 T, and for every preset (the paper's flagship
|
| 10 |
+
acquisitions, the cosine OGSE among them) bare and at 3 T. It runs where the layout is (``DISCO_LAYOUT`` a local copy,
|
| 11 |
+
else the Hub) and reads the pack's rows at the gate's voxels (``DISCO_PACK`` a local copy, else the Hub); on a CPU
|
| 12 |
+
host every image streams the layout chunk by chunk (minutes per class), so it runs when asked:
|
| 13 |
+
|
| 14 |
+
DISCO_GATE=1 DISCO_LAYOUT=/local/layout DISCO_PACK=/local/coefficients pytest tests/test_acceptance.py -s
|
| 15 |
+
DISCO_GATE=1 JAX_PLATFORMS=cuda XLA_FLAGS=--xla_gpu_deterministic_ops=true pytest tests/test_acceptance.py -s
|
| 16 |
+
|
| 17 |
+
The per-case worst difference and timings go to ``gate.json`` (``GATE_OUT``).
|
| 18 |
"""
|
| 19 |
import json
|
| 20 |
import os
|
|
|
|
| 24 |
import pytest
|
| 25 |
|
| 26 |
from space import pipeline as P
|
| 27 |
+
from space import reference as R
|
| 28 |
from space import sources
|
| 29 |
from space.sources import disco as D
|
| 30 |
|
| 31 |
CFG = P.config()
|
| 32 |
OUT = os.environ.get("GATE_OUT", os.path.join(P.ROOT, "gate.json"))
|
| 33 |
+
pytestmark = pytest.mark.skipif(not os.environ.get("DISCO_GATE"), reason="the gate runs when asked (DISCO_GATE=1)")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _record(key, value):
|
| 37 |
+
table = json.load(open(OUT)) if os.path.exists(OUT) else {}
|
| 38 |
+
table[key] = value
|
| 39 |
+
with open(OUT, "w") as f:
|
| 40 |
+
json.dump(table, f, indent=1, default=float)
|
| 41 |
|
| 42 |
|
| 43 |
@pytest.fixture(scope="module")
|
| 44 |
def layout():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
t0 = time.perf_counter()
|
| 46 |
lay = sources.source(CFG)
|
|
|
|
| 47 |
lay.load_seconds = time.perf_counter() - t0
|
| 48 |
return lay
|
| 49 |
|
| 50 |
|
| 51 |
+
@pytest.fixture(scope="module")
|
| 52 |
+
def pack():
|
| 53 |
+
pk = R.open_pack(os.environ.get("DISCO_PACK") or CFG["data"]["pack"])
|
| 54 |
+
return pk, R.gate_voxels(pk, CFG["gate"]["reference_voxels"])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
CASES = [(D.DISCO_364, run) for run in ("bare", "3T", "7T")] + [(name, run) for name in CFG["presets"] for run in ("bare", "3T")]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@pytest.mark.parametrize("preset,run", CASES)
|
| 61 |
+
def test_the_layout_is_the_replay_packs_replay_at_the_gates_voxels(layout, pack, preset, run):
|
| 62 |
+
"""At the gate's voxels: the page's replay through the layout (its classes' waveforms, its tiers) is the pack's replay
|
| 63 |
+
of the same rows, to ``reference_max_abs_diff`` in units of the voxel's bare b = 0 signal."""
|
| 64 |
+
pk, voxels = pack
|
| 65 |
+
protocol = D.Disco.protocol(CFG, preset)
|
| 66 |
physics = None if run == "bare" else P.Physics.at(float(run[:-1]))
|
| 67 |
+
meas = layout.validate(protocol, physics)
|
| 68 |
+
t0 = time.perf_counter()
|
| 69 |
+
dwi, floor, factor, secs = layout.replay(meas, physics)
|
| 70 |
+
tissue, scanner = P.tissue_and_scanner(physics, layout.unseeded)
|
| 71 |
+
ref = np.zeros((len(voxels), len(meas.bvals)))
|
| 72 |
+
t1 = time.perf_counter()
|
| 73 |
+
for name in np.unique(meas.shape):
|
| 74 |
+
rows = meas.shape == name
|
| 75 |
+
ref[:, rows] = R.replay_at(pk, voxels, layout.sequence(name, meas.bvals[rows], meas.dirs[rows], None if meas.encoding is None else meas.encoding[rows]), tissue, scanner)
|
| 76 |
+
t_ref = time.perf_counter() - t1
|
| 77 |
+
ref_S0 = ref[:, meas.b0].mean(1, keepdims=True)
|
| 78 |
+
got = np.stack([dwi[tuple(v)] for v in voxels])
|
| 79 |
+
d = np.abs(got - ref / ref_S0) # S/S0
|
| 80 |
+
d_S = np.abs(got * ref_S0 - ref) # the signal over the voxel's bare b = 0 signal: what the certificates bound
|
| 81 |
+
worst, worst_S = float(np.nanmax(d)), float(np.nanmax(d_S))
|
| 82 |
+
print(f"\n{preset} {run}: max |dS| {worst_S:.2e} (S over the bare b = 0 signal), max |d(S/S0)| {worst:.2e} over "
|
| 83 |
+
f"{len(voxels)} voxels x {len(meas.bvals)} measurements (S0 down to {float(ref_S0.min()):.3f}); "
|
| 84 |
+
f"layout replay {secs:.1f} s, reference {t_ref:.1f} s")
|
| 85 |
+
_record(f"{preset} | {run}", dict(worst_S=worst_S, worst_S_over_S0=worst, median_S=float(np.nanmedian(d_S)), S0_min=float(ref_S0.min()),
|
| 86 |
+
voxels=len(voxels), n_meas=int(len(meas.bvals)), replay_seconds=secs, reference_seconds=t_ref,
|
| 87 |
+
bound=CFG["gate"]["reference_max_abs_diff"], manifest_sha256=layout.manifest_sha256))
|
| 88 |
+
assert np.isfinite(got).all(), "a gate voxel the pack has rows in has no signal from the layout"
|
| 89 |
+
assert worst_S < CFG["gate"]["reference_max_abs_diff"]
|
| 90 |
|
| 91 |
|
| 92 |
def test_the_noiseless_pipeline_scores_within_the_references_band(layout):
|
| 93 |
+
"""Pearson vs strand count at or above the gate's floor and no ground-truth pair missed on DiSCo's own protocol;
|
| 94 |
+
the timings recorded (first call with every compile, then steady)."""
|
| 95 |
p, _ = D.disco_protocol(CFG)
|
| 96 |
tr = P.Tracking(**{k: CFG["tracking"][k] for k in ("density", "step_mm", "max_angle", "max_steps")})
|
| 97 |
+
first = P.run(layout, p, snr=None, tracking=tr)
|
| 98 |
steady = P.run(layout, p, snr=None, tracking=tr)
|
| 99 |
s = steady.score
|
| 100 |
+
_record("pipeline", dict(layout_load_seconds=layout.load_seconds, first_call=first.seconds, steady=steady.seconds, score=s,
|
| 101 |
+
streamlines=len(steady.tractogram), seeds=int(len(steady.seeds)), n_meas=p.n_meas))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
assert s["pearson_count"] >= CFG["gate"]["pearson_count_min"], s
|
| 103 |
assert s["missed_pairs"] <= CFG["gate"]["missed_pairs_max"], s
|
| 104 |
+
assert first.score["pearson_count"] == s["pearson_count"]
|
| 105 |
|
| 106 |
|
| 107 |
@pytest.mark.parametrize("machine", [m["key"] for m in CFG["scanners"]["machines"]])
|
| 108 |
+
def test_a_machine_on_discos_protocol_moves_the_image(layout, machine):
|
| 109 |
+
"""Each machine of the menu at 7.9 cm from isocentre along R-L on DiSCo's own protocol against the ideal scanner at
|
| 110 |
+
the machine's field and direction: the delivered image is finite where the ideal one is and moves it."""
|
|
|
|
|
|
|
|
|
|
| 111 |
from dataclasses import replace
|
| 112 |
L = P.limits(machine)
|
|
|
|
|
|
|
| 113 |
p, _ = D.disco_protocol(CFG)
|
| 114 |
ideal = P.Physics.at(float(L.field_T), b0_direction=tuple(L.b0_axis or (0.0, 0.0, 1.0)))
|
| 115 |
far = replace(ideal, scanner=machine, offset_m=(0.079, 0.0, 0.0))
|
| 116 |
m = layout.validate(p, far)
|
| 117 |
+
ref, *_ = layout.replay(m, ideal)
|
| 118 |
+
dwi, _, _, secs = layout.replay(m, far)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
both = np.isfinite(ref)
|
| 120 |
assert (np.isfinite(dwi) == both).all()
|
| 121 |
moved = float(np.max(np.abs(dwi - ref)[both]))
|
| 122 |
+
_record(f"machine | {machine}", dict(max_abs_dS=moved, seconds=secs))
|
| 123 |
assert moved > 1e-3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_app.py
CHANGED
|
@@ -8,6 +8,7 @@ import pytest
|
|
| 8 |
|
| 9 |
from space import app as A
|
| 10 |
from space import pipeline as P
|
|
|
|
| 11 |
from space.sources import disco as D
|
| 12 |
|
| 13 |
CFG = P.config()
|
|
@@ -21,8 +22,8 @@ def test_the_three_kinds_of_acquisition():
|
|
| 21 |
assert disco.name == "DiSCo 364" and disco.n_meas == 364 and disco.n_b0 == 4 # the table's own, not the slider's
|
| 22 |
name = next(iter(CFG["presets"]))
|
| 23 |
preset = A._protocol_from_inputs(S, CFG, name, 5, *ROWS)
|
| 24 |
-
assert preset.name == name and preset.n_b0 == CFG
|
| 25 |
-
assert
|
| 26 |
custom = A._protocol_from_inputs(S, CFG, A.CUSTOM, 2, *ROWS)
|
| 27 |
assert custom.n_b0 == 2 and [(s.shape, s.b, s.n_dirs) for s in custom.shells] == [("d12-D24", 1000.0, 30), ("d8-D20", 3000.0, 45)]
|
| 28 |
assert not any(s.free_timing for s in custom.shells)
|
|
@@ -68,7 +69,8 @@ def test_a_knob_changes_one_thing_of_a():
|
|
| 68 |
v = _values()
|
| 69 |
prot = A._protocol_from_inputs(S, CFG, A.CUSTOM, 2, *ROWS)
|
| 70 |
ks = S.knobs(CFG)
|
| 71 |
-
|
|
|
|
| 72 |
for name, change in ks.items():
|
| 73 |
if change is None:
|
| 74 |
continue
|
|
@@ -215,11 +217,14 @@ def test_the_estimated_seconds_grow_with_the_run_and_stay_in_the_pools_window():
|
|
| 215 |
small = A.estimated_seconds("clinical b1000 x 30", 1, True, 30, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, False, *v, *ROWS)
|
| 216 |
assert 30 <= small < one < two <= 480 and one < keys <= 480 and one < ladder <= 480
|
| 217 |
both = A.estimated_seconds("DiSCo 364", 1, True, 30, 2, 30.0, 0.5, 0, None, "SNR → 10", A.IDEAL, 1, True, *v, *ROWS)
|
| 218 |
-
assert one < ladder < both <=
|
|
|
|
|
|
|
|
|
|
| 219 |
assert A.estimated_seconds("nonsense", 1, True, 30, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, True, *v, *ROWS) == 480
|
| 220 |
text = A.gpu_seconds_text("DiSCo 364", 1, True, 30, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, True, *v, *ROWS)
|
| 221 |
-
assert f"reserves {ladder} s" in text and "logged out" in text.split("started by")[1].split(".")[0]
|
| 222 |
-
assert len(A.OUTPUTS) == 30
|
| 223 |
|
| 224 |
|
| 225 |
def test_the_explorer_draws_layers_differences_and_metrics_from_its_state():
|
|
@@ -230,15 +235,16 @@ def test_the_explorer_draws_layers_differences_and_metrics_from_its_state():
|
|
| 230 |
rng = np.random.default_rng(0)
|
| 231 |
bare = rng.uniform(0.3, 1.0, (4, 4, 2, 13)).astype(np.float16); a = (bare * 0.98).astype(np.float16); b = (bare * 0.97).astype(np.float16)
|
| 232 |
md = rng.random((4, 4, 2)).astype(np.float16); fa = rng.random((4, 4, 2)).astype(np.float16)
|
| 233 |
-
ex = dict(layers=[("bare diffusion", bare), ("A = bare + field", a), ("B = A with field → 7 T", b)], metrics={k: (md, fa) for k in ("bare diffusion", "A = bare + field", "B = A with field → 7 T")},
|
| 234 |
floor=np.full((4, 4, 2), 0.01, np.float32), meas=m, mask=np.ones((4, 4, 2), bool),
|
| 235 |
ingredients=S.ingredient_layers(dict(intra_fraction=rng.random((4, 4, 2)), wall_contact_um=rng.random((4, 4, 2)), contact_survival=None, field_rad=None, D_walk=6e-10)),
|
| 236 |
differences=P.layer_differences([("bare diffusion", bare.astype(np.float32)), ("A", a.astype(np.float32)), ("B", b.astype(np.float32))], m, np.ones((4, 4, 2), bool)),
|
| 237 |
snr=None, physics=None)
|
| 238 |
for mode in A.EXPLORE_MODES:
|
| 239 |
-
for metric in A.METRICS:
|
| 240 |
img = A.explore(ex, "A = bare + field", mode, metric, 1, 5)
|
| 241 |
assert img is not None and img.size[0] > 100, (mode, metric)
|
|
|
|
| 242 |
assert A.explore(ex, "bare diffusion", "minus the previous layer", A.METRICS[0], 0, 0) is not None
|
| 243 |
pool, contact, fld = A.ingredient_views(ex, 1)
|
| 244 |
assert pool is not None and contact is not None and fld is None
|
|
@@ -283,3 +289,82 @@ def test_the_payload_carries_float32_volumes():
|
|
| 283 |
assert light.dwi.dtype == np.float32 and light.clean.dtype == np.float32 and res.dwi.dtype == np.float64
|
| 284 |
np.testing.assert_allclose(light.dwi, dwi, rtol=1e-6); assert light.snr == 30.0 and light.meas is m
|
| 285 |
assert A._light(P.Result(p, m, dwi, floor, None, None, None, None, None, {})).clean is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
from space import app as A
|
| 10 |
from space import pipeline as P
|
| 11 |
+
from space import viewers as V
|
| 12 |
from space.sources import disco as D
|
| 13 |
|
| 14 |
CFG = P.config()
|
|
|
|
| 22 |
assert disco.name == "DiSCo 364" and disco.n_meas == 364 and disco.n_b0 == 4 # the table's own, not the slider's
|
| 23 |
name = next(iter(CFG["presets"]))
|
| 24 |
preset = A._protocol_from_inputs(S, CFG, name, 5, *ROWS)
|
| 25 |
+
assert preset.name == name and preset.n_b0 == P.preset_protocol(CFG, name).n_b0 == 3 # the table's own
|
| 26 |
+
assert {s.shape for s in preset.shells} == {CFG["presets"][name]["shape"]}
|
| 27 |
custom = A._protocol_from_inputs(S, CFG, A.CUSTOM, 2, *ROWS)
|
| 28 |
assert custom.n_b0 == 2 and [(s.shape, s.b, s.n_dirs) for s in custom.shells] == [("d12-D24", 1000.0, 30), ("d8-D20", 3000.0, 45)]
|
| 29 |
assert not any(s.free_timing for s in custom.shells)
|
|
|
|
| 69 |
v = _values()
|
| 70 |
prot = A._protocol_from_inputs(S, CFG, A.CUSTOM, 2, *ROWS)
|
| 71 |
ks = S.knobs(CFG)
|
| 72 |
+
timing = [n for n, sh in CFG["shapes"].items() if sh.get("kind") != "tensor"]
|
| 73 |
+
assert ks[A.NO_KNOB] is None and len(ks) == 1 + 5 + 2 + 3 + 1 + 3 + len(timing)
|
| 74 |
for name, change in ks.items():
|
| 75 |
if change is None:
|
| 76 |
continue
|
|
|
|
| 217 |
small = A.estimated_seconds("clinical b1000 x 30", 1, True, 30, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, False, *v, *ROWS)
|
| 218 |
assert 30 <= small < one < two <= 480 and one < keys <= 480 and one < ladder <= 480
|
| 219 |
both = A.estimated_seconds("DiSCo 364", 1, True, 30, 2, 30.0, 0.5, 0, None, "SNR → 10", A.IDEAL, 1, True, *v, *ROWS)
|
| 220 |
+
assert one <= 120 < ladder < both <= 480 # DiSCo alone fits a logged-out visitor's quota; the ladder and B need an account
|
| 221 |
+
bt = next(n for n, p in CFG["presets"].items() if "tensor" in p)
|
| 222 |
+
tensor = A.estimated_seconds(bt, 1, True, 100, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, False, *v, *ROWS)
|
| 223 |
+
assert small < tensor <= 120 # 77 rows on six terms: 23 s of replay measured on the pool
|
| 224 |
assert A.estimated_seconds("nonsense", 1, True, 30, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, True, *v, *ROWS) == 480
|
| 225 |
text = A.gpu_seconds_text("DiSCo 364", 1, True, 30, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, True, *v, *ROWS)
|
| 226 |
+
assert f"reserves {ladder} s" in text and "logged out" not in text.split("started by")[1].split(".")[0]
|
| 227 |
+
assert len(A.OUTPUTS) == 36 # the 30 of a connectome run and the b-tensor run's six
|
| 228 |
|
| 229 |
|
| 230 |
def test_the_explorer_draws_layers_differences_and_metrics_from_its_state():
|
|
|
|
| 235 |
rng = np.random.default_rng(0)
|
| 236 |
bare = rng.uniform(0.3, 1.0, (4, 4, 2, 13)).astype(np.float16); a = (bare * 0.98).astype(np.float16); b = (bare * 0.97).astype(np.float16)
|
| 237 |
md = rng.random((4, 4, 2)).astype(np.float16); fa = rng.random((4, 4, 2)).astype(np.float16)
|
| 238 |
+
ex = dict(layers=[("bare diffusion", bare), ("A = bare + field", a), ("B = A with field → 7 T", b)], metrics={k: (md, fa, None) for k in ("bare diffusion", "A = bare + field", "B = A with field → 7 T")},
|
| 239 |
floor=np.full((4, 4, 2), 0.01, np.float32), meas=m, mask=np.ones((4, 4, 2), bool),
|
| 240 |
ingredients=S.ingredient_layers(dict(intra_fraction=rng.random((4, 4, 2)), wall_contact_um=rng.random((4, 4, 2)), contact_survival=None, field_rad=None, D_walk=6e-10)),
|
| 241 |
differences=P.layer_differences([("bare diffusion", bare.astype(np.float32)), ("A", a.astype(np.float32)), ("B", b.astype(np.float32))], m, np.ones((4, 4, 2), bool)),
|
| 242 |
snr=None, physics=None)
|
| 243 |
for mode in A.EXPLORE_MODES:
|
| 244 |
+
for metric in A.METRICS[:3]:
|
| 245 |
img = A.explore(ex, "A = bare + field", mode, metric, 1, 5)
|
| 246 |
assert img is not None and img.size[0] > 100, (mode, metric)
|
| 247 |
+
assert A.explore(ex, "A = bare + field", "signal", A.METRICS[3], 1, 5) is None # no µFA off a b-tensor run
|
| 248 |
assert A.explore(ex, "bare diffusion", "minus the previous layer", A.METRICS[0], 0, 0) is not None
|
| 249 |
pool, contact, fld = A.ingredient_views(ex, 1)
|
| 250 |
assert pool is not None and contact is not None and fld is None
|
|
|
|
| 289 |
assert light.dwi.dtype == np.float32 and light.clean.dtype == np.float32 and res.dwi.dtype == np.float64
|
| 290 |
np.testing.assert_allclose(light.dwi, dwi, rtol=1e-6); assert light.snr == 30.0 and light.meas is m
|
| 291 |
assert A._light(P.Result(p, m, dwi, floor, None, None, None, None, None, {})).clean is None
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _tensor_rs():
|
| 295 |
+
"""A results state of a b-tensor run on a 6 x 5 x 2 grid: A and B, their maps, the measurement rows."""
|
| 296 |
+
p = P.preset_protocol(CFG, next(n for n, v in CFG["presets"].items() if "tensor" in v))
|
| 297 |
+
m = P.measurements(p, CFG["shapes"])
|
| 298 |
+
rng = np.random.default_rng(1)
|
| 299 |
+
bvals = sorted(set(float(b) for b in m.bvals if b > 0))
|
| 300 |
+
|
| 301 |
+
def maps(scale):
|
| 302 |
+
return dict(fa=rng.random((6, 5, 2)) * scale, md=rng.random((6, 5, 2)), ufa=rng.random((6, 5, 2)) * scale, bvals=bvals,
|
| 303 |
+
powder={(e, b): np.exp(-b * 1e-3 * (0.5 if e == "LTE" else 0.7)) * np.ones((6, 5, 2)) for e in ("LTE", "STE") for b in bvals})
|
| 304 |
+
dwi = rng.uniform(0.2, 1.0, (6, 5, 2, len(m.bvals))).astype(np.float32)
|
| 305 |
+
run = lambda s: dict(dwi=dwi, floor=np.full((6, 5, 2), 0.01, np.float32), floor_median=0.01, meas=m, peaks=None, peak_amp=None,
|
| 306 |
+
extras=dict(fractions=None), tensor=A._tensor_state(maps(s)))
|
| 307 |
+
return dict(A=run(1.0), B=run(0.5)), m
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def test_a_b_tensor_runs_rows_maps_and_voxel_click():
|
| 311 |
+
"""On a b-tensor run the measurement slider counts within the chosen encoding; the µFA slice is a picker whose
|
| 312 |
+
click pixel names its voxel (y up); the clicked voxel's powder curves draw for A and B; the maps draw for both."""
|
| 313 |
+
rs, m = _tensor_rs()
|
| 314 |
+
enc = np.asarray(m.encoding)
|
| 315 |
+
assert A.row_of(m, "every row", 3) == 3
|
| 316 |
+
assert enc[A.row_of(m, "STE", 0)] == "STE" and enc[A.row_of(m, "LTE", 10 ** 6)] == "LTE"
|
| 317 |
+
assert A.row_of(m, "STE", 0) == int(np.flatnonzero(enc == "STE")[0])
|
| 318 |
+
pick = V.map_picker(rs["A"]["tensor"]["ufa"][:, :, 1], A.PICK)
|
| 319 |
+
assert pick.size == (6 * A.PICK, 5 * A.PICK)
|
| 320 |
+
seen = []
|
| 321 |
+
import space.viewers as VV
|
| 322 |
+
orig = VV.powder_curve
|
| 323 |
+
try:
|
| 324 |
+
VV.powder_curve = lambda tm, ijk, other=None: seen.append((tuple(ijk), other is not None)) or "img"
|
| 325 |
+
evt = types.SimpleNamespace(index=(2 * A.PICK + 3, 0)) # x in voxel 2, the top row = y 4
|
| 326 |
+
assert A.pick_voxel(rs, 1, evt) == "img" and seen[-1] == ((2, 4, 1), True)
|
| 327 |
+
finally:
|
| 328 |
+
VV.powder_curve = orig
|
| 329 |
+
assert V.powder_curve(rs["A"]["tensor"], (1, 1, 0), other=rs["B"]["tensor"]).size[0] > 100
|
| 330 |
+
n_dirs = np.ones((6, 5, 2), int); n_dirs[2:4, 1:3] = 2
|
| 331 |
+
A._CROSSINGS["c"] = (n_dirs, np.ones((6, 5, 2)))
|
| 332 |
+
try:
|
| 333 |
+
a, picker, b = A.tensor_views(rs, 0)
|
| 334 |
+
assert a is not None and picker is not None and b is not None
|
| 335 |
+
finally:
|
| 336 |
+
A._CROSSINGS.clear()
|
| 337 |
+
assert A.tensor_views(dict(A=dict(tensor=None)), 0) == (None, None, None)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def test_the_explorer_on_a_b_tensor_run_draws_the_encodings_rows_and_b_minus_a_of_mufa():
|
| 341 |
+
rs, m = _tensor_rs()
|
| 342 |
+
a = rs["A"]["tensor"]; b = rs["B"]["tensor"]
|
| 343 |
+
ex = dict(layers=[("A = bare diffusion", rs["A"]["dwi"].astype(np.float16)), ("B = A with SNR → 10", rs["B"]["dwi"].astype(np.float16))],
|
| 344 |
+
metrics={"A = bare diffusion": (a["md"], a["fa"], a["ufa"]), "B = A with SNR → 10": (b["md"], b["fa"], b["ufa"])},
|
| 345 |
+
floor=np.full((6, 5, 2), 0.01, np.float32), meas=m, mask=np.ones((6, 5, 2), bool), ingredients=[None, None, None],
|
| 346 |
+
differences=[], snr=None, physics=None)
|
| 347 |
+
for metric in A.METRICS:
|
| 348 |
+
for mode in A.EXPLORE_MODES:
|
| 349 |
+
assert A.explore(ex, "A = bare diffusion", mode, metric, 1, 2, "STE") is not None, (metric, mode)
|
| 350 |
+
assert A.explore(ex, "A = bare diffusion", "B − A", A.METRICS[3], 0, 0) is not None
|
| 351 |
+
with pytest.raises(ValueError, match="encodings of its one class"):
|
| 352 |
+
P.retime(P.preset_protocol(CFG, next(n for n, v in CFG["presets"].items() if "tensor" in v)), "d12-D24")
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def test_every_stage_of_a_run_has_its_text():
|
| 356 |
+
"""The progress text names every stage either kind of run passes (the connectome's and the b-tensor's)."""
|
| 357 |
+
texts = S.describe(CFG)["stages"]
|
| 358 |
+
assert set(P.TENSOR_STAGES) <= set(texts) and set(D.Disco.STAGES) <= set(texts)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def test_a_b_tensor_headline_reads_the_strand_voxels_by_crossing_count(monkeypatch):
|
| 362 |
+
rs, m = _tensor_rs()
|
| 363 |
+
n_dirs = np.zeros((6, 5, 2), int); n_dirs[:2] = 1; n_dirs[2:4] = 2; n_dirs[4:] = 3
|
| 364 |
+
length = np.ones((6, 5, 2)); length[0] = 0
|
| 365 |
+
monkeypatch.setitem(A._CROSSINGS, "c", (n_dirs, length))
|
| 366 |
+
tm = rs["A"]["tensor"]
|
| 367 |
+
res = types.SimpleNamespace(extras=dict(tensor=tm), physics=None, dwi=rs["A"]["dwi"], snr=30.0, seconds=dict(total=1.0),
|
| 368 |
+
protocol=types.SimpleNamespace(n_meas=len(m.bvals)), floor=rs["A"]["floor"])
|
| 369 |
+
text = A.tensor_text("A", res)
|
| 370 |
+
assert "voxels the strands cross" in text and "2 directions" in text and "3+ directions" in text and "SNR 30" in text
|
tests/test_brain.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""The brain source: the asset's layout (the BATMAN fixture of ``tools/build_brain_fixture.py``, local only), the
|
| 2 |
-
kernel-route replay against dmipy-
|
| 3 |
tractogram, the reservation model, the panel and its knobs, the refusals, and the page's chain on a small crop of the
|
| 4 |
fixture. Skipped where the fixture or the packs are not on this machine; ``DISCO_BRAIN_FIXTURE`` and
|
| 5 |
``DISCO_BRAIN_PACKS`` point at them."""
|
|
@@ -12,12 +12,12 @@ import pytest
|
|
| 12 |
from space import pipeline as P
|
| 13 |
from space.sources import brain as B
|
| 14 |
|
| 15 |
-
FIXTURE = os.environ.get("DISCO_BRAIN_FIXTURE",
|
| 16 |
-
PACKS = os.environ.get("DISCO_BRAIN_PACKS",
|
| 17 |
WM_PACK = os.path.join(PACKS, "single_bundle_1s_c3_seg125ms.rpk")
|
| 18 |
GM_PACK = os.path.join(PACKS, "gm_spheres_100ms.rpk")
|
| 19 |
-
has_fixture = pytest.mark.skipif(not os.path.exists(os.path.join(FIXTURE, "manifest.json")), reason="
|
| 20 |
-
has_packs = pytest.mark.skipif(not (os.path.exists(WM_PACK) and os.path.exists(GM_PACK)), reason="
|
| 21 |
|
| 22 |
|
| 23 |
def brain_cfg(asset=FIXTURE):
|
|
@@ -63,9 +63,9 @@ def test_the_fixture_loads_in_the_assets_layout(brain):
|
|
| 63 |
|
| 64 |
def compose_reference(brain, meas, ph, *, scanner, n=200):
|
| 65 |
"""``(got, ref)`` on ``n`` voxels drawn with seed 0, in M0 units: the page's contraction (:meth:`Brain.prepare`,
|
| 66 |
-
:meth:`Brain.replay`) and dmipy-
|
| 67 |
same packs as loaded, tissue, fractions and FODs, NaN where a voxel carries no signal."""
|
| 68 |
-
from
|
| 69 |
dwi, floor, factor, _ = brain.replay(meas, ph, brain.prepare(meas, ph))
|
| 70 |
assert floor.max() == 0 and np.nanmax(dwi) <= 1 + 1e-5
|
| 71 |
pick = np.sort(np.random.default_rng(0).choice(len(brain.vox), n, replace=False))
|
|
@@ -90,7 +90,7 @@ def compose_reference(brain, meas, ph, *, scanner, n=200):
|
|
| 90 |
|
| 91 |
@has_fixture
|
| 92 |
@has_packs
|
| 93 |
-
def
|
| 94 |
"""At the scan's protocol, 3 T with every tier on (relaxation, contact, the sheath's field along the bore), noise
|
| 95 |
off: the Space's float32 contraction over 200 voxels equals ``Phantom.compose(...).replay(seq, pose=...)`` of the
|
| 96 |
same packs, tissue, fractions and FODs to 1e-5 in M0 units, the field given as ``scanner=ph.scanner_field``."""
|
|
@@ -106,13 +106,13 @@ def test_the_replay_is_dmipy_sims_phantom_composition(brain):
|
|
| 106 |
|
| 107 |
@pytest.mark.skipif(not os.environ.get("DISCO_HUB_TESTS"), reason="reads the Hub: set DISCO_HUB_TESTS=1")
|
| 108 |
@has_fixture
|
| 109 |
-
def
|
| 110 |
"""The page's default packs (brain.toml's first menu entries: the WM walk of 1 s in 125 ms windows from several
|
| 111 |
seeded walks, the GM walk of 250 ms in 125 ms windows), read from the Hub by window, every tier on at 3 T: a
|
| 112 |
class within window 0 loads ``windows=range(1)`` and the page's contraction over 200 voxels equals
|
| 113 |
``Phantom.compose(...).replay`` of the same packs to float precision (1e-6 in M0 units; measured 7e-8). The
|
| 114 |
sheath's field is the composition's ``scanner=ph.scanner_field``; the same composition without it
|
| 115 |
-
(``scanner=None``, the ideal machine's key ``ph.scanner``) is refused by dmipy-
|
| 116 |
susceptibility needs a field to act in (disco-space#36)."""
|
| 117 |
cfg = P.config(os.path.join(P.HERE, "brain.toml"))
|
| 118 |
cfg["asset"] = {"local": FIXTURE}
|
|
@@ -428,16 +428,19 @@ def test_the_asset_as_the_masivar_build_writes_it_loads(tmp_path):
|
|
| 428 |
|
| 429 |
@pytest.fixture(scope="module")
|
| 430 |
def two_windows(tmp_path_factory):
|
| 431 |
-
"""A cylinder walk of 200 ms stored in two 100 ms windows (300 walkers,
|
| 432 |
-
the same pack truncated to its first window (no re-encode), both on disk: ``(two, one)`` paths."""
|
| 433 |
import dmipy_sim as d
|
| 434 |
-
from
|
| 435 |
from dmipy_sim.replay.bank import build_replay_pack
|
|
|
|
| 436 |
out = tmp_path_factory.mktemp("windows")
|
| 437 |
-
|
|
|
|
|
|
|
| 438 |
two = str(out / "two.rpk"); one = str(out / "one.rpk")
|
| 439 |
-
build_replay_pack(walk, id="test/two-windows", license="x", citation="x", K=48,
|
| 440 |
-
read_rpk(two)
|
| 441 |
return two, one
|
| 442 |
|
| 443 |
|
|
@@ -447,6 +450,10 @@ def windowed(two_windows):
|
|
| 447 |
if not os.path.exists(os.path.join(FIXTURE, "manifest.json")):
|
| 448 |
pytest.skip("the BATMAN brain fixture is not on this machine")
|
| 449 |
two, one = two_windows
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
cfg = brain_cfg()
|
| 451 |
menu = [{"label": "two", "uri": two}, {"label": "one", "uri": one}]
|
| 452 |
cfg["packs"] = {"wm": list(menu), "gm": list(menu)}
|
|
@@ -495,8 +502,8 @@ def test_a_hub_pack_loads_the_windows_reached_and_reloads_for_more(windowed, two
|
|
| 495 |
loads nothing, a request for two reloads with ``range(2)`` and replaces the cached pack; a local path loads whole.
|
| 496 |
(The library's window-ranged read is stood in for by the fixture stamped with the windows asked for;
|
| 497 |
``test_the_hub_fixture_loads_by_window`` reads the Hub.)"""
|
| 498 |
-
import
|
| 499 |
-
from
|
| 500 |
two, _ = two_windows
|
| 501 |
uri = "hf://SubstrateCommons/test/packs/two.rpk"
|
| 502 |
calls = []
|
|
@@ -532,8 +539,7 @@ def test_a_hub_pack_loads_by_window(windowed, tmp_path, monkeypatch):
|
|
| 532 |
byte range into an empty Hub cache (the library keeps a window read there and serves a later, smaller request
|
| 533 |
from it): ``windows=range(1)`` for a class within window 0, then ``range(2)`` when a class reaching window 1 asks,
|
| 534 |
which replaces the cached pack."""
|
| 535 |
-
|
| 536 |
-
monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_CACHE", str(tmp_path))
|
| 537 |
cfg = P.config(os.path.join(P.HERE, "brain.toml"))
|
| 538 |
label, uri = next(iter(B.Brain.pack_menu(cfg, "gm").items()))
|
| 539 |
src = B.Brain.__new__(B.Brain)
|
|
@@ -643,7 +649,7 @@ def test_the_warm_set_has_one_entry_per_response_and_the_high_fields_last():
|
|
| 643 |
@has_fixture
|
| 644 |
@has_packs
|
| 645 |
def test_a_machine_plays_every_pulse_at_its_slew_and_field():
|
| 646 |
-
"""``BrainPhysics.scanner`` names a machine of dmipy-
|
| 647 |
slew (the same b), its field and direction replace the panel's, the response key tells the machines apart, the label
|
| 648 |
names it, a magnet with its own gradient (the Swoop) is refused by name, and a name outside the catalogue too."""
|
| 649 |
cfg = brain_cfg()
|
|
@@ -723,7 +729,7 @@ def test_a_left_handed_brains_bore_maps_are_the_catalogues_field_at_its_affines_
|
|
| 723 |
its transmit scale and its own gradient, the Prisma's gradient nonlinearity -- at every sampled brain voxel equal
|
| 724 |
the catalogue's law at ``affine @ [ijk, 1]`` from the head centre, on both sides of the midline (a mirrored head
|
| 725 |
would read the law at the wrong side's places); its left-handed and right-handed copies give every voxel the same values."""
|
| 726 |
-
from
|
| 727 |
asset = left_handed_asset(tmp_path)
|
| 728 |
left = B.Brain(brain_cfg(asset), asset=asset)
|
| 729 |
right = B.Brain(brain_cfg(), asset=FIXTURE)
|
|
@@ -760,9 +766,9 @@ def test_a_machine_plays_each_voxel_its_encoding_class_and_a_representative_exac
|
|
| 760 |
within the configured tolerance of its class's), and a class's first member -- the voxel whose exact values the
|
| 761 |
class was composed from -- gets exactly what its own delivered acquisition, expanded by the packs and composed with
|
| 762 |
its FOD and fractions, gives: the replay to 1e-5 in M0 units; the machine moves the image off the ideal scanner's."""
|
| 763 |
-
from
|
| 764 |
-
from
|
| 765 |
-
from
|
| 766 |
cfg = brain_cfg()
|
| 767 |
src = B.Brain(cfg, asset=FIXTURE)
|
| 768 |
values = default_values(cfg)
|
|
@@ -793,8 +799,8 @@ def test_a_machine_plays_each_voxel_its_encoding_class_and_a_representative_exac
|
|
| 793 |
for v in reps:
|
| 794 |
_, own = encoding_classes(P.limits(ph.scanner), grid, src.sequence(meas, rows, np.eye(3), ph.slew_rate), idx[v:v + 1], tolerance=None)
|
| 795 |
seq = rotate_waveform(own[0], pose)
|
| 796 |
-
Cw = wm_pack.pose_responses([seq], tissue=t_wm, scanner=ph.scanner_field, pose=pose, keep=(B.LMAX, 0))[0].retained(B.LMAX, 0) @ B.axis_map()
|
| 797 |
-
eg = gm_pack.pose_responses([seq], tissue=t_gm, scanner=ph.scanner_field, pose=pose, keep=(0, 0))[0].retained(0, 0)[:, 0] * B.axis_map()[0, 0] * B.C00
|
| 798 |
ec = FreeWater(m0=1.0, tissue=t_csf).response(seq)
|
| 799 |
f = src.f[v].astype(np.float64); m0 = ph.m0
|
| 800 |
ref = np.abs(f[0] * m0["wm"] * (src.fod_unit[v].astype(np.float64) @ Cw.T) + f[1] * m0["gm"] * eg + f[2] * m0["csf"] * ec)
|
|
@@ -817,4 +823,113 @@ def test_the_warm_set_holds_the_scan_on_every_machine_that_can_play_it():
|
|
| 817 |
got = {ph.scanner: ph for m, ph in src.warm_entries() if ph.scanner is not None}
|
| 818 |
assert set(got) == can and can
|
| 819 |
for key, ph in got.items():
|
| 820 |
-
assert ph.field_T == pytest.approx(P.limits(key).field_T) and ph.slew_rate == pytest.approx(P.limits(key).slew_max)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""The brain source: the asset's layout (the BATMAN fixture of ``tools/build_brain_fixture.py``, local only), the
|
| 2 |
+
kernel-route replay against dmipy-replay's own ``Phantom.compose(...).replay`` (the oracle), the connectome of a known
|
| 3 |
tractogram, the reservation model, the panel and its knobs, the refusals, and the page's chain on a small crop of the
|
| 4 |
fixture. Skipped where the fixture or the packs are not on this machine; ``DISCO_BRAIN_FIXTURE`` and
|
| 5 |
``DISCO_BRAIN_PACKS`` point at them."""
|
|
|
|
| 12 |
from space import pipeline as P
|
| 13 |
from space.sources import brain as B
|
| 14 |
|
| 15 |
+
FIXTURE = os.environ.get("DISCO_BRAIN_FIXTURE", "") # the directory tools/build_brain_fixture.py wrote
|
| 16 |
+
PACKS = os.environ.get("DISCO_BRAIN_PACKS", "") # the directory holding the WM and GM replay packs
|
| 17 |
WM_PACK = os.path.join(PACKS, "single_bundle_1s_c3_seg125ms.rpk")
|
| 18 |
GM_PACK = os.path.join(PACKS, "gm_spheres_100ms.rpk")
|
| 19 |
+
has_fixture = pytest.mark.skipif(not os.path.exists(os.path.join(FIXTURE, "manifest.json")), reason="DISCO_BRAIN_FIXTURE does not name a BATMAN brain fixture")
|
| 20 |
+
has_packs = pytest.mark.skipif(not (os.path.exists(WM_PACK) and os.path.exists(GM_PACK)), reason="DISCO_BRAIN_PACKS does not name a directory with the WM and GM packs")
|
| 21 |
|
| 22 |
|
| 23 |
def brain_cfg(asset=FIXTURE):
|
|
|
|
| 63 |
|
| 64 |
def compose_reference(brain, meas, ph, *, scanner, n=200):
|
| 65 |
"""``(got, ref)`` on ``n`` voxels drawn with seed 0, in M0 units: the page's contraction (:meth:`Brain.prepare`,
|
| 66 |
+
:meth:`Brain.replay`) and dmipy-replay's ``Phantom.compose(...).replay(seq, scanner=scanner, pose=...)`` of the
|
| 67 |
same packs as loaded, tissue, fractions and FODs, NaN where a voxel carries no signal."""
|
| 68 |
+
from dmipy_replay.phantom import FreeWater, Grid, Inert, ODF, PackSubstrate, Phantom
|
| 69 |
dwi, floor, factor, _ = brain.replay(meas, ph, brain.prepare(meas, ph))
|
| 70 |
assert floor.max() == 0 and np.nanmax(dwi) <= 1 + 1e-5
|
| 71 |
pick = np.sort(np.random.default_rng(0).choice(len(brain.vox), n, replace=False))
|
|
|
|
| 90 |
|
| 91 |
@has_fixture
|
| 92 |
@has_packs
|
| 93 |
+
def test_the_replay_is_dmipy_replays_phantom_composition(brain):
|
| 94 |
"""At the scan's protocol, 3 T with every tier on (relaxation, contact, the sheath's field along the bore), noise
|
| 95 |
off: the Space's float32 contraction over 200 voxels equals ``Phantom.compose(...).replay(seq, pose=...)`` of the
|
| 96 |
same packs, tissue, fractions and FODs to 1e-5 in M0 units, the field given as ``scanner=ph.scanner_field``."""
|
|
|
|
| 106 |
|
| 107 |
@pytest.mark.skipif(not os.environ.get("DISCO_HUB_TESTS"), reason="reads the Hub: set DISCO_HUB_TESTS=1")
|
| 108 |
@has_fixture
|
| 109 |
+
def test_a_windowed_multi_seed_pack_replays_as_dmipy_replays_phantom_composition():
|
| 110 |
"""The page's default packs (brain.toml's first menu entries: the WM walk of 1 s in 125 ms windows from several
|
| 111 |
seeded walks, the GM walk of 250 ms in 125 ms windows), read from the Hub by window, every tier on at 3 T: a
|
| 112 |
class within window 0 loads ``windows=range(1)`` and the page's contraction over 200 voxels equals
|
| 113 |
``Phantom.compose(...).replay`` of the same packs to float precision (1e-6 in M0 units; measured 7e-8). The
|
| 114 |
sheath's field is the composition's ``scanner=ph.scanner_field``; the same composition without it
|
| 115 |
+
(``scanner=None``, the ideal machine's key ``ph.scanner``) is refused by dmipy-replay, since the tissue's
|
| 116 |
susceptibility needs a field to act in (disco-space#36)."""
|
| 117 |
cfg = P.config(os.path.join(P.HERE, "brain.toml"))
|
| 118 |
cfg["asset"] = {"local": FIXTURE}
|
|
|
|
| 428 |
|
| 429 |
@pytest.fixture(scope="module")
|
| 430 |
def two_windows(tmp_path_factory):
|
| 431 |
+
"""A cylinder walk of 200 ms stored in two 100 ms windows (300 walkers, built with the bank for the Connectome 2.0
|
| 432 |
+
class) and the same pack truncated to its first window (no re-encode), both on disk: ``(two, one)`` paths."""
|
| 433 |
import dmipy_sim as d
|
| 434 |
+
from dmipy_replay import envelope_of, read_rpk
|
| 435 |
from dmipy_sim.replay.bank import build_replay_pack
|
| 436 |
+
from dmipy_sim.replay.reencode import truncate_pack
|
| 437 |
out = tmp_path_factory.mktemp("windows")
|
| 438 |
+
envelope = envelope_of("connectome_2", B0_list=(3.0,), rho2_max=1e-4)
|
| 439 |
+
walk = d.walk(d.spec.spec_of(d.Cylinder(radius=3e-6, orientation=(0.0, 0.0, 1.0))), 300, 0.2, diffusivity=2e-9, seed=0,
|
| 440 |
+
K=48, window_T=0.1, require_gpu=False, envelope=envelope)
|
| 441 |
two = str(out / "two.rpk"); one = str(out / "one.rpk")
|
| 442 |
+
build_replay_pack(walk, id="test/two-windows", license="x", citation="x", K=48, window_T=0.1, out_path=two)
|
| 443 |
+
truncate_pack(read_rpk(two), 1, out_path=one)
|
| 444 |
return two, one
|
| 445 |
|
| 446 |
|
|
|
|
| 450 |
if not os.path.exists(os.path.join(FIXTURE, "manifest.json")):
|
| 451 |
pytest.skip("the BATMAN brain fixture is not on this machine")
|
| 452 |
two, one = two_windows
|
| 453 |
+
try:
|
| 454 |
+
B.declared_segments(two)
|
| 455 |
+
except ValueError as e: # a pack of this release records its windows {n, T}; the source reads the table {n, n_t, T}
|
| 456 |
+
pytest.skip(f"the brain source does not read this release's window record yet: {e}"[:300])
|
| 457 |
cfg = brain_cfg()
|
| 458 |
menu = [{"label": "two", "uri": two}, {"label": "one", "uri": one}]
|
| 459 |
cfg["packs"] = {"wm": list(menu), "gm": list(menu)}
|
|
|
|
| 502 |
loads nothing, a request for two reloads with ``range(2)`` and replaces the cached pack; a local path loads whole.
|
| 503 |
(The library's window-ranged read is stood in for by the fixture stamped with the windows asked for;
|
| 504 |
``test_the_hub_fixture_loads_by_window`` reads the Hub.)"""
|
| 505 |
+
import dmipy_replay.phantom as phantom
|
| 506 |
+
from dmipy_replay import read_rpk
|
| 507 |
two, _ = two_windows
|
| 508 |
uri = "hf://SubstrateCommons/test/packs/two.rpk"
|
| 509 |
calls = []
|
|
|
|
| 539 |
byte range into an empty Hub cache (the library keeps a window read there and serves a later, smaller request
|
| 540 |
from it): ``windows=range(1)`` for a class within window 0, then ``range(2)`` when a class reaching window 1 asks,
|
| 541 |
which replaces the cached pack."""
|
| 542 |
+
monkeypatch.setenv("DMIPY_REPLAY_CACHE", str(tmp_path))
|
|
|
|
| 543 |
cfg = P.config(os.path.join(P.HERE, "brain.toml"))
|
| 544 |
label, uri = next(iter(B.Brain.pack_menu(cfg, "gm").items()))
|
| 545 |
src = B.Brain.__new__(B.Brain)
|
|
|
|
| 649 |
@has_fixture
|
| 650 |
@has_packs
|
| 651 |
def test_a_machine_plays_every_pulse_at_its_slew_and_field():
|
| 652 |
+
"""``BrainPhysics.scanner`` names a machine of dmipy-replay's catalogue: the pulses are trapezoids with ramps at its
|
| 653 |
slew (the same b), its field and direction replace the panel's, the response key tells the machines apart, the label
|
| 654 |
names it, a magnet with its own gradient (the Swoop) is refused by name, and a name outside the catalogue too."""
|
| 655 |
cfg = brain_cfg()
|
|
|
|
| 729 |
its transmit scale and its own gradient, the Prisma's gradient nonlinearity -- at every sampled brain voxel equal
|
| 730 |
the catalogue's law at ``affine @ [ijk, 1]`` from the head centre, on both sides of the midline (a mirrored head
|
| 731 |
would read the law at the wrong side's places); its left-handed and right-handed copies give every voxel the same values."""
|
| 732 |
+
from dmipy_replay.phantom.bore import b0_offset_map, b1_scale_map, background_gradient_map, gradient_tensor_map
|
| 733 |
asset = left_handed_asset(tmp_path)
|
| 734 |
left = B.Brain(brain_cfg(asset), asset=asset)
|
| 735 |
right = B.Brain(brain_cfg(), asset=FIXTURE)
|
|
|
|
| 766 |
within the configured tolerance of its class's), and a class's first member -- the voxel whose exact values the
|
| 767 |
class was composed from -- gets exactly what its own delivered acquisition, expanded by the packs and composed with
|
| 768 |
its FOD and fractions, gives: the replay to 1e-5 in M0 units; the machine moves the image off the ideal scanner's."""
|
| 769 |
+
from dmipy_replay.acquisition.waveforms import rotate_waveform
|
| 770 |
+
from dmipy_replay.phantom import FreeWater
|
| 771 |
+
from dmipy_replay.phantom.bore import encoding_classes
|
| 772 |
cfg = brain_cfg()
|
| 773 |
src = B.Brain(cfg, asset=FIXTURE)
|
| 774 |
values = default_values(cfg)
|
|
|
|
| 799 |
for v in reps:
|
| 800 |
_, own = encoding_classes(P.limits(ph.scanner), grid, src.sequence(meas, rows, np.eye(3), ph.slew_rate), idx[v:v + 1], tolerance=None)
|
| 801 |
seq = rotate_waveform(own[0], pose)
|
| 802 |
+
Cw = wm_pack.pose_responses([seq], tissue=t_wm, scanner=ph.scanner_field, pose=pose, keep=(B.LMAX, 0), backend=B.POSE_BACKEND)[0].retained(B.LMAX, 0) @ B.axis_map()
|
| 803 |
+
eg = gm_pack.pose_responses([seq], tissue=t_gm, scanner=ph.scanner_field, pose=pose, keep=(0, 0), backend=B.POSE_BACKEND)[0].retained(0, 0)[:, 0] * B.axis_map()[0, 0] * B.C00
|
| 804 |
ec = FreeWater(m0=1.0, tissue=t_csf).response(seq)
|
| 805 |
f = src.f[v].astype(np.float64); m0 = ph.m0
|
| 806 |
ref = np.abs(f[0] * m0["wm"] * (src.fod_unit[v].astype(np.float64) @ Cw.T) + f[1] * m0["gm"] * eg + f[2] * m0["csf"] * ec)
|
|
|
|
| 823 |
got = {ph.scanner: ph for m, ph in src.warm_entries() if ph.scanner is not None}
|
| 824 |
assert set(got) == can and can
|
| 825 |
for key, ph in got.items():
|
| 826 |
+
assert ph.field_T == pytest.approx(P.limits(key).field_T) and ph.slew_rate == pytest.approx(P.limits(key).slew_max)
|
| 827 |
+
|
| 828 |
+
# ---- the page when the Hub does not serve what the configuration names -------------------------------------------------
|
| 829 |
+
|
| 830 |
+
BRAIN_TOML = P.config(os.path.join(P.HERE, "brain.toml"))
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
def _not_found():
|
| 834 |
+
import urllib.error
|
| 835 |
+
return urllib.error.HTTPError("https://huggingface.co/datasets/o/n/resolve/main/p.rpk", 404, "Not Found", {}, None)
|
| 836 |
+
|
| 837 |
+
|
| 838 |
+
def test_a_pack_is_refused_by_name_when_it_is_absent_refused_or_unreadable(monkeypatch, tmp_path):
|
| 839 |
+
"""The three ways a pack of the menu cannot be served, each named: absent from its dataset (HTTP 404), refused by
|
| 840 |
+
the reader (its message, which names what it does not read), and a window record this page does not read; a pack
|
| 841 |
+
that opens and has its table is served; an error that says nothing about the pack (HTTP 500) is raised, not named."""
|
| 842 |
+
from dmipy_replay import ReplayPack
|
| 843 |
+
gone, old, bare, ok, down = (f"hf://o/n/{n}.rpk" for n in ("gone", "old", "bare", "ok", "down"))
|
| 844 |
+
|
| 845 |
+
def opened(uri, workers=8):
|
| 846 |
+
if uri == gone:
|
| 847 |
+
raise _not_found()
|
| 848 |
+
if uri == old:
|
| 849 |
+
raise ValueError(f"{uri}: This pack carries `walk_params.n_t`, which this revision does not read; re-walk the pack")
|
| 850 |
+
if uri == down:
|
| 851 |
+
import urllib.error
|
| 852 |
+
raise urllib.error.HTTPError(uri, 500, "Server Error", {}, None)
|
| 853 |
+
return object()
|
| 854 |
+
|
| 855 |
+
def table(uri):
|
| 856 |
+
if uri == bare:
|
| 857 |
+
raise ValueError(f"{uri}: no declared segment table (walk_params.segments, RPK.md 4.3) in its record")
|
| 858 |
+
return dict(n=1, n_t=1001, T=0.1)
|
| 859 |
+
|
| 860 |
+
monkeypatch.setattr(ReplayPack, "open", staticmethod(opened))
|
| 861 |
+
monkeypatch.setattr(B, "declared_segments", table)
|
| 862 |
+
assert B.pack_refusal(gone) == "it is not in its dataset on the Hub"
|
| 863 |
+
assert "the reader refuses it" in B.pack_refusal(old) and "walk_params.n_t" in B.pack_refusal(old)
|
| 864 |
+
assert "cannot read its window record" in B.pack_refusal(bare) and "walk_params.segments" in B.pack_refusal(bare)
|
| 865 |
+
assert B.pack_refusal(ok) is None
|
| 866 |
+
with pytest.raises(Exception, match="500"):
|
| 867 |
+
B.pack_refusal(down)
|
| 868 |
+
local = tmp_path / "p.rpk"
|
| 869 |
+
assert B.pack_refusal(str(local)) == "there is no such file"
|
| 870 |
+
local.write_bytes(b"x")
|
| 871 |
+
assert B.pack_refusal(str(local)) is None
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
def test_the_asset_is_refused_by_name_when_its_revision_or_manifest_is_gone(monkeypatch, tmp_path):
|
| 875 |
+
"""A Hub revision the Hub answers 404 for is named; so is a directory without its manifest; an unreachable Hub is raised."""
|
| 876 |
+
import types
|
| 877 |
+
where = "hf://SubstrateCommons/masivar-brain@7556d3900adfe73f2b78b34fffb74902354775e9"
|
| 878 |
+
|
| 879 |
+
class Answer(OSError):
|
| 880 |
+
def __init__(self, status):
|
| 881 |
+
super().__init__(f"HTTP {status}")
|
| 882 |
+
self.response = types.SimpleNamespace(status_code=status)
|
| 883 |
+
|
| 884 |
+
def gone(w):
|
| 885 |
+
raise Answer(404)
|
| 886 |
+
|
| 887 |
+
monkeypatch.setattr(B, "manifest", gone)
|
| 888 |
+
assert B.asset_refusal(where) == "the Hub has no manifest.json of SubstrateCommons/masivar-brain at revision 7556d3900adfe73f2b78b34fffb74902354775e9"
|
| 889 |
+
monkeypatch.setattr(B, "manifest", lambda w: (_ for _ in ()).throw(Answer(503)))
|
| 890 |
+
with pytest.raises(Answer):
|
| 891 |
+
B.asset_refusal(where)
|
| 892 |
+
monkeypatch.undo()
|
| 893 |
+
assert "has no manifest.json" in B.asset_refusal(str(tmp_path))
|
| 894 |
+
(tmp_path / "manifest.json").write_text("{}")
|
| 895 |
+
assert B.asset_refusal(str(tmp_path)) is None
|
| 896 |
+
|
| 897 |
+
|
| 898 |
+
def test_the_brain_names_every_input_it_cannot_read_and_says_nothing_when_all_read(monkeypatch):
|
| 899 |
+
"""The page's text lists the asset and each pack of both menus with its reason, under the title and the sentence that
|
| 900 |
+
the packs are being re-walked for dmipy 3.0; with every input readable there is no text."""
|
| 901 |
+
cfg = BRAIN_TOML
|
| 902 |
+
uris = [p["uri"] for t in ("wm", "gm") for p in cfg["packs"][t]]
|
| 903 |
+
monkeypatch.setattr(B, "asset_refusal", lambda w: "the Hub has no manifest.json of the asset")
|
| 904 |
+
monkeypatch.setattr(B, "pack_refusal", lambda u: None if u == uris[1] else f"reason of {u.rsplit('/', 1)[-1]}")
|
| 905 |
+
title, text = B.Brain.unavailable(cfg)
|
| 906 |
+
assert title == cfg["describe"]["title"] and text.startswith(f"# {title}")
|
| 907 |
+
assert "its packs are being re-walked for dmipy 3.0" in text and "the Hub has no manifest.json of the asset" in text
|
| 908 |
+
for u in uris:
|
| 909 |
+
assert (u in text) == (u != uris[1]), u
|
| 910 |
+
assert text.count("\n- ") == 4
|
| 911 |
+
monkeypatch.setattr(B, "asset_refusal", lambda w: None)
|
| 912 |
+
monkeypatch.setattr(B, "pack_refusal", lambda u: None)
|
| 913 |
+
assert B.Brain.unavailable(cfg) is None
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
def test_the_brain_page_is_the_text_instead_of_a_crash_and_the_disco_page_is_unaffected(monkeypatch):
|
| 917 |
+
"""With the Hub serving none of the brain's inputs the Space builds a page of the refusal -- the title, the text, nothing
|
| 918 |
+
to run, and without reading the asset the page would be built from -- while the DiSCo page is built from its own
|
| 919 |
+
class and never consults the brain."""
|
| 920 |
+
gr = pytest.importorskip("gradio")
|
| 921 |
+
from space import app as A
|
| 922 |
+
from space.sources import disco as D
|
| 923 |
+
cfg = BRAIN_TOML
|
| 924 |
+
monkeypatch.setattr(B, "asset_refusal", lambda w: "the Hub has no manifest.json of the asset")
|
| 925 |
+
monkeypatch.setattr(B, "pack_refusal", lambda u: "it is not in its dataset on the Hub")
|
| 926 |
+
monkeypatch.setattr(B.Brain, "describe", classmethod(lambda cls, c: (_ for _ in ()).throw(AssertionError("the asset was read"))))
|
| 927 |
+
demo = A.build(cfg=cfg)
|
| 928 |
+
texts = [b.value for b in demo.blocks.values() if isinstance(b, gr.Markdown)]
|
| 929 |
+
assert demo.title == cfg["describe"]["title"] and len(texts) == 1 and "its packs are being re-walked for dmipy 3.0" in texts[0]
|
| 930 |
+
assert not any(isinstance(b, gr.Button) for b in demo.blocks.values())
|
| 931 |
+
disco = P.config()
|
| 932 |
+
assert D.Layout.unavailable(disco) is None and D.Columns.unavailable(disco) is None
|
| 933 |
+
monkeypatch.setattr(B.Brain, "unavailable", classmethod(lambda cls, c: (_ for _ in ()).throw(AssertionError("the brain was consulted"))))
|
| 934 |
+
page = A.build(cfg=disco)
|
| 935 |
+
assert page.title == D.Disco.describe(disco)["title"] and any(isinstance(b, gr.Button) for b in page.blocks.values())
|
tests/test_deploy_requirements.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The ZeroGPU requirements of a configuration are the default's, with the pins of its own file when it has one: one
|
| 2 |
+
family of requirements files."""
|
| 3 |
+
import importlib.util
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _deploy():
|
| 10 |
+
spec = importlib.util.spec_from_file_location("deploy_tool", os.path.join(ROOT, "tools", "deploy.py"))
|
| 11 |
+
mod = importlib.util.module_from_spec(spec)
|
| 12 |
+
spec.loader.exec_module(mod)
|
| 13 |
+
return mod
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_the_default_is_requirements_zero_as_it_stands():
|
| 17 |
+
d = _deploy()
|
| 18 |
+
with open(os.path.join(ROOT, "requirements-zero.txt")) as f:
|
| 19 |
+
assert d.compose_requirements(ROOT, d.DEFAULT_CONFIG) == f.read()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_the_brain_space_takes_the_default_pins():
|
| 23 |
+
"""One code runs both pages, so one release of the dmipy packages serves both: the brain Space has no pins of its own."""
|
| 24 |
+
d = _deploy()
|
| 25 |
+
assert not os.path.exists(os.path.join(ROOT, d.requirements_for("brain.toml")))
|
| 26 |
+
assert d.compose_requirements(ROOT, "brain.toml") == d.compose_requirements(ROOT, d.DEFAULT_CONFIG)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_a_configuration_with_pins_of_its_own_replaces_only_those_lines(tmp_path):
|
| 30 |
+
d = _deploy()
|
| 31 |
+
(tmp_path / "requirements-zero.txt").write_text("numpy>=2\n# the stack\njax==1\ndmipy-sim==3.0.0\n")
|
| 32 |
+
(tmp_path / "requirements-x-zero.txt").write_text("# x was accepted on this one\ndmipy-sim==3.0.1\n")
|
| 33 |
+
assert d.compose_requirements(str(tmp_path), "x.toml") == "numpy>=2\n# the stack\njax==1\n# x was accepted on this one\ndmipy-sim==3.0.1\n"
|
| 34 |
+
(tmp_path / "requirements-y-zero.txt").write_text("not-listed==1\n")
|
| 35 |
+
try:
|
| 36 |
+
d.compose_requirements(str(tmp_path), "y.toml")
|
| 37 |
+
except SystemExit as e:
|
| 38 |
+
assert "not-listed" in str(e)
|
| 39 |
+
else:
|
| 40 |
+
raise AssertionError("a pin of a package the default does not list is refused")
|
tests/test_dmipy_names.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Every dmipy name this repository imports resolves in the installed release. A static pass over the imports of the
|
| 2 |
+
package, the tools and the tests -- nothing of them is run -- because most sit inside functions that only a deployment
|
| 3 |
+
calls: a name that moved between releases fails here, in the gate, not on the Space."""
|
| 4 |
+
import ast
|
| 5 |
+
import importlib
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _sources():
|
| 12 |
+
for top in ("space", "tools", "tests", "app.py"):
|
| 13 |
+
path = os.path.join(ROOT, top)
|
| 14 |
+
if os.path.isfile(path):
|
| 15 |
+
yield path
|
| 16 |
+
continue
|
| 17 |
+
for base, dirs, files in os.walk(path):
|
| 18 |
+
dirs[:] = [d for d in dirs if d != "__pycache__"]
|
| 19 |
+
yield from (os.path.join(base, f) for f in sorted(files) if f.endswith(".py"))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _imports():
|
| 23 |
+
"""``(file, line, module, name)`` of every ``import dmipy_x.y`` (``name`` None) and ``from dmipy_x.y import name``."""
|
| 24 |
+
for path in _sources():
|
| 25 |
+
with open(path, encoding="utf-8") as f:
|
| 26 |
+
tree = ast.parse(f.read(), path)
|
| 27 |
+
for node in ast.walk(tree):
|
| 28 |
+
if isinstance(node, ast.Import):
|
| 29 |
+
yield from ((path, node.lineno, a.name, None) for a in node.names if a.name.startswith("dmipy"))
|
| 30 |
+
elif isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("dmipy") and not node.level:
|
| 31 |
+
yield from ((path, node.lineno, node.module, a.name) for a in node.names)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_every_dmipy_name_the_repository_imports_resolves():
|
| 35 |
+
imports = list(_imports())
|
| 36 |
+
assert imports # the pass reads the package, the tools and the tests
|
| 37 |
+
unresolved = []
|
| 38 |
+
for path, line, module, name in imports:
|
| 39 |
+
try:
|
| 40 |
+
mod = importlib.import_module(module)
|
| 41 |
+
if name is not None and not hasattr(mod, name):
|
| 42 |
+
importlib.import_module(f"{module}.{name}") # a submodule: `from dmipy_replay.replay import so3`
|
| 43 |
+
except Exception as e: # an ImportError names the moved name; anything else is a broken module
|
| 44 |
+
unresolved.append(f"{os.path.relpath(path, ROOT)}:{line}: {module}{'' if name is None else ' import ' + name}: {e}")
|
| 45 |
+
assert not unresolved, "\n".join(unresolved)
|
tests/test_gate_job.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The acceptance job's script runs the gate: it sets every variable the acceptance tests are gated on, so the paid job
|
| 2 |
+
never ends with every test skipped."""
|
| 3 |
+
import importlib.util
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _gate_job():
|
| 11 |
+
spec = importlib.util.spec_from_file_location("gate_job_tool", os.path.join(ROOT, "tools", "gate_job.py"))
|
| 12 |
+
mod = importlib.util.module_from_spec(spec)
|
| 13 |
+
spec.loader.exec_module(mod)
|
| 14 |
+
return mod
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_the_job_script_sets_every_variable_the_acceptance_tests_are_gated_on():
|
| 18 |
+
with open(os.path.join(ROOT, "tests", "test_acceptance.py")) as f:
|
| 19 |
+
gates = re.findall(r'skipif\(\s*not os\.environ\.get\("(\w+)"\)', f.read())
|
| 20 |
+
assert gates, "the acceptance tests are gated on a variable"
|
| 21 |
+
script = _gate_job().SCRIPT
|
| 22 |
+
for name in gates:
|
| 23 |
+
assert re.search(rf"(^|\s){name}=1(\s|$)", script), f"the job script does not set {name}=1"
|
tests/test_pipeline.py
CHANGED
|
@@ -13,12 +13,50 @@ def test_the_config_names_every_shell_and_preset_shape():
|
|
| 13 |
shapes = set(CFG["shapes"])
|
| 14 |
assert {s["shape"] for s in CFG["disco"]["shells"]} <= shapes
|
| 15 |
for name, preset in CFG["presets"].items():
|
| 16 |
-
assert
|
| 17 |
assert P.preset_protocol(CFG, name).name == name
|
| 18 |
for s in CFG["shapes"].values():
|
| 19 |
assert 0 < s["delta"] < s["Delta"] < s["TE"]
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def test_a_protocols_measurements_are_its_rows_in_order():
|
| 23 |
p = P.Protocol((P.Shell("d12-D24", 1000, 30), P.Shell("d8-D20", 6000, 12)), n_b0=2)
|
| 24 |
m = P.measurements(p, CFG["shapes"])
|
|
@@ -122,9 +160,9 @@ def test_the_volumes_round_trip(tmp_path):
|
|
| 122 |
|
| 123 |
def test_the_backend_and_residency_come_from_the_config_or_the_environment(monkeypatch):
|
| 124 |
monkeypatch.delenv("DISCO_BACKEND", raising=False); monkeypatch.delenv("DISCO_RESIDENT", raising=False)
|
| 125 |
-
assert P.backend(CFG) == CFG["compute"]["backend"] == "jax" and P.resident(CFG) is
|
| 126 |
-
monkeypatch.setenv("DISCO_BACKEND", "torch"); monkeypatch.setenv("DISCO_RESIDENT", "
|
| 127 |
-
assert P.backend(CFG) == "torch" and P.resident(CFG) is
|
| 128 |
monkeypatch.setenv("DISCO_BACKEND", "numpy")
|
| 129 |
with pytest.raises(ValueError, match="backend"):
|
| 130 |
P.backend(CFG)
|
|
@@ -268,28 +306,30 @@ def test_floor_stats_are_over_the_voxels_with_signal():
|
|
| 268 |
|
| 269 |
|
| 270 |
def test_the_layout_at_the_pinned_revision_holds_the_configs_classes_and_tiers():
|
| 271 |
-
"""The manifest the Space will download: every [shapes] class
|
| 272 |
-
tissue panel starts on (needs the Hub; skipped offline)."""
|
|
|
|
| 273 |
import json
|
|
|
|
|
|
|
| 274 |
hf = pytest.importorskip("huggingface_hub")
|
| 275 |
d = CFG["data"]
|
| 276 |
import requests
|
| 277 |
from huggingface_hub.errors import HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled
|
| 278 |
try:
|
| 279 |
-
path = hf.hf_hub_download(d["repo"], f"{d['
|
| 280 |
except (requests.ConnectionError, HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled) as e: # offline, or the Hub unreachable
|
| 281 |
pytest.skip(f"the Hub is not reachable: {e!r}"[:120])
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
missing = [c for c in CFG["shapes"] if c not in m["
|
| 286 |
assert not missing, f"the layout at {d['revision'][:12]} lacks {missing}"
|
| 287 |
-
for name,
|
| 288 |
-
|
| 289 |
-
for
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
assert abs(v - t[k]) < 1e-9, (name, k, v, t[k])
|
| 293 |
if CFG["physics"]["default_on"]:
|
| 294 |
assert m.get("tiers"), "the tissue panel starts on but the layout has no tiers"
|
| 295 |
|
|
@@ -328,7 +368,7 @@ def test_the_ingredients_are_the_layouts_tier_maps_in_the_pages_units():
|
|
| 328 |
calls.append((shape, tissue is not None, scanner, backend, resident, tuple(b0_direction)))
|
| 329 |
return dict(pool={"intra": np.full((2, 1, 1), 0.4), "extra": np.full((2, 1, 1), 0.6)}, contact=np.array([[[-2e-5]], [[np.nan]]]),
|
| 330 |
contact_weight=np.full((2, 1, 1), 0.9), phase=np.zeros((2, 1, 1)), phase_std=np.full((2, 1, 1), 0.1))
|
| 331 |
-
lay = D.Layout.__new__(D.Layout); lay.
|
| 332 |
meas = P.measurements(P.Protocol((P.Shell("d12-D24", 1000, 6),), n_b0=1), P.config()["shapes"])
|
| 333 |
ph = P.Physics.at(3.0)
|
| 334 |
out = D.Layout.ingredients(lay, meas, ph)
|
|
@@ -344,27 +384,37 @@ def test_the_ingredients_are_the_layouts_tier_maps_in_the_pages_units():
|
|
| 344 |
|
| 345 |
|
| 346 |
def test_a_machine_reaches_the_layout_as_a_delivery_at_the_placement():
|
| 347 |
-
"""On a stand-in layout: the ideal scanner asks the image for the commanded acquisition (
|
| 348 |
-
asks the layout's delivery for every class
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
| 352 |
seen = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
class _M:
|
| 354 |
grid = Grid(shape=(2, 1, 1), voxel_size_m=(25e-6,) * 3, origin_m=(0.0, 0.0, 0.0))
|
| 355 |
-
|
| 356 |
-
manifest = {"
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
| 365 |
meas = P.measurements(P.Protocol((P.Shell("d12-D24", 1000, 3), P.Shell("d8-D20", 2000, 3)), n_b0=1), CFG["shapes"])
|
| 366 |
-
D.Layout.replay(lay, meas, P.Physics.at(3.0))
|
| 367 |
-
assert [s[2] for s in seen if s[0] == "image"] == [None, None]
|
|
|
|
| 368 |
seen.clear()
|
| 369 |
ph = P.Physics.at(3.0, scanner="siemens_magnetom_prisma_3T", offset_m=(0.0, 0.0, 0.05))
|
| 370 |
D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 1000, 3),), n_b0=1), ph)
|
|
@@ -372,9 +422,45 @@ def test_a_machine_reaches_the_layout_as_a_delivery_at_the_placement():
|
|
| 372 |
assert [s for s in seen if s[0] == "delivery"] == [("delivery", "d12-D24", "siemens_magnetom_prisma_3T", (0.0, 0.0, -0.05)),
|
| 373 |
("delivery", "d8-D20", "siemens_magnetom_prisma_3T", (0.0, 0.0, -0.05))]
|
| 374 |
assert [s[2] for s in seen if s[0] == "image"] == [("delivered", "d12-D24"), ("delivered", "d8-D20")]
|
|
|
|
|
|
|
| 375 |
swoop = P.Physics.at(0.064, scanner="hyperfine_swoop_64mT", b0_direction=(0.0, 1.0, 0.0))
|
| 376 |
-
with pytest.raises(ValueError, match="background
|
| 377 |
-
D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 100, 3),), n_b0=1), swoop)
|
| 378 |
-
lay.moments.background = {"amplitude": 5e-3}
|
| 379 |
-
with pytest.raises(ValueError, match="RF schedules"):
|
| 380 |
D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 100, 3),), n_b0=1), swoop)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
shapes = set(CFG["shapes"])
|
| 14 |
assert {s["shape"] for s in CFG["disco"]["shells"]} <= shapes
|
| 15 |
for name, preset in CFG["presets"].items():
|
| 16 |
+
assert preset["shape"] in shapes, name
|
| 17 |
assert P.preset_protocol(CFG, name).name == name
|
| 18 |
for s in CFG["shapes"].values():
|
| 19 |
assert 0 < s["delta"] < s["Delta"] < s["TE"]
|
| 20 |
|
| 21 |
|
| 22 |
+
#: the paper's flagship acquisitions (experiments/disco/disco_acq.py): b = 0 rows, shells (b, directions), class
|
| 23 |
+
FLAGSHIP = {"clinical b1000 x 30": (3, [(1000, 30)], "d12-D24"), "research 3-shell x 90": (9, [(1000, 90), (2000, 90), (3000, 90)], "d17-D30"),
|
| 24 |
+
"Connectome 2.0 b6000 x 60": (6, [(6000, 60)], "d8-D20"), "cosine OGSE 50 Hz b1000 x 60": (6, [(1000, 60)], "cos-f50-T20")}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_the_presets_are_the_papers_flagship_tables():
|
| 28 |
+
"""Every preset is its stored gradient table, the paper's own (data/protocols/, the same files the paper replays):
|
| 29 |
+
its b = 0 rows first, then each shell's directions, every row on the preset's class."""
|
| 30 |
+
assert set(FLAGSHIP) <= set(CFG["presets"])
|
| 31 |
+
for name, (n_b0, shells, shape) in FLAGSHIP.items():
|
| 32 |
+
p = P.preset_protocol(CFG, name)
|
| 33 |
+
assert p.n_b0 == n_b0 and [(s.b, s.n_dirs) for s in p.shells] == shells and {s.shape for s in p.shells} == {shape}
|
| 34 |
+
m = P.measurements(p, CFG["shapes"])
|
| 35 |
+
np.testing.assert_allclose(np.linalg.norm(m.dirs, axis=1), 1.0, atol=1e-6)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_each_class_plays_its_one_waveform():
|
| 39 |
+
"""A class's rows are its one shape scaled to each row's b along each row's direction: the b of every row is its
|
| 40 |
+
own; a square PGSE class needs the textbook amplitude; the OGSE class keeps the 200 T/m/s edges of its b = 1000
|
| 41 |
+
build at every b (the shape is fixed), a b = 0 row plays nothing."""
|
| 42 |
+
from space import classes as C
|
| 43 |
+
u = np.array([[1.0, 0.0, 0.0], [0.0, 0.6, 0.8], [0.0, 0.0, 1.0]])
|
| 44 |
+
for name, sh in CFG["shapes"].items():
|
| 45 |
+
if C.kind(sh) == "tensor": # its rows are level matrices: tests/test_tensor.py
|
| 46 |
+
continue
|
| 47 |
+
b = np.array([500.0, 1000.0, 0.0]) if C.kind(sh) == "ogse" else np.array([1000.0, 3000.0, 0.0])
|
| 48 |
+
seq = C.play(sh, b * 1e6, u)
|
| 49 |
+
np.testing.assert_allclose(seq.b(), b * 1e6, rtol=1e-5, atol=1e3)
|
| 50 |
+
G = np.asarray(seq.G, np.float64)
|
| 51 |
+
assert not G[2].any()
|
| 52 |
+
if C.kind(sh) == "pgse":
|
| 53 |
+
assert abs(C.amplitude(sh, 1000) / P.gradient_needed(1000, sh["delta"], sh["Delta"]) - 1) < 0.02, name
|
| 54 |
+
sh = CFG["shapes"]["cos-f50-T20"]
|
| 55 |
+
G = np.abs(np.asarray(C.play(sh, [0.5e9, 1e9], u[:2]).G, np.float64)).max(axis=2)
|
| 56 |
+
np.testing.assert_allclose(G[0] / G[0].max(), G[1] / G[1].max(), atol=1e-6) # one shape at both b
|
| 57 |
+
assert 0.2 < C.amplitude(sh, 1000) < 0.3 # 0.25 T/m: a 50 Hz cosine is weak in b
|
| 58 |
+
|
| 59 |
+
|
| 60 |
def test_a_protocols_measurements_are_its_rows_in_order():
|
| 61 |
p = P.Protocol((P.Shell("d12-D24", 1000, 30), P.Shell("d8-D20", 6000, 12)), n_b0=2)
|
| 62 |
m = P.measurements(p, CFG["shapes"])
|
|
|
|
| 160 |
|
| 161 |
def test_the_backend_and_residency_come_from_the_config_or_the_environment(monkeypatch):
|
| 162 |
monkeypatch.delenv("DISCO_BACKEND", raising=False); monkeypatch.delenv("DISCO_RESIDENT", raising=False)
|
| 163 |
+
assert P.backend(CFG) == CFG["compute"]["backend"] == "jax" and P.resident(CFG) is False
|
| 164 |
+
monkeypatch.setenv("DISCO_BACKEND", "torch"); monkeypatch.setenv("DISCO_RESIDENT", "1")
|
| 165 |
+
assert P.backend(CFG) == "torch" and P.resident(CFG) is True
|
| 166 |
monkeypatch.setenv("DISCO_BACKEND", "numpy")
|
| 167 |
with pytest.raises(ValueError, match="backend"):
|
| 168 |
P.backend(CFG)
|
|
|
|
| 306 |
|
| 307 |
|
| 308 |
def test_the_layout_at_the_pinned_revision_holds_the_configs_classes_and_tiers():
|
| 309 |
+
"""The manifest the Space will download: every [shapes] class is a stored partition whose terms are that class's
|
| 310 |
+
waveform (its template fits them), and the tiers when the tissue panel starts on (needs the Hub; skipped offline)."""
|
| 311 |
+
import hashlib
|
| 312 |
import json
|
| 313 |
+
from dmipy_replay.replay.time_segments import Partition, Shape
|
| 314 |
+
from space import classes as C
|
| 315 |
hf = pytest.importorskip("huggingface_hub")
|
| 316 |
d = CFG["data"]
|
| 317 |
import requests
|
| 318 |
from huggingface_hub.errors import HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled
|
| 319 |
try:
|
| 320 |
+
path = hf.hf_hub_download(d["repo"], f"{d['layout']}/manifest.json", repo_type="dataset", revision=d["revision"])
|
| 321 |
except (requests.ConnectionError, HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled) as e: # offline, or the Hub unreachable
|
| 322 |
pytest.skip(f"the Hub is not reachable: {e!r}"[:120])
|
| 323 |
+
raw = open(path, "rb").read(); m = json.loads(raw)
|
| 324 |
+
if d.get("manifest_sha256"):
|
| 325 |
+
assert hashlib.sha256(raw).hexdigest() == d["manifest_sha256"]
|
| 326 |
+
missing = [c for c in CFG["shapes"] if c not in m["partitions"]]
|
| 327 |
assert not missing, f"the layout at {d['revision'][:12]} lacks {missing}"
|
| 328 |
+
for name, sh in CFG["shapes"].items():
|
| 329 |
+
p = m["partitions"][name]
|
| 330 |
+
part = Partition(name, tuple(tuple(t["t"]) for t in p["segments"]), tuple(int(t["gate"]) for t in p["segments"]),
|
| 331 |
+
tuple(Shape.from_meta(t["shape"], name) for t in p["segments"]))
|
| 332 |
+
part.levels(C.template(sh)) # refused by name unless the waveform is the class's
|
|
|
|
| 333 |
if CFG["physics"]["default_on"]:
|
| 334 |
assert m.get("tiers"), "the tissue panel starts on but the layout has no tiers"
|
| 335 |
|
|
|
|
| 368 |
calls.append((shape, tissue is not None, scanner, backend, resident, tuple(b0_direction)))
|
| 369 |
return dict(pool={"intra": np.full((2, 1, 1), 0.4), "extra": np.full((2, 1, 1), 0.6)}, contact=np.array([[[-2e-5]], [[np.nan]]]),
|
| 370 |
contact_weight=np.full((2, 1, 1), 0.9), phase=np.zeros((2, 1, 1)), phase_std=np.full((2, 1, 1), 0.1))
|
| 371 |
+
lay = D.Layout.__new__(D.Layout); lay.layout = _M(); lay.tiers = True; lay.backend = "torch"; lay.unseeded = ("myelin",); lay.resident = True
|
| 372 |
meas = P.measurements(P.Protocol((P.Shell("d12-D24", 1000, 6),), n_b0=1), P.config()["shapes"])
|
| 373 |
ph = P.Physics.at(3.0)
|
| 374 |
out = D.Layout.ingredients(lay, meas, ph)
|
|
|
|
| 384 |
|
| 385 |
|
| 386 |
def test_a_machine_reaches_the_layout_as_a_delivery_at_the_placement():
|
| 387 |
+
"""On a stand-in layout: the ideal scanner asks the image for the commanded acquisition (the class's waveform, no
|
| 388 |
+
delivery); a machine asks the layout's delivery for every class with the grid centred at the physics' offset, and
|
| 389 |
+
hands it to that class's image; the image's normalised signal is scaled by every voxel's walker weight; a machine
|
| 390 |
+
with its own gradient on a class without background vectors, and any machine on a shaped class (the OGSE), is
|
| 391 |
+
refused by name."""
|
| 392 |
+
from dmipy_replay.phantom import Grid
|
| 393 |
+
from space import classes as C
|
| 394 |
seen = []
|
| 395 |
+
|
| 396 |
+
class _Part:
|
| 397 |
+
def __init__(self, constant):
|
| 398 |
+
self.is_constant = constant
|
| 399 |
+
|
| 400 |
class _M:
|
| 401 |
grid = Grid(shape=(2, 1, 1), voxel_size_m=(25e-6,) * 3, origin_m=(0.0, 0.0, 0.0))
|
| 402 |
+
partitions = {n: _Part(C.kind(sh) != "ogse") for n, sh in CFG["shapes"].items()}
|
| 403 |
+
manifest = {"partitions": {n: {"group": "g0"} for n in CFG["shapes"]}, "groups": {"g0": {}}}
|
| 404 |
+
|
| 405 |
+
def delivery(self, seq, scanner, grid, *, partition):
|
| 406 |
+
seen.append(("delivery", partition, scanner.name, tuple(np.round(np.asarray(grid.isocenter_m) - np.asarray(self.grid.isocenter_m), 6))))
|
| 407 |
+
return ("delivered", partition)
|
| 408 |
+
|
| 409 |
+
def image(self, seq, *, partition, backend, resident, tissue, scanner, b0_direction, delivered=None):
|
| 410 |
+
seen.append(("image", partition, delivered, scanner, tuple(b0_direction), np.asarray(seq.G).shape[0]))
|
| 411 |
+
return np.full((2, 1, 1, np.asarray(seq.G).shape[0]), 0.5), np.zeros((2, 1, 1))
|
| 412 |
+
lay = D.Layout.__new__(D.Layout); lay.layout = _M(); lay.tiers = True; lay.backend = "jax"; lay.unseeded = ("myelin",); lay.resident = False
|
| 413 |
+
lay.M0 = np.array([2.0, 4.0]).reshape(2, 1, 1); lay.mask = np.ones((2, 1, 1), bool); lay.pools = ("intra", "extra"); lay.shapes = CFG["shapes"]
|
| 414 |
meas = P.measurements(P.Protocol((P.Shell("d12-D24", 1000, 3), P.Shell("d8-D20", 2000, 3)), n_b0=1), CFG["shapes"])
|
| 415 |
+
dwi, floor, factor, _ = D.Layout.replay(lay, meas, P.Physics.at(3.0))
|
| 416 |
+
assert [s[2] for s in seen if s[0] == "image"] == [None, None] and [s[5] for s in seen if s[0] == "image"] == [4, 3]
|
| 417 |
+
np.testing.assert_allclose(dwi, 1.0); np.testing.assert_allclose(factor.ravel(), [0.25, 0.5]) # S0 = 0.5 W over the fullest voxel's W
|
| 418 |
seen.clear()
|
| 419 |
ph = P.Physics.at(3.0, scanner="siemens_magnetom_prisma_3T", offset_m=(0.0, 0.0, 0.05))
|
| 420 |
D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 1000, 3),), n_b0=1), ph)
|
|
|
|
| 422 |
assert [s for s in seen if s[0] == "delivery"] == [("delivery", "d12-D24", "siemens_magnetom_prisma_3T", (0.0, 0.0, -0.05)),
|
| 423 |
("delivery", "d8-D20", "siemens_magnetom_prisma_3T", (0.0, 0.0, -0.05))]
|
| 424 |
assert [s[2] for s in seen if s[0] == "image"] == [("delivered", "d12-D24"), ("delivered", "d8-D20")]
|
| 425 |
+
with pytest.raises(ValueError, match="shaped waveforms"):
|
| 426 |
+
D.Layout.validate(lay, P.Protocol((P.Shell("cos-f50-T20", 1000, 3),), n_b0=1), ph)
|
| 427 |
swoop = P.Physics.at(0.064, scanner="hyperfine_swoop_64mT", b0_direction=(0.0, 1.0, 0.0))
|
| 428 |
+
with pytest.raises(ValueError, match="background vectors"):
|
|
|
|
|
|
|
|
|
|
| 429 |
D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 100, 3),), n_b0=1), swoop)
|
| 430 |
+
lay.layout.manifest["groups"]["g0"]["background"] = {"column": "bg_g0.npy"}
|
| 431 |
+
D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 100, 3),), n_b0=1), swoop)
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def test_each_preset_starts_at_its_own_snr():
|
| 435 |
+
"""The b-tensor preset starts at SNR 100 (µFA is noise-limited); every other preset at ``[noise] snr``."""
|
| 436 |
+
for name, p in CFG["presets"].items():
|
| 437 |
+
assert P.preset_snr(CFG, name) == (100.0 if "tensor" in p else float(CFG["noise"]["snr"])) and CFG["noise"]["snr"] == 30
|
| 438 |
+
assert P.preset_snr(CFG, "DiSCo 364") == 30.0
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def test_a_tractogram_writes_to_tck_and_reads_back(tmp_path):
|
| 442 |
+
"""The .tck download: dmipy-tract's writer read back through dmipy-sim's reader (their conventions agree)."""
|
| 443 |
+
from dmipy_sim.io.strands import read_tck
|
| 444 |
+
from dmipy_tract import Tractogram
|
| 445 |
+
t = Tractogram(np.random.default_rng(0).random((10, 3)).astype(np.float32), np.array([0, 4, 10]), np.array([0, 1]), np.zeros((2, 2), int))
|
| 446 |
+
t.to_tck(str(tmp_path / "t.tck"))
|
| 447 |
+
assert [len(s) for s in read_tck(str(tmp_path / "t.tck"), coordinate_unit_m=1e-3)] == [4, 6]
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def test_the_csd_runs_on_the_configured_backend_alone(monkeypatch):
|
| 451 |
+
"""The FOD field (the response estimate and the fit) runs on the backend it is given: on ``jax`` nothing reaches
|
| 452 |
+
for PyTorch, which the Docker Space does not install."""
|
| 453 |
+
import sys
|
| 454 |
+
monkeypatch.setitem(sys.modules, "torch", None) # any import of torch now fails
|
| 455 |
+
p = P.Protocol((P.Shell("d12-D24", 1000, 30),))
|
| 456 |
+
m = P.measurements(p, CFG["shapes"])
|
| 457 |
+
rng = np.random.default_rng(3)
|
| 458 |
+
axes = rng.normal(size=(2, 2, 2, 3))
|
| 459 |
+
axes /= np.linalg.norm(axes, axis=-1, keepdims=True)
|
| 460 |
+
g = m.dirs / np.maximum(np.linalg.norm(m.dirs, axis=-1, keepdims=True), 1e-12)
|
| 461 |
+
cos2 = np.einsum("xyzk,nk->xyzn", axes, g) ** 2
|
| 462 |
+
b = m.bvals * 1e-3 # ms/um^2 x um^2/ms
|
| 463 |
+
dwi = np.exp(-b * (0.3 + 1.4 * cos2)) # a prolate tensor along each voxel's axis
|
| 464 |
+
mask = np.ones(dwi.shape[:3], bool)
|
| 465 |
+
sh, seconds, _ = P.csd(dwi, m, mask, backend="jax")
|
| 466 |
+
assert sh.shape == (2, 2, 2, 45) and np.isfinite(sh).all() and seconds > 0
|
tests/test_tensor.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The b-tensor class's encodings (space/tensor.py) without the layout: every row's b-tensor, read off the sequence by
|
| 2 |
+
dmipy-replay, is the encoding asked for; the class's waveform stays inside the scanner it was designed for; the maps
|
| 3 |
+
recover a known microscopic anisotropy from Gaussian compartments played through the same rows."""
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from space import pipeline as P
|
| 8 |
+
from space import tensor as T
|
| 9 |
+
|
| 10 |
+
CFG = P.config()
|
| 11 |
+
TC = next(sh for sh in CFG["shapes"].values() if sh.get("kind") == "tensor")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _rows(bvals=(500.0, 1000.0, 2000.0), n=12):
|
| 15 |
+
return T.rows(TC, list(bvals), n)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_every_row_is_the_encoding_asked_for():
|
| 19 |
+
"""b to 1e-6 relative and b_delta to 1e-6 (1, -1/2, 0) for every row; the b = 0 row plays nothing."""
|
| 20 |
+
from dmipy_replay.acquisition.waveforms import btensor_invariants
|
| 21 |
+
seq, enc, bs = _rows()
|
| 22 |
+
B = seq.btensor()
|
| 23 |
+
b, b_delta, _ = btensor_invariants(B)
|
| 24 |
+
assert enc[0] == "b0" and abs(b[0]) < 1e-6
|
| 25 |
+
for e in T.ENCODINGS:
|
| 26 |
+
m = enc == e
|
| 27 |
+
np.testing.assert_allclose(b[m] / 1e6, bs[m], rtol=1e-6)
|
| 28 |
+
np.testing.assert_allclose(b_delta[m], T.B_DELTA[e], atol=1e-6)
|
| 29 |
+
ste = B[enc == "STE"] # spherical: b/3 I at every rotation
|
| 30 |
+
np.testing.assert_allclose(ste, (bs[enc == "STE"] * 1e6 / 3)[:, None, None] * np.eye(3), rtol=0, atol=1e-6 * 2e9)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_the_waveform_is_refocused_and_inside_its_scanner():
|
| 34 |
+
"""Each shape's net moment at the echo vanishes and the designed amplitude and slew are within 80 mT/m and
|
| 35 |
+
200 T/m/s per axis; the spherical row at b = 2000 at the design's own frame needs no more than the design."""
|
| 36 |
+
W = T.waveform(TC); g = T._grid(); e = np.asarray(g.effective_gate, np.float64)
|
| 37 |
+
net = np.abs((W * e[:, None]).sum(0) * g.dt)
|
| 38 |
+
assert (net < 1e-9 * np.abs(W).max() * 0.0535).all(), net
|
| 39 |
+
assert np.abs(W).max() <= 0.080 * (1 + 1e-6)
|
| 40 |
+
assert np.abs(np.diff(W, axis=0) / g.dt).max() <= 200.0 * 3 * (1 + 1e-6) # linear interpolation of a 200 T/m/s design
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@pytest.mark.parametrize("bvals", [(500.0, 1000.0, 2000.0), (100.0, 700.0, 1400.0, 2000.0)])
|
| 44 |
+
def test_the_maps_recover_a_known_microscopic_anisotropy(bvals):
|
| 45 |
+
"""Powder of randomly oriented axisymmetric Gaussian tensors (D_iso 1 um^2/ms, muFA 0.8) played through the rows'
|
| 46 |
+
own b-tensors: MD within 3 %, muFA within 0.03 over the b-values (the cumulant fits' own truncation); the powder
|
| 47 |
+
averages are read by the b-values as given (700 is not 0.7 x 1e3 in floating point)."""
|
| 48 |
+
seq, enc, bs = _rows(bvals=bvals, n=30)
|
| 49 |
+
B = seq.btensor() * 1e-9 # s/m^2 -> ms/um^2
|
| 50 |
+
D_iso, mufa = 1.0, 0.8
|
| 51 |
+
x = mufa ** 2 * D_iso ** 2 / (3 - 2 * mufa ** 2) # (D_iso D_Delta)^2 of an axisymmetric tensor of that muFA
|
| 52 |
+
D_delta = np.sqrt(x) / D_iso
|
| 53 |
+
rng = np.random.default_rng(0)
|
| 54 |
+
u = rng.normal(size=(4000, 3)); u /= np.linalg.norm(u, axis=1)[:, None]
|
| 55 |
+
Dpar, Dperp = D_iso * (1 + 2 * D_delta), D_iso * (1 - D_delta)
|
| 56 |
+
D = Dperp * np.eye(3)[None] + (Dpar - Dperp) * u[:, :, None] * u[:, None, :]
|
| 57 |
+
S = np.exp(-np.einsum("mij,cij->mc", B, D)).mean(1)
|
| 58 |
+
S = S / S[0]
|
| 59 |
+
pw = T.powder(S[None, None, None, :], enc, bs, list(bvals))
|
| 60 |
+
md, got = T.md_ufa(pw, list(bvals))
|
| 61 |
+
assert abs(float(md.ravel()[0]) - D_iso) < 0.03 * D_iso
|
| 62 |
+
assert abs(float(got.ravel()[0]) - mufa) < 0.03
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_the_tensor_preset_is_linear_and_spherical_rows_on_the_class_and_flags_a_prisma():
|
| 66 |
+
"""The preset: one b = 0 row, then LTE and STE interleaved at b 100, 700, 1400, 2000 with 6, 6, 10, 16 axes, every
|
| 67 |
+
row's b-tensor the one asked for as dmipy-replay reads it off the sequence the Layout plays; rotated, the rows need
|
| 68 |
+
more than a Prisma's 80 mT/m on one axis, so the page flags that machine and passes the ideal scanner."""
|
| 69 |
+
from dmipy_replay.acquisition.waveforms import btensor_invariants
|
| 70 |
+
from space import classes as C
|
| 71 |
+
name = next(n for n in CFG["presets"] if "tensor" in CFG["presets"][n])
|
| 72 |
+
p = P.preset_protocol(CFG, name)
|
| 73 |
+
assert p.tensor and p.n_b0 == 1 and [(s.encoding, s.b, s.n_dirs) for s in p.shells] == [
|
| 74 |
+
(e, b, n) for b, n in ((100.0, 6), (700.0, 6), (1400.0, 10), (2000.0, 16)) for e in ("LTE", "STE")]
|
| 75 |
+
m = P.measurements(p, CFG["shapes"])
|
| 76 |
+
G = C.tensor_rows(TC, m.encoding, m.bvals, m.dirs)
|
| 77 |
+
b, b_delta, _ = btensor_invariants(T._sequence(G).btensor())
|
| 78 |
+
np.testing.assert_allclose(b[1:] / 1e6, m.bvals[1:], rtol=1e-6)
|
| 79 |
+
np.testing.assert_allclose(b_delta[m.encoding == "LTE"], 1.0, atol=1e-6); np.testing.assert_allclose(b_delta[m.encoding == "STE"], 0.0, atol=1e-6)
|
| 80 |
+
rows = P.playable(p, CFG["shapes"], "siemens_magnetom_prisma_3T")
|
| 81 |
+
assert not all(r[-1] for r in rows) and all(r[-1] for r in P.playable(p, CFG["shapes"], None))
|
| 82 |
+
assert 0.10 < max(r[4] for r in rows) < 0.14 # ~124 mT/m at b 2000
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_the_maps_hold_under_noise_on_one_b0_row():
|
| 86 |
+
"""The same voxel as above, 3000 times, with Rician noise at SNR 15 and ONE b = 0 row, every row normalised by it
|
| 87 |
+
as the page does: the joint fit's shared intercept takes up the b = 0 row's noise, so the median muFA stays within
|
| 88 |
+
0.08 of the truth and MD within 5 % (a fit through ln S/S0 = 0 at b = 0 reads that noise as curvature)."""
|
| 89 |
+
bvals = (100.0, 700.0, 1400.0, 2000.0)
|
| 90 |
+
seq, enc, bs = _rows(bvals=bvals, n=10)
|
| 91 |
+
B = seq.btensor() * 1e-9
|
| 92 |
+
D_iso, mufa = 0.8, 0.7
|
| 93 |
+
x = mufa ** 2 * D_iso ** 2 / (3 - 2 * mufa ** 2); D_delta = np.sqrt(x) / D_iso
|
| 94 |
+
rng = np.random.default_rng(1)
|
| 95 |
+
u = rng.normal(size=(4000, 3)); u /= np.linalg.norm(u, axis=1)[:, None]
|
| 96 |
+
Dpar, Dperp = D_iso * (1 + 2 * D_delta), D_iso * (1 - D_delta)
|
| 97 |
+
D = Dperp * np.eye(3)[None] + (Dpar - Dperp) * u[:, :, None] * u[:, None, :]
|
| 98 |
+
S = np.exp(-np.einsum("mij,cij->mc", B, D)).mean(1)
|
| 99 |
+
sd = 1.0 / 15.0
|
| 100 |
+
noisy = np.sqrt((S[None] + rng.normal(0, sd, (3000, len(S)))) ** 2 + rng.normal(0, sd, (3000, len(S))) ** 2)
|
| 101 |
+
noisy = noisy / noisy[:, :1] # the one b = 0 row
|
| 102 |
+
pw = T.powder(noisy[:, None, None, :], enc, bs, list(bvals))
|
| 103 |
+
counts = {(e, b): int(((enc == e) & (bs == b)).sum()) for e in ("LTE", "STE") for b in bvals}
|
| 104 |
+
md, got = T.md_ufa(pw, list(bvals), counts, n_b0=1)
|
| 105 |
+
assert abs(float(np.nanmedian(got)) - mufa) < 0.08, float(np.nanmedian(got))
|
| 106 |
+
assert abs(float(np.nanmedian(md)) - D_iso) < 0.05 * D_iso
|
tests/test_viewers.py
CHANGED
|
@@ -79,3 +79,15 @@ def test_the_map_slice_draws_plain_and_diverging():
|
|
| 79 |
vol = np.random.default_rng(0).normal(size=(6, 6, 3)); vol[0, 0] = np.nan
|
| 80 |
assert V.map_slice(vol, 1, "t").size[0] > 100 and V.map_slice(vol, 1, "d", symmetric=True).size[0] > 100
|
| 81 |
assert V.map_slice(np.full((6, 6, 3), np.nan), 1, "empty", symmetric=True).size[0] > 100
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
vol = np.random.default_rng(0).normal(size=(6, 6, 3)); vol[0, 0] = np.nan
|
| 80 |
assert V.map_slice(vol, 1, "t").size[0] > 100 and V.map_slice(vol, 1, "d", symmetric=True).size[0] > 100
|
| 81 |
assert V.map_slice(np.full((6, 6, 3), np.nan), 1, "empty", symmetric=True).size[0] > 100
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_the_tensor_views_draw_maps_with_the_crossings_and_a_voxels_powder_curve():
|
| 85 |
+
"""The b-tensor results: FA, muFA (crossings outlined) and MD on a slice; a voxel's powder signal against b for
|
| 86 |
+
A and, dashed, B."""
|
| 87 |
+
rng = np.random.default_rng(1)
|
| 88 |
+
bv = [100.0, 700.0, 1400.0, 2000.0]
|
| 89 |
+
pw = {(e, b): np.exp(-b * 1e-3 * (0.6 if e == "STE" else 0.5)) * np.ones((4, 4, 4)) for e in ("LTE", "STE") for b in bv}
|
| 90 |
+
m = dict(fa=rng.random((4, 4, 4)), ufa=rng.random((4, 4, 4)), md=rng.random((4, 4, 4)), powder=pw, bvals=bv)
|
| 91 |
+
cross = np.zeros((4, 4, 4), bool); cross[1:3, 1:3] = True
|
| 92 |
+
assert V.tensor_maps(m, cross, 2).size[0] > 0
|
| 93 |
+
assert V.powder_curve(m, (1, 1, 2), other=m).size[0] > 0
|
tools/build_brain_asset.py
CHANGED
|
@@ -94,8 +94,7 @@ def fit_msmt_csd(data, bvals, bvecs, mask, sh_order=SH_ORDER, solver="csd_cvxpy"
|
|
| 94 |
those voxels are fit; the rest of ``mask`` is still used to estimate the responses.
|
| 95 |
|
| 96 |
Always fits serially (``use_parallel_processing=False``): dmipy-fit's parallel path forks a
|
| 97 |
-
``ProcessPoolExecutor``
|
| 98 |
-
basis), which JAX warns is unsafe. Thread-level parallelism (BLAS/OSQP) is capped by
|
| 99 |
``OMP_NUM_THREADS``/``MKL_NUM_THREADS`` in the environment, per this lab's shared-box rule.
|
| 100 |
|
| 101 |
Returns ``(sh, fractions, S0_responses, response_models, scheme, seconds)``: ``sh`` is ``(X, Y, Z, n_coef)`` (the
|
|
|
|
| 94 |
those voxels are fit; the rest of ``mask`` is still used to estimate the responses.
|
| 95 |
|
| 96 |
Always fits serially (``use_parallel_processing=False``): dmipy-fit's parallel path forks a
|
| 97 |
+
``ProcessPoolExecutor``, which JAX warns is unsafe once JAX is imported. Thread-level parallelism (BLAS/OSQP) is capped by
|
|
|
|
| 98 |
``OMP_NUM_THREADS``/``MKL_NUM_THREADS`` in the environment, per this lab's shared-box rule.
|
| 99 |
|
| 100 |
Returns ``(sh, fractions, S0_responses, response_models, scheme, seconds)``: ``sh`` is ``(X, Y, Z, n_coef)`` (the
|
tools/build_brain_fixture.py
CHANGED
|
@@ -5,9 +5,9 @@ before the MASiVar asset exists. It stays local (BATMAN's licence, dmipy-sim#193
|
|
| 5 |
|
| 6 |
What each file is:
|
| 7 |
|
| 8 |
-
* ``fod_wm.npy`` -- ``wmfod_norm.mif`` (MRtrix3 multi-tissue CSD, lmax 8, the MRtrix3 basis, which is dmipy-
|
| 9 |
required basis and dmipy-fit's) turned from the scanner frame into the image frame by ``R.T`` (``R`` from
|
| 10 |
-
:meth:`
|
| 11 |
* ``fractions.npy`` -- WM / GM / CSF from ``5tt_coreg.mif`` (on the 1 mm T1 grid), averaged into every 2.5 mm
|
| 12 |
voxel on a 3^3 sub-grid as ``examples/rph/brain_from_csd.py`` does: GM is cortical + sub-cortical GM, WM where
|
| 13 |
the FOD has no positive l = 0 coefficient is counted as GM (no FOD, no oriented WM), zero outside the mask.
|
|
@@ -19,7 +19,7 @@ What each file is:
|
|
| 19 |
* ``manifest.json`` -- the grid (the image's affine, voxel -> scanner RAS mm), the tutorial's gradient table
|
| 20 |
(scanner frame) with the capstone's pulse timing (TE 100 / delta 25 / Delta 55 ms, which the 100 ms packs play).
|
| 21 |
|
| 22 |
-
python tools/build_brain_fixture.py
|
| 23 |
"""
|
| 24 |
import argparse
|
| 25 |
import json
|
|
@@ -76,13 +76,15 @@ def parcellate(gm, shape):
|
|
| 76 |
|
| 77 |
|
| 78 |
def main(argv=None):
|
|
|
|
|
|
|
| 79 |
from dmipy_sim.io.mrtrix import read_mif
|
| 80 |
-
from dmipy_sim.phantom import Grid
|
| 81 |
-
from dmipy_sim.replay.so3 import rotate_sh
|
| 82 |
ap = argparse.ArgumentParser()
|
| 83 |
-
ap.add_argument("--batman", default=os.
|
| 84 |
-
ap.add_argument("--out", default=os.
|
| 85 |
a = ap.parse_args(argv)
|
|
|
|
|
|
|
| 86 |
os.makedirs(a.out, exist_ok=True)
|
| 87 |
fod = read_mif(os.path.join(a.batman, "wmfod_norm.mif"))
|
| 88 |
mask = np.asarray(read_mif(os.path.join(a.batman, "mask_den_unr_preproc_unb.mif")).data, bool)
|
|
|
|
| 5 |
|
| 6 |
What each file is:
|
| 7 |
|
| 8 |
+
* ``fod_wm.npy`` -- ``wmfod_norm.mif`` (MRtrix3 multi-tissue CSD, lmax 8, the MRtrix3 basis, which is dmipy-replay's
|
| 9 |
required basis and dmipy-fit's) turned from the scanner frame into the image frame by ``R.T`` (``R`` from
|
| 10 |
+
:meth:`dmipy_replay.phantom.Grid.from_oblique_affine`), zero outside the brain mask.
|
| 11 |
* ``fractions.npy`` -- WM / GM / CSF from ``5tt_coreg.mif`` (on the 1 mm T1 grid), averaged into every 2.5 mm
|
| 12 |
voxel on a 3^3 sub-grid as ``examples/rph/brain_from_csd.py`` does: GM is cortical + sub-cortical GM, WM where
|
| 13 |
the FOD has no positive l = 0 coefficient is counted as GM (no FOD, no oriented WM), zero outside the mask.
|
|
|
|
| 19 |
* ``manifest.json`` -- the grid (the image's affine, voxel -> scanner RAS mm), the tutorial's gradient table
|
| 20 |
(scanner frame) with the capstone's pulse timing (TE 100 / delta 25 / Delta 55 ms, which the 100 ms packs play).
|
| 21 |
|
| 22 |
+
python tools/build_brain_fixture.py --batman <dir> --out <dir> # or DISCO_BATMAN / DISCO_BRAIN_FIXTURE
|
| 23 |
"""
|
| 24 |
import argparse
|
| 25 |
import json
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
def main(argv=None):
|
| 79 |
+
from dmipy_replay.phantom import Grid
|
| 80 |
+
from dmipy_replay.replay.so3 import rotate_sh
|
| 81 |
from dmipy_sim.io.mrtrix import read_mif
|
|
|
|
|
|
|
| 82 |
ap = argparse.ArgumentParser()
|
| 83 |
+
ap.add_argument("--batman", default=os.environ.get("DISCO_BATMAN"), help="the BATMAN directory (DISCO_BATMAN)")
|
| 84 |
+
ap.add_argument("--out", default=os.environ.get("DISCO_BRAIN_FIXTURE"), help="the fixture directory (DISCO_BRAIN_FIXTURE)")
|
| 85 |
a = ap.parse_args(argv)
|
| 86 |
+
if not a.batman or not a.out:
|
| 87 |
+
ap.error("name the BATMAN directory and the output directory (--batman/--out or DISCO_BATMAN/DISCO_BRAIN_FIXTURE)")
|
| 88 |
os.makedirs(a.out, exist_ok=True)
|
| 89 |
fod = read_mif(os.path.join(a.batman, "wmfod_norm.mif"))
|
| 90 |
mask = np.asarray(read_mif(os.path.join(a.batman, "mask_den_unr_preproc_unb.mif")).data, bool)
|
tools/deploy.py
CHANGED
|
@@ -15,6 +15,7 @@ bucket is retired), so the deploy also removes a ``DISCO_MOMENTS`` variable and
|
|
| 15 |
"""
|
| 16 |
import argparse
|
| 17 |
import os
|
|
|
|
| 18 |
import shutil
|
| 19 |
import subprocess
|
| 20 |
import tempfile
|
|
@@ -44,6 +45,51 @@ def readme_for(config):
|
|
| 44 |
return "README-zero.md" if config == DEFAULT_CONFIG else f"README-{os.path.splitext(config)[0]}-zero.md"
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def set_config(api, space, config):
|
| 48 |
"""``DISCO_CONFIG`` on the Space: the configuration's name, absent for the default."""
|
| 49 |
if config == DEFAULT_CONFIG:
|
|
@@ -79,7 +125,8 @@ def main():
|
|
| 79 |
for name in ("app.py", "pyproject.toml"):
|
| 80 |
shutil.copy(os.path.join(root, name), staged)
|
| 81 |
shutil.copy(os.path.join(root, readme_for(a.config)), os.path.join(staged, "README.md"))
|
| 82 |
-
|
|
|
|
| 83 |
folder = staged
|
| 84 |
elif a.base_tag:
|
| 85 |
staged = tempfile.mkdtemp(prefix="disco-space-")
|
|
@@ -91,7 +138,7 @@ def main():
|
|
| 91 |
g.write(f.read().replace("BASE_TAG", a.base_tag))
|
| 92 |
folder = staged
|
| 93 |
info = api.upload_folder(folder_path=folder, repo_id=a.space, repo_type="space", ignore_patterns=IGNORE,
|
| 94 |
-
delete_patterns=["Dockerfile", "Dockerfile.base", "Dockerfile.space", "entrypoint.sh", "README-*zero.md", "requirements-zero.txt"] if a.zero
|
| 95 |
else (["Dockerfile.base", "Dockerfile.space"] if a.base_tag else None),
|
| 96 |
commit_message=f"disco-space {sha[:12]}" + (f" on base {a.base_tag[:12]}" if a.base_tag else "") + (f", {a.config}" if a.config != DEFAULT_CONFIG else ""))
|
| 97 |
if staged:
|
|
|
|
| 15 |
"""
|
| 16 |
import argparse
|
| 17 |
import os
|
| 18 |
+
import re
|
| 19 |
import shutil
|
| 20 |
import subprocess
|
| 21 |
import tempfile
|
|
|
|
| 45 |
return "README-zero.md" if config == DEFAULT_CONFIG else f"README-{os.path.splitext(config)[0]}-zero.md"
|
| 46 |
|
| 47 |
|
| 48 |
+
def requirements_for(config):
|
| 49 |
+
"""The file of the pins a configuration replaces in ``requirements-zero.txt`` (:func:`compose_requirements`):
|
| 50 |
+
``requirements-<stem>-zero.txt``, when the configuration has one."""
|
| 51 |
+
return f"requirements-{os.path.splitext(config)[0]}-zero.txt"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _package_of(line):
|
| 55 |
+
"""The distribution a requirements line pins (``name @ url``, ``name==x``, ``name>=x``), or None for a comment."""
|
| 56 |
+
line = line.strip()
|
| 57 |
+
if not line or line.startswith("#"):
|
| 58 |
+
return None
|
| 59 |
+
return re.split(r"[\s@=<>!~;\[]", line, maxsplit=1)[0].lower().replace("_", "-")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def compose_requirements(root, config):
|
| 63 |
+
"""The ZeroGPU ``requirements.txt`` of a configuration: ``requirements-zero.txt`` and, for a configuration that has a
|
| 64 |
+
file of pins of its own, the lines of ``requirements-<stem>-zero.txt`` replacing the lines of the same package
|
| 65 |
+
(every other line is the default's, in one place). A configuration without that file takes the default's."""
|
| 66 |
+
with open(os.path.join(root, "requirements-zero.txt")) as f:
|
| 67 |
+
lines = f.read().splitlines()
|
| 68 |
+
own = os.path.join(root, requirements_for(config))
|
| 69 |
+
if config == DEFAULT_CONFIG or not os.path.exists(own):
|
| 70 |
+
return "\n".join(lines) + "\n"
|
| 71 |
+
with open(own) as f:
|
| 72 |
+
over = f.read().splitlines()
|
| 73 |
+
pins = {_package_of(l): l for l in over if _package_of(l)}
|
| 74 |
+
notes = {_package_of(over[i + 1]): over[i] for i in range(len(over) - 1) if _package_of(over[i + 1]) and over[i].lstrip().startswith("#")}
|
| 75 |
+
out, used = [], set()
|
| 76 |
+
for l in lines:
|
| 77 |
+
k = _package_of(l)
|
| 78 |
+
if k in pins:
|
| 79 |
+
used.add(k)
|
| 80 |
+
if k in notes:
|
| 81 |
+
out.append(notes[k])
|
| 82 |
+
out.append(pins[k])
|
| 83 |
+
elif k is None and l.lstrip().startswith("#") and _package_of(lines[min(lines.index(l) + 1, len(lines) - 1)]) in pins:
|
| 84 |
+
continue # the default's note on a pin the configuration replaces
|
| 85 |
+
else:
|
| 86 |
+
out.append(l)
|
| 87 |
+
unknown = sorted(set(pins) - used)
|
| 88 |
+
if unknown:
|
| 89 |
+
raise SystemExit(f"{requirements_for(config)} pins {unknown}, which requirements-zero.txt does not list")
|
| 90 |
+
return "\n".join(out) + "\n"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
def set_config(api, space, config):
|
| 94 |
"""``DISCO_CONFIG`` on the Space: the configuration's name, absent for the default."""
|
| 95 |
if config == DEFAULT_CONFIG:
|
|
|
|
| 125 |
for name in ("app.py", "pyproject.toml"):
|
| 126 |
shutil.copy(os.path.join(root, name), staged)
|
| 127 |
shutil.copy(os.path.join(root, readme_for(a.config)), os.path.join(staged, "README.md"))
|
| 128 |
+
with open(os.path.join(staged, "requirements.txt"), "w") as f:
|
| 129 |
+
f.write(compose_requirements(root, a.config))
|
| 130 |
folder = staged
|
| 131 |
elif a.base_tag:
|
| 132 |
staged = tempfile.mkdtemp(prefix="disco-space-")
|
|
|
|
| 138 |
g.write(f.read().replace("BASE_TAG", a.base_tag))
|
| 139 |
folder = staged
|
| 140 |
info = api.upload_folder(folder_path=folder, repo_id=a.space, repo_type="space", ignore_patterns=IGNORE,
|
| 141 |
+
delete_patterns=["Dockerfile", "Dockerfile.base", "Dockerfile.space", "entrypoint.sh", "README-*zero.md", "requirements-*zero.txt"] if a.zero
|
| 142 |
else (["Dockerfile.base", "Dockerfile.space"] if a.base_tag else None),
|
| 143 |
commit_message=f"disco-space {sha[:12]}" + (f" on base {a.base_tag[:12]}" if a.base_tag else "") + (f", {a.config}" if a.config != DEFAULT_CONFIG else ""))
|
| 144 |
if staged:
|
tools/gate_job.py
CHANGED
|
@@ -4,19 +4,43 @@ nothing but pytest. The job runs ``tests/test_acceptance.py`` and prints ``gate.
|
|
| 4 |
prints its log and exits with the job's outcome. The Space is private, so the container gets the caller's Hub token
|
| 5 |
as the job secret ``HF_TOKEN`` (the layout download reads it too).
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
import argparse
|
| 10 |
import os
|
| 11 |
import sys
|
| 12 |
import time
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
SCRIPT = r'''
|
| 15 |
set -e
|
| 16 |
cd /app
|
| 17 |
pip install -q --retries 10 pytest
|
| 18 |
python -c "import jax; print('devices', jax.devices())"
|
| 19 |
-
JAX_PLATFORMS=cuda XLA_PYTHON_CLIENT_PREALLOCATE=false XLA_FLAGS=--xla_gpu_deterministic_ops=true HF_HOME=/tmp/hf JAX_COMPILATION_CACHE_DIR=/tmp/jax-cache \
|
| 20 |
GATE_OUT=/tmp/gate.json PYTHONPATH=/app pytest tests/test_acceptance.py -s -q -p no:cacheprovider
|
| 21 |
echo "=== gate.json"; cat /tmp/gate.json; echo; echo "=== GATE PASSED"
|
| 22 |
'''
|
|
@@ -27,11 +51,13 @@ def main():
|
|
| 27 |
ap.add_argument("--space", default=os.environ.get("DISCO_SPACE", "rfick/disco"))
|
| 28 |
ap.add_argument("--flavor", default="l4x1")
|
| 29 |
ap.add_argument("--timeout", default="45m")
|
|
|
|
| 30 |
a = ap.parse_args()
|
| 31 |
from huggingface_hub import HfApi, get_token
|
| 32 |
token = os.environ.get("HF_TOKEN") or get_token()
|
| 33 |
api = HfApi(token=token)
|
| 34 |
rev = api.repo_info(a.space, repo_type="space").sha
|
|
|
|
| 35 |
job = api.run_job(image=f"hf.co/spaces/{a.space}", command=["bash", "-c", SCRIPT],
|
| 36 |
secrets={"HF_TOKEN": token}, flavor=a.flavor, timeout=a.timeout, name=f"disco-space-gate-{rev[:8]}")
|
| 37 |
print(f"job {job.id} on {a.flavor}: the image of {a.space} (repo at {rev})", flush=True)
|
|
|
|
| 4 |
prints its log and exits with the job's outcome. The Space is private, so the container gets the caller's Hub token
|
| 5 |
as the job secret ``HF_TOKEN`` (the layout download reads it too).
|
| 6 |
|
| 7 |
+
The job starts only once the Space runs the deployed commit (:func:`wait_for_build`): a deploy that changes a variable
|
| 8 |
+
rebuilds the image, and a job started on an image still building errors with no log.
|
| 9 |
+
|
| 10 |
+
python tools/gate_job.py [--space rfick/disco] [--flavor l4x1] [--timeout 45m] [--build-timeout 2400]
|
| 11 |
"""
|
| 12 |
import argparse
|
| 13 |
import os
|
| 14 |
import sys
|
| 15 |
import time
|
| 16 |
|
| 17 |
+
POLL_S = 30
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def wait_for_build(api, space, rev, timeout_s):
|
| 21 |
+
"""Return once ``space`` runs commit ``rev`` (stage RUNNING with that sha, on two polls ``POLL_S`` apart, so a stage
|
| 22 |
+
read just before a rebuild starts does not count); exit on an error stage or after ``timeout_s``."""
|
| 23 |
+
t0, seen = time.time(), 0
|
| 24 |
+
time.sleep(POLL_S)
|
| 25 |
+
while True:
|
| 26 |
+
r = api.get_space_runtime(space)
|
| 27 |
+
stage, sha = r.stage, (r.raw or {}).get("sha")
|
| 28 |
+
if str(stage).endswith("ERROR"):
|
| 29 |
+
sys.exit(f"the Space is in {stage} at {sha}: the image to test did not build or start")
|
| 30 |
+
seen = seen + 1 if (stage == "RUNNING" and sha == rev) else 0
|
| 31 |
+
if seen >= 2:
|
| 32 |
+
print(f"{space} runs {rev[:12]} ({time.time() - t0:.0f} s after the deploy)", flush=True)
|
| 33 |
+
return
|
| 34 |
+
if time.time() - t0 > timeout_s:
|
| 35 |
+
sys.exit(f"the Space is still {stage} at {sha} after {timeout_s} s, not RUNNING at {rev}")
|
| 36 |
+
time.sleep(POLL_S)
|
| 37 |
+
|
| 38 |
SCRIPT = r'''
|
| 39 |
set -e
|
| 40 |
cd /app
|
| 41 |
pip install -q --retries 10 pytest
|
| 42 |
python -c "import jax; print('devices', jax.devices())"
|
| 43 |
+
DISCO_GATE=1 JAX_PLATFORMS=cuda XLA_PYTHON_CLIENT_PREALLOCATE=false XLA_FLAGS=--xla_gpu_deterministic_ops=true HF_HOME=/tmp/hf DMIPY_REPLAY_CACHE=/tmp/replay JAX_COMPILATION_CACHE_DIR=/tmp/jax-cache \
|
| 44 |
GATE_OUT=/tmp/gate.json PYTHONPATH=/app pytest tests/test_acceptance.py -s -q -p no:cacheprovider
|
| 45 |
echo "=== gate.json"; cat /tmp/gate.json; echo; echo "=== GATE PASSED"
|
| 46 |
'''
|
|
|
|
| 51 |
ap.add_argument("--space", default=os.environ.get("DISCO_SPACE", "rfick/disco"))
|
| 52 |
ap.add_argument("--flavor", default="l4x1")
|
| 53 |
ap.add_argument("--timeout", default="45m")
|
| 54 |
+
ap.add_argument("--build-timeout", type=int, default=2400, help="seconds to wait for the Space to run the deployed commit")
|
| 55 |
a = ap.parse_args()
|
| 56 |
from huggingface_hub import HfApi, get_token
|
| 57 |
token = os.environ.get("HF_TOKEN") or get_token()
|
| 58 |
api = HfApi(token=token)
|
| 59 |
rev = api.repo_info(a.space, repo_type="space").sha
|
| 60 |
+
wait_for_build(api, a.space, rev, a.build_timeout)
|
| 61 |
job = api.run_job(image=f"hf.co/spaces/{a.space}", command=["bash", "-c", SCRIPT],
|
| 62 |
secrets={"HF_TOKEN": token}, flavor=a.flavor, timeout=a.timeout, name=f"disco-space-gate-{rev[:8]}")
|
| 63 |
print(f"job {job.id} on {a.flavor}: the image of {a.space} (repo at {rev})", flush=True)
|
tools/live.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""The live Space through its Gradio API: the configuration's first preset at
|
| 2 |
then a custom two-shell protocol on the configuration's first timing class; prints the headline, the timings and the
|
| 3 |
seconds the worker spent on the packs' responses the page had not cached and, with ``--stream``, every stage update as it
|
| 4 |
arrives; downloads the figures and files to ``$LIVE_OUT``. ``--config`` is the configuration the Space serves
|
|
@@ -39,7 +39,7 @@ def main():
|
|
| 39 |
def inputs(preset, *, knob, n_keys=1, ladder=True):
|
| 40 |
shells = [True, shapes[0], 1000, 30, 12.0, 24.0, 53.5, True, shapes[0], 3000, 45, 8.0, 20.0, 53.5,
|
| 41 |
False, shapes[0], 3000, 90, 17.0, 30.0, 53.5, False, shapes[0], 6000, 60, 17.0, 30.0, 53.5]
|
| 42 |
-
return [preset, 2, True,
|
| 43 |
|
| 44 |
c = Client(a.space, token=get_token(), verbose=False, download_files=os.environ.get("LIVE_OUT", "/tmp/disco-live"), httpx_kwargs={"timeout": 900})
|
| 45 |
for preset in a.preset or (S.presets(cfg)[0], A.CUSTOM):
|
|
@@ -62,7 +62,7 @@ def main():
|
|
| 62 |
rows = named["timings"]["data"] if isinstance(named["timings"], dict) else named["timings"]
|
| 63 |
responses = [r[1] for r in rows if r[0] == A.RESPONSES_ROW]
|
| 64 |
print(f"the packs' responses on the worker: {responses[0] if responses else 'none (the source prepares nothing)'} s", flush=True)
|
| 65 |
-
print("tck", named
|
| 66 |
|
| 67 |
|
| 68 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
"""The live Space through its Gradio API: the configuration's first preset at its own starting SNR with the tissue panel's defaults,
|
| 2 |
then a custom two-shell protocol on the configuration's first timing class; prints the headline, the timings and the
|
| 3 |
seconds the worker spent on the packs' responses the page had not cached and, with ``--stream``, every stage update as it
|
| 4 |
arrives; downloads the figures and files to ``$LIVE_OUT``. ``--config`` is the configuration the Space serves
|
|
|
|
| 39 |
def inputs(preset, *, knob, n_keys=1, ladder=True):
|
| 40 |
shells = [True, shapes[0], 1000, 30, 12.0, 24.0, 53.5, True, shapes[0], 3000, 45, 8.0, 20.0, 53.5,
|
| 41 |
False, shapes[0], 3000, 90, 17.0, 30.0, 53.5, False, shapes[0], 6000, 60, 17.0, 30.0, 53.5]
|
| 42 |
+
return [preset, 2, True, P.preset_snr(cfg, preset) if preset in cfg.get("presets", {}) else cfg["noise"]["snr"], tc["density"][2], tc["max_angle"][2], tc["step"][2], 0, None, knob, A.IDEAL, n_keys, ladder, *physics, *shells]
|
| 43 |
|
| 44 |
c = Client(a.space, token=get_token(), verbose=False, download_files=os.environ.get("LIVE_OUT", "/tmp/disco-live"), httpx_kwargs={"timeout": 900})
|
| 45 |
for preset in a.preset or (S.presets(cfg)[0], A.CUSTOM):
|
|
|
|
| 62 |
rows = named["timings"]["data"] if isinstance(named["timings"], dict) else named["timings"]
|
| 63 |
responses = [r[1] for r in rows if r[0] == A.RESPONSES_ROW]
|
| 64 |
print(f"the packs' responses on the worker: {responses[0] if responses else 'none (the source prepares nothing)'} s", flush=True)
|
| 65 |
+
print("tck", named.get("tck"), "volumes", named.get("volumes"), flush=True)
|
| 66 |
|
| 67 |
|
| 68 |
if __name__ == "__main__":
|
tools/smoke_tensor.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The b-tensor preset through the page's own run button without a browser: A at 3 T with B = A with the field off
|
| 2 |
+
(the knob), the explorer's ladder on; every output of :data:`space.app.OUTPUTS` is produced and the b-tensor ones are
|
| 3 |
+
images; then a voxel click, an STE row of the DWI slice and the explorer's B − A of µFA.
|
| 4 |
+
|
| 5 |
+
DISCO_LAYOUT=/local/layout python tools/smoke_tensor.py [--snr 30]
|
| 6 |
+
"""
|
| 7 |
+
import argparse
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
import types
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main():
|
| 16 |
+
ap = argparse.ArgumentParser(); ap.add_argument("--snr", type=float, default=30.0); ap.add_argument("--knob", default="field tier → off")
|
| 17 |
+
a = ap.parse_args()
|
| 18 |
+
from space import app as A
|
| 19 |
+
from space import pipeline as P
|
| 20 |
+
st = A._load()
|
| 21 |
+
if st["error"]:
|
| 22 |
+
raise SystemExit(st["error"])
|
| 23 |
+
src, cfg = st["source"], st["cfg"]; S = type(src)
|
| 24 |
+
preset = next(n for n, v in cfg["presets"].items() if "tensor" in v)
|
| 25 |
+
panel = S.panel(cfg)
|
| 26 |
+
vals = {c.name: c.value for row in panel.rows for c in row}
|
| 27 |
+
values = [vals[f] for f in panel.fields]
|
| 28 |
+
shells = [False, "d12-D24", 1000, 30, 10.0, 20.0, 60.0] * A.MAX_SHELLS
|
| 29 |
+
args = (preset, 1, True, a.snr, 2, 30.0, 0.5, 0, None, a.knob, A.IDEAL, 1, True, *values, *shells)
|
| 30 |
+
t0 = time.perf_counter(); last = None; texts = []
|
| 31 |
+
for item in A.run_pipeline(A.compute, *args):
|
| 32 |
+
last = item
|
| 33 |
+
if isinstance(item, tuple) and isinstance(item[1], str):
|
| 34 |
+
texts.append(item[1])
|
| 35 |
+
out = dict(zip(A.OUTPUTS, last)); secs = time.perf_counter() - t0
|
| 36 |
+
rs = out["result"]
|
| 37 |
+
kinds = {k: type(v).__name__ for k, v in out.items()}
|
| 38 |
+
for k in ("tensor_view", "tensor_pick", "tensor_view_b", "dwi_view", "explore_view", "metric_view"):
|
| 39 |
+
assert out[k] is not None and hasattr(out[k], "size"), (k, kinds[k])
|
| 40 |
+
assert out["tract_view"] is None and out["mats"] is None
|
| 41 |
+
evt = types.SimpleNamespace(index=(20 * A.PICK + 3, 19 * A.PICK + 3))
|
| 42 |
+
curve = A.pick_voxel(rs, 20, evt)
|
| 43 |
+
ste = A.redraw_slice(rs, 20, 3, True, "A", "STE")
|
| 44 |
+
ex = A.redraw_explorer(rs, rs["explorer"]["layers"][-1][0], "B − A", A.METRICS[3], 20, 0, "LTE")
|
| 45 |
+
tm = rs["A"]["tensor"]; sig = np.isfinite(rs["A"]["dwi"][..., 0])
|
| 46 |
+
report = dict(seconds=secs, headline=out["headline"][:600], stages=texts[-6:], outputs=kinds,
|
| 47 |
+
ufa_median=float(np.nanmedian(tm["ufa"][sig])), fa_median=float(np.nanmedian(tm["fa"][sig])),
|
| 48 |
+
md_median=float(np.nanmedian(tm["md"][sig])), voxel_curve=curve is not None, ste_slice=ste[0] is not None,
|
| 49 |
+
explorer_b_minus_a_mufa=ex[0] is not None, files=[str(f).rsplit("/", 1)[-1] for f in out["volumes"]],
|
| 50 |
+
timings=out["timings"])
|
| 51 |
+
print(json.dumps(report, indent=1, default=str))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
if __name__ == "__main__":
|
| 55 |
+
main()
|