Spaces:
Running on Zero
Running on Zero
disco-space 8c9afd1593ec
Browse files- README.md +9 -5
- app.py +15 -17
- data/SOURCE.md +1 -1
- 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 +2 -2
- space/app.py +173 -45
- space/brain.toml +4 -0
- space/classes.py +114 -0
- space/config.toml +73 -25
- space/pipeline.py +86 -7
- space/reference.py +42 -0
- space/sources/disco.py +196 -95
- space/tensor.py +245 -0
- space/viewers.py +63 -0
- tests/test_acceptance.py +75 -59
- tests/test_app.py +88 -6
- tests/test_pipeline.py +97 -38
- tests/test_tensor.py +108 -0
- tests/test_viewers.py +12 -0
- tools/deploy.py +8 -2
- tools/smoke_tensor.py +55 -0
README.md
CHANGED
|
@@ -11,12 +11,16 @@ license: mit
|
|
| 11 |
short_description: DiSCo replay to connectome on the shared GPU pool
|
| 12 |
---
|
| 13 |
|
| 14 |
-
# The DiSCo Space
|
| 15 |
|
| 16 |
-
The
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
call. Source and pins: https://github.com/dmrai-lab/disco-space (`requirements-zero.txt`, `app.py`).
|
| 21 |
|
| 22 |
## GPU quota per visitor
|
|
|
|
| 11 |
short_description: DiSCo replay to connectome on the shared GPU pool
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# The DiSCo replay Space (`rfick/disco-replay`)
|
| 15 |
|
| 16 |
+
The DiSCo page (dmrai-lab/disco-space) on Hugging Face's shared GPU pool, serving the v2 DiSCo walk through its
|
| 17 |
+
time-segment layout (`SubstrateCommons/disco-time-segments`): the compute backend is PyTorch (`DISCO_BACKEND=torch`:
|
| 18 |
+
dmipy-sim's time-segment image, dmipy-fit's `csd_tournier07_torch`, dmipy-tract's torch tracker), the GPU exists for
|
| 19 |
+
the duration of one run (`spaces.GPU`), and the layout's packed words are streamed to the device a chunk at a time
|
| 20 |
+
inside every call (decoded in the kernel) from the layout downloaded from the Hub when the container started. Its
|
| 21 |
+
acquisitions are the paper's DiSCo flagship set: DiSCo's own 364 measurements, clinical, research, Connectome 2.0 and
|
| 22 |
+
a cosine OGSE at 50 Hz, each one stored waveform of the layout. (Formerly `rfick/disco-zero`; the old address
|
| 23 |
+
redirects.) Deterministic algorithms are on and TF32 off inside the
|
| 24 |
call. Source and pins: https://github.com/dmrai-lab/disco-space (`requirements-zero.txt`, `app.py`).
|
| 25 |
|
| 26 |
## GPU quota per visitor
|
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
|
@@ -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.
|
|
|
|
| 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
|
@@ -17,7 +17,7 @@ matplotlib>=3.8
|
|
| 17 |
plotly>=5.20
|
| 18 |
pillow>=10
|
| 19 |
dipy>=1.9
|
| 20 |
-
# dmipy-sim: main at the merge of
|
| 21 |
-
dmipy-sim @ git+https://github.com/dmrai-lab/dmipy-sim@
|
| 22 |
dmipy-fit @ git+https://github.com/dmrai-lab/dmipy-fit@c3dcc546595cbde8e8e7c7bb2ff4e87f099d5dab
|
| 23 |
dmipy-tract @ git+https://github.com/dmrai-lab/dmipy-tract@7da22c3a9473c50a08cc7c6246ec16b68a64d877
|
|
|
|
| 17 |
plotly>=5.20
|
| 18 |
pillow>=10
|
| 19 |
dipy>=1.9
|
| 20 |
+
# dmipy-sim: main at the merge of #828 (the columnar field window; after #823, the streamed time-segment image)
|
| 21 |
+
dmipy-sim @ git+https://github.com/dmrai-lab/dmipy-sim@8d7b97f982e0c10e744d0065dd2cafbcc1c6d781
|
| 22 |
dmipy-fit @ git+https://github.com/dmrai-lab/dmipy-fit@c3dcc546595cbde8e8e7c7bb2ff4e87f099d5dab
|
| 23 |
dmipy-tract @ git+https://github.com/dmrai-lab/dmipy-tract@7da22c3a9473c50a08cc7c6246ec16b68a64d877
|
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
|
|
@@ -523,19 +588,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():
|
|
@@ -624,7 +733,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])
|
|
@@ -657,6 +768,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 +783,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 +819,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 +846,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 +859,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
|
|
|
|
| 588 |
return gr.update(value=int(value), maximum=int(maximum))
|
| 589 |
|
| 590 |
|
| 591 |
+
def redraw_explorer(rs, layer, mode, metric, z, m, rows=ROWS[0]):
|
| 592 |
ex = rs and rs.get("explorer")
|
| 593 |
+
second = (METRICS[3] if ex and ex["meas"].encoding is not None else METRICS[2]) if metric == METRICS[0] else metric
|
| 594 |
+
return (explore(ex, layer, mode, metric, z, m, rows), explore(ex, layer, mode, second, z, m, rows), *ingredient_views(ex, z))
|
| 595 |
|
| 596 |
|
| 597 |
+
def redraw_slice(rs, z, m, overlay, which, rows=ROWS[0]):
|
| 598 |
if not rs or which not in rs:
|
| 599 |
+
return None, None, None, None, None, None, None
|
| 600 |
r = rs[which]; source = _load()["source"]
|
| 601 |
views = source.describe(source.cfg)["views"]
|
| 602 |
+
m = row_of(r["meas"], rows, m)
|
| 603 |
+
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}: "),
|
| 604 |
V.floor_slice(r["floor"], int(z), r["floor_median"], label=f"{which}: ") if views["floor"] else None,
|
| 605 |
+
source.truth_view(r["dwi"], r["meas"], int(z), m), source.fractions_view(r["extras"], int(z)), *tensor_views(rs, int(z)))
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
def tensor_views(rs, z):
|
| 609 |
+
"""A b-tensor run's maps at slice ``z`` for A (with the ground truth's crossings outlined) and B, and the clickable
|
| 610 |
+
µFA slice of A; Nones on any other run."""
|
| 611 |
+
if not rs or "tensor" not in rs.get("A", {}) or rs["A"]["tensor"] is None:
|
| 612 |
+
return None, None, None
|
| 613 |
+
crossing = _crossings()
|
| 614 |
+
a = V.tensor_maps(rs["A"]["tensor"], crossing, z, label="A: ")
|
| 615 |
+
b = V.tensor_maps(rs["B"]["tensor"], crossing, z, label="B: ") if rs.get("B") and rs["B"].get("tensor") else None
|
| 616 |
+
return a, V.map_picker(rs["A"]["tensor"]["ufa"][:, :, z], PICK, vmin=0.0, vmax=1.0), b
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
def pick_voxel(rs, z, evt):
|
| 620 |
+
"""The powder curve (linear and spherical, A and dashed B) of the voxel clicked on the µFA slice: ``evt.index`` is
|
| 621 |
+
the click's pixel ``(x, y)``, a voxel ``PICK`` pixels wide, the slice drawn with y up."""
|
| 622 |
+
if not rs or not rs.get("A", {}).get("tensor"):
|
| 623 |
+
return None
|
| 624 |
+
tm = rs["A"]["tensor"]; nx, ny = tm["ufa"].shape[:2]
|
| 625 |
+
x, y = (int(v) for v in evt.index)
|
| 626 |
+
i, j = min(x // PICK, nx - 1), min(ny - 1 - y // PICK, ny - 1)
|
| 627 |
+
other = rs["B"]["tensor"] if rs.get("B") and rs["B"].get("tensor") else None
|
| 628 |
+
return V.powder_curve(tm, (i, j, int(z)), other=other)
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
_CROSSINGS = {}
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
def _crossings():
|
| 635 |
+
"""The voxels the ground truth's strands cross in two or more directions (the source's strand configuration),
|
| 636 |
+
computed once per process."""
|
| 637 |
+
return _strands()[0] >= 2
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
def _strands():
|
| 641 |
+
"""``(n_dirs, length)`` per voxel of the ground truth's strands (the source's strand configuration), computed once
|
| 642 |
+
per process."""
|
| 643 |
+
if "c" not in _CROSSINGS:
|
| 644 |
+
from .sources.disco import strand_configuration
|
| 645 |
+
n_dirs, _, length = strand_configuration(shape=_load()["source"].mask.shape)
|
| 646 |
+
_CROSSINGS["c"] = (np.asarray(n_dirs), np.asarray(length))
|
| 647 |
+
return _CROSSINGS["c"]
|
| 648 |
|
| 649 |
|
| 650 |
def ground_truth_views():
|
|
|
|
| 733 |
physics_inputs = [widgets[name] for name in panel.fields]
|
| 734 |
with gr.Row():
|
| 735 |
snr_on = gr.Checkbox(value=True, label="add Rician noise")
|
| 736 |
+
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)")
|
| 737 |
+
if any("tensor" in p for p in (cfg.get("presets") or {}).values()):
|
| 738 |
+
gr.Markdown(SNR_NOTE)
|
| 739 |
density = gr.Slider(tc["density"][0], tc["density"][1], value=tc["density"][2], step=tc["density"][3], label=tc["density"][4])
|
| 740 |
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])
|
| 741 |
step_mm = gr.Slider(tc["step"][0], tc["step"][1], value=tc["step"][2], step=tc["step"][3], label=tc["step"][4])
|
|
|
|
| 768 |
with gr.Row():
|
| 769 |
ez_slider = gr.Slider(0, 39, value=20, step=1, label="axial slice z")
|
| 770 |
em_slider = gr.Slider(0, 363, value=0, step=1, label="measurement (DWI only)")
|
| 771 |
+
erows = gr.Radio(list(ROWS), value=ROWS[0], label="rows (a b-tensor run: the measurement counts within the encoding)")
|
| 772 |
with gr.Row():
|
| 773 |
explore_view = gr.Image(label="the chosen layer and mode", type="pil")
|
| 774 |
metric_view = gr.Image(label="the same for FA (or the chosen metric)", type="pil")
|
|
|
|
| 783 |
m_slider = gr.Slider(0, 363, value=0, step=1, label="measurement")
|
| 784 |
overlay = gr.Checkbox(value=True, label="FOD principal directions")
|
| 785 |
which = gr.Radio(["A", "B"], value="A", label="run")
|
| 786 |
+
rows_radio = gr.Radio(list(ROWS), value=ROWS[0], label="rows (b-tensor runs)")
|
| 787 |
with gr.Column(scale=1, visible=d["views"]["truth"]):
|
| 788 |
truth_view = gr.Image(label="the same slice with the input FOD's principal directions", type="pil")
|
| 789 |
with gr.Column(scale=1):
|
| 790 |
tract_view = gr.Plot(label="tractogram A")
|
| 791 |
fractions_view = gr.Image(label="recovered vs input fractions", type="pil", visible=d["views"]["fractions"])
|
| 792 |
+
with gr.Column(visible=False) as tensor_row: # a b-tensor run: the maps and a voxel's powder curves
|
| 793 |
+
tensor_view = gr.Image(label="b-tensor maps of A: FA, µFA, MD (the slice above)", type="pil")
|
| 794 |
+
with gr.Row():
|
| 795 |
+
tensor_pick = gr.Image(label="µFA of A: click a voxel", type="pil", interactive=False)
|
| 796 |
+
powder_view = gr.Image(label="the clicked voxel's powder signal: linear and spherical, A and B (dashed)", type="pil")
|
| 797 |
+
tensor_view_b = gr.Image(label="b-tensor maps of B", type="pil")
|
| 798 |
+
with gr.Column() as connectome_row:
|
| 799 |
+
mats = gr.Image(label="connectome A vs its truth", type="pil")
|
| 800 |
+
lobar_view = gr.Image(label="connectome A vs its truth over the lobar groups", type="pil", visible=d["views"]["lobar"])
|
| 801 |
+
with gr.Row(visible=False) as b_row:
|
| 802 |
+
tract_view_b = gr.Plot(label="tractogram B")
|
| 803 |
+
mats_b = gr.Image(label="connectome B vs its truth", type="pil")
|
| 804 |
+
spread_view = gr.Image(label="A over N tracker keys: mean and spread per pair", type="pil")
|
| 805 |
roundtrip = gr.Dataframe(headers=["what", "value"], label="the round trip: the reconstruction and the connectome against the input",
|
| 806 |
interactive=False, wrap=True, visible=d["views"]["roundtrip"])
|
| 807 |
with gr.Accordion("accuracy: what this replay is an approximation of", open=False):
|
|
|
|
| 819 |
def run_with_progress(*args, progress=gr.Progress()):
|
| 820 |
yield from run_pipeline(compute_fn, *args, progress=progress)
|
| 821 |
tissue_numbers = [widgets[name] for name in panel.catalogue] + [catalogue_note]
|
| 822 |
+
preset.change(lambda name: P.preset_snr(cfg, name), inputs=preset, outputs=snr, show_progress="hidden")
|
| 823 |
field_preset.change(lambda name: [field_presets[name]] + S.catalogue_numbers(cfg, field_presets[name]), inputs=field_preset,
|
| 824 |
outputs=[widgets["field_T"]] + tissue_numbers, show_progress="hidden")
|
| 825 |
reset.click(lambda f: S.catalogue_numbers(cfg, f), inputs=widgets["field_T"], outputs=tissue_numbers, show_progress="hidden")
|
|
|
|
| 846 |
floor_view=floor_view, accuracy=accuracy, spread_view=spread_view, explore_view=explore_view, metric_view=metric_view,
|
| 847 |
ingredient_pool=ingredient_pool, ingredient_contact=ingredient_contact, ingredient_field=ingredient_field,
|
| 848 |
layer_table=layer_tbl, layer_choice=layer_choice, ez_slider=ez_slider, em_slider=em_slider, truth_view=truth_view,
|
| 849 |
+
fractions_view=fractions_view, lobar_view=lobar_view, roundtrip=roundtrip, response_view=response_view,
|
| 850 |
+
tensor_row=tensor_row, tensor_view=tensor_view, tensor_pick=tensor_pick, powder_view=powder_view,
|
| 851 |
+
tensor_view_b=tensor_view_b, connectome_row=connectome_row)
|
| 852 |
+
for ctl in (layer_choice, mode, metric, ez_slider, em_slider, erows):
|
| 853 |
+
ctl.change(redraw_explorer, inputs=[result, layer_choice, mode, metric, ez_slider, em_slider, erows],
|
| 854 |
outputs=[explore_view, metric_view, ingredient_pool, ingredient_contact, ingredient_field], show_progress="hidden")
|
| 855 |
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]
|
| 856 |
if runner is not None: # the pool: what the run reserves, live as the inputs change
|
|
|
|
| 859 |
outputs=[outputs[name] for name in OUTPUTS], concurrency_limit=1, api_name="run_pipeline") # the endpoint tools/live.py drives
|
| 860 |
if runner is not None: # the run's responses are now cached on the page: the reservation falls
|
| 861 |
run_event.then(gpu_seconds_text, inputs=run_inputs, outputs=gpu_text, show_progress="hidden")
|
| 862 |
+
for ctl in (z_slider, m_slider, overlay, which, rows_radio):
|
| 863 |
+
ctl.change(redraw_slice, inputs=[result, z_slider, m_slider, overlay, which, rows_radio],
|
| 864 |
+
outputs=[dwi_view, floor_view, truth_view, fractions_view, tensor_view, tensor_pick, tensor_view_b], show_progress="hidden")
|
| 865 |
+
|
| 866 |
+
def on_pick(rs, z, evt: gr.SelectData):
|
| 867 |
+
return pick_voxel(rs, z, evt)
|
| 868 |
+
tensor_pick.select(on_pick, inputs=[result, z_slider], outputs=powder_view, show_progress="hidden")
|
| 869 |
gt_tab.select(ground_truth_views, outputs=[strands_view, gt_matrix])
|
| 870 |
demo.load(lambda: (_load().get("error") and f"**the source did not load:** {_load()['error']}") or "", outputs=headline)
|
| 871 |
return demo
|
space/brain.toml
CHANGED
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-sim's own 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 |
+
import dmipy_sim as d
|
| 33 |
+
sh = dict(key)
|
| 34 |
+
k = kind(sh)
|
| 35 |
+
if k == "pgse":
|
| 36 |
+
return d.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 d.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 d.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 |
+
from dmipy_sim.acquisition.waveforms import calc_b
|
| 79 |
+
t = template(sh)
|
| 80 |
+
G = np.asarray(t.G, np.float64)[0, :, 2]
|
| 81 |
+
peak = float(np.abs(G).max())
|
| 82 |
+
return G / peak, float(calc_b(t)[0]) / peak ** 2
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def amplitude(sh, b):
|
| 86 |
+
"""The peak gradient (T/m) the class plays to reach ``b`` (s/mm^2)."""
|
| 87 |
+
return float(np.sqrt(max(float(b), 0.0) * 1e6 / unit_b(sh)[1]))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def play(sh, bvals_SI, dirs):
|
| 91 |
+
"""The ScannerSequence of rows on the class: its shape at each row's amplitude along each row's unit direction
|
| 92 |
+
(``bvals_SI`` in s/m^2, ``dirs`` ``(n, 3)``); a b = 0 row plays nothing."""
|
| 93 |
+
from dmipy_sim.acquisition.scanner_sequence import ScannerSequence
|
| 94 |
+
t = template(sh)
|
| 95 |
+
prof, unit = unit_b(sh)
|
| 96 |
+
b = np.asarray(bvals_SI, np.float64).ravel()
|
| 97 |
+
u = np.asarray(dirs, np.float64).reshape(-1, 3)
|
| 98 |
+
n = np.linalg.norm(u, axis=1)
|
| 99 |
+
u = np.where(n[:, None] > 0, u / np.where(n > 0, n, 1.0)[:, None], 0.0)
|
| 100 |
+
amp = np.sqrt(np.maximum(b, 0.0) / unit)
|
| 101 |
+
G = prof[None, :, None] * (amp[:, None] * u)[:, None, :]
|
| 102 |
+
return ScannerSequence(G=G.astype(np.float32), dt=t.dt, rf=t.rf, readout=t.readout)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def describe(sh):
|
| 106 |
+
"""The class's timing in a few words, for the gradient table."""
|
| 107 |
+
k = kind(sh)
|
| 108 |
+
if k == "tensor":
|
| 109 |
+
return "b-tensor class (three shapes: linear, planar, spherical at any rotation)"
|
| 110 |
+
if k == "ogse":
|
| 111 |
+
return f"cosine {float(sh['frequency']):g} Hz, {float(sh['duration']) * 1e3:g} ms per side"
|
| 112 |
+
if k == "pgste":
|
| 113 |
+
return f"STE δ {float(sh['delta']) * 1e3:g} / Δ {float(sh['Delta']) * 1e3:g} ms"
|
| 114 |
+
return f"δ {float(sh['delta']) * 1e3:g} / Δ {float(sh['Delta']) * 1e3:g} ms"
|
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
|
|
@@ -88,7 +134,7 @@ pools = ["intra", "extra"] # the pack's seeded pools (its spec also nam
|
|
| 88 |
# The scanner menu after "ideal": catalogue machines of dmipy-sim (ScannerLimits.of(key)), each applied with every term
|
| 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_sim.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 = "8277ff05ea8237a98f1f8bdfe6a86ce37c4fcdc3"
|
| 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-sim's own
|
| 15 |
+
# 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
|
|
|
|
| 134 |
# The scanner menu after "ideal": catalogue machines of dmipy-sim (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/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
|
|
@@ -1015,13 +1076,18 @@ def limits(key):
|
|
| 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 |
|
|
@@ -1120,6 +1186,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 +1200,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
|
|
|
|
| 1076 |
def playable(protocol, shapes, scanner=None):
|
| 1077 |
"""Per shell, the gradient it needs and whether ``scanner`` (a catalogue key, or None for no limit) can play it:
|
| 1078 |
rows of ``(shell, b, delta, Delta, G_needed, G_max or None, ok)`` in SI. A stimulated-echo class needs the same
|
| 1079 |
+
amplitude as its PGSE twin (the same δ and diffusion time); an OGSE class the peak of its own waveform
|
| 1080 |
+
(:func:`space.classes.amplitude`; its delta / Delta are the scheme's nominal block and separation)."""
|
| 1081 |
+
from . import classes as C
|
| 1082 |
G_max = float(limits(scanner).G_max) if scanner else None
|
| 1083 |
rows = []
|
| 1084 |
for s in protocol.shells:
|
| 1085 |
t = dict(delta=s.delta, Delta=s.Delta) if s.free_timing else shapes[s.shape]
|
| 1086 |
delta, Delta = float(t["delta"]), float(t["Delta"])
|
| 1087 |
+
if t.get("kind") == "tensor":
|
| 1088 |
+
G = C.tensor_peak(t, s.encoding, s.b, s.n_dirs) # per axis, over the shell's rotations
|
| 1089 |
+
else:
|
| 1090 |
+
G = C.amplitude(t, s.b) if t.get("kind") == "ogse" else gradient_needed(s.b, delta, Delta)
|
| 1091 |
rows.append((s.shape, s.b, delta, Delta, G, G_max, G_max is None or G <= G_max))
|
| 1092 |
return rows
|
| 1093 |
|
|
|
|
| 1186 |
extras: dict = field(default_factory=dict)
|
| 1187 |
|
| 1188 |
|
| 1189 |
+
TENSOR_STAGES = ("replay", "noise", "maps") # a b-tensor protocol's stages
|
| 1190 |
+
|
| 1191 |
+
|
| 1192 |
def run_stages(source, protocol, *, snr=None, tracking=Tracking(), noise_seed=0, physics=None, prepared=None):
|
| 1193 |
"""The pipeline as a generator: before each of the source's ``STAGES`` it yields ``(stage, k, n)`` (so a page
|
| 1194 |
can show where the run is, from inside any worker), and last the :class:`Result`. ``physics`` is the tissue and
|
|
|
|
| 1200 |
def at(stage):
|
| 1201 |
print(f"[pipeline] {stage} at +{time.perf_counter() - t_run:.1f} s", flush=True) # the server log shows where a run is
|
| 1202 |
return stage, stages.index(stage), len(stages)
|
| 1203 |
+
if protocol.tensor: # a b-tensor protocol: microstructure maps, not a connectome
|
| 1204 |
+
stages = TENSOR_STAGES
|
| 1205 |
meas = source.validate(protocol, physics)
|
| 1206 |
yield at("replay"); dwi, floor, s0_factor, t_replay = source.replay(meas, physics, prepared)
|
| 1207 |
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
|
| 1208 |
signal = np.isfinite(dwi[..., 0])
|
| 1209 |
+
if protocol.tensor:
|
| 1210 |
+
from . import tensor as T
|
| 1211 |
+
yield at("maps"); t0 = time.perf_counter(); tmaps = T.maps(noisy, meas, signal)
|
| 1212 |
+
seconds = dict(replay=t_replay, noise=t_noise, maps=time.perf_counter() - t0)
|
| 1213 |
+
seconds["total"] = sum(seconds.values())
|
| 1214 |
+
yield Result(protocol, meas, noisy, floor, s0_factor, None, None, None, None, None, snr=snr, physics=physics, clean=dwi,
|
| 1215 |
+
seconds=seconds, extras=dict(tensor=tmaps))
|
| 1216 |
+
return
|
| 1217 |
yield at("csd"); sh, t_csd, extras = source.reconstruct(noisy, s0_factor, meas, signal)
|
| 1218 |
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
|
| 1219 |
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_sim.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_sim.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/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-sim's
|
| 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
|
|
@@ -299,7 +303,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 +347,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 +413,65 @@ def compare(a, b):
|
|
| 363 |
|
| 364 |
|
| 365 |
class Layout(Disco):
|
| 366 |
-
"""Demo mode: dmipy-sim's
|
| 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 +479,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 +517,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:`~dmipy_sim.replay.
|
| 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 +556,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,17 +598,17 @@ 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:`dmipy_sim.replay.columnar.ColumnarPack`) at
|
| 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 |
|
|
@@ -534,15 +621,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 +640,27 @@ class Columns(Disco):
|
|
| 551 |
return meas
|
| 552 |
|
| 553 |
def sequence(self, meas):
|
| 554 |
-
"""The one ScannerSequence of the measurements
|
| 555 |
-
|
|
|
|
| 556 |
import dmipy_sim as d
|
|
|
|
|
|
|
| 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``
|
|
@@ -583,7 +684,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-sim'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
|
|
|
|
| 303 |
"""The ground truth tab: a sample of the strands in 3-D with the region markers, and the two matrices."""
|
| 304 |
from dmipy_sim.io.strands import read_tck, read_diameters
|
| 305 |
from .. import viewers as V
|
| 306 |
+
strands = read_tck(strands_path(self.cfg), coordinate_unit_m=VOXEL_M)
|
| 307 |
diameters = read_diameters(os.path.join(P.DATA_DIR, "DiSCo_Strands_Diameters.txt"), diameter_unit_m=1e-3)
|
| 308 |
return (V.strands3d([s / VOXEL_M for s in strands], diameters, V.region_markers(self.regions), self.mask.shape),
|
| 309 |
V.ground_truth_matrix(self.gt_count, self.gt_area, P.connected_pairs(self.gt_count)))
|
|
|
|
| 347 |
return [pool, contact, fld]
|
| 348 |
|
| 349 |
|
| 350 |
+
@functools.lru_cache(maxsize=2)
|
| 351 |
+
def strands_path(cfg=None):
|
| 352 |
+
"""The DiSCo strand centrelines (``.tck``, voxel units; CC BY 4.0): ``[data] strands`` in the replay dataset
|
| 353 |
+
(``[data] pack``'s repository) at ``[data] pack_revision``, fetched into the Hub cache once."""
|
| 354 |
+
from huggingface_hub import hf_hub_download
|
| 355 |
+
d = (cfg or P.config())["data"]
|
| 356 |
+
repo = "/".join(d["pack"][len("hf://"):].split("/")[:2])
|
| 357 |
+
return hf_hub_download(repo, d["strands"], repo_type="dataset", revision=d["pack_revision"])
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def strand_configuration(tck=None, shape=(40, 40, 40), *, angle_deg=30.0, share=0.10):
|
| 361 |
+
"""The ground truth's fibre configuration per voxel from the released strand trajectories (voxel units):
|
| 362 |
+
``(n_dirs, dispersion, length)`` -- the distinct strand directions crossing the voxel (the strands' length-weighted
|
| 363 |
+
mean tangents in it, clustered at ``angle_deg``, a direction counted when it carries at least ``share`` of the voxel's
|
| 364 |
+
strand length), ``1 - lambda_1 / trace`` of the length-weighted dyadic tensor of the tangents, and the strand length
|
| 365 |
+
in the voxel (voxel units)."""
|
| 366 |
+
from dmipy_sim.io.strands import read_tck
|
| 367 |
+
strands = read_tck(tck or strands_path(), coordinate_unit_m=1.0)
|
| 368 |
+
n_vox = int(np.prod(shape))
|
| 369 |
+
Tm = np.zeros((n_vox, 3, 3)); per = {}
|
| 370 |
+
for c in strands:
|
| 371 |
+
c = np.asarray(c, float)
|
| 372 |
+
if len(c) < 2:
|
| 373 |
+
continue
|
| 374 |
+
seg = np.diff(c, axis=0); mid = 0.5 * (c[1:] + c[:-1]); L = np.linalg.norm(seg, axis=1); ok = L > 0
|
| 375 |
+
t = seg[ok] / L[ok, None]; ijk = np.floor(mid[ok]).astype(int); inside = np.all((ijk >= 0) & (ijk < np.asarray(shape)), axis=1)
|
| 376 |
+
v = np.ravel_multi_index(ijk[inside].T, shape); w = L[ok][inside]; t = t[inside]
|
| 377 |
+
np.add.at(Tm, v, (t[:, :, None] * t[:, None, :]) * w[:, None, None])
|
| 378 |
+
for vox in np.unique(v):
|
| 379 |
+
k = v == vox
|
| 380 |
+
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())))
|
| 381 |
+
ev = np.linalg.eigvalsh(Tm); tr = ev.sum(-1)
|
| 382 |
+
disp = np.where(tr > 0, 1.0 - ev[:, -1] / np.maximum(tr, 1e-30), np.nan)
|
| 383 |
+
ndirs = np.zeros(n_vox, int); cos = np.cos(np.radians(angle_deg))
|
| 384 |
+
for vox, items in per.items():
|
| 385 |
+
items = sorted(items, key=lambda x: -x[1]); total = sum(w for _, w in items); clusters = []
|
| 386 |
+
for d, w in items:
|
| 387 |
+
for cl in clusters:
|
| 388 |
+
if abs(d @ cl[0]) >= cos:
|
| 389 |
+
cl[1] += w; break
|
| 390 |
+
else:
|
| 391 |
+
clusters.append([d, w])
|
| 392 |
+
ndirs[vox] = sum(1 for _, w in clusters if w >= share * total)
|
| 393 |
+
return ndirs.reshape(shape), disp.reshape(shape), tr.reshape(shape)
|
| 394 |
+
|
| 395 |
+
|
| 396 |
PAIRS = np.triu_indices(N_REGIONS, 1) # the 120 region pairs
|
| 397 |
|
| 398 |
|
|
|
|
| 413 |
|
| 414 |
|
| 415 |
class Layout(Disco):
|
| 416 |
+
"""Demo mode: dmipy-sim's time-segment layout (``dmipy_sim.replay.time_segments``) at the config's dataset revision,
|
| 417 |
+
or a local copy. Every stored class is one waveform (:mod:`space.classes`), checked at load to be the layout's
|
| 418 |
+
partition of that name; a shell plays it at its b-values and directions."""
|
| 419 |
mode = "demo"
|
| 420 |
|
| 421 |
@classmethod
|
| 422 |
def load(cls, cfg, *, local=None):
|
| 423 |
+
"""The layout at ``local``, ``DISCO_LAYOUT``, or the config's dataset revision on the Hub."""
|
| 424 |
+
return cls(cfg, local=local or os.environ.get("DISCO_LAYOUT"))
|
| 425 |
|
| 426 |
def __init__(self, cfg, *, local=None):
|
| 427 |
+
import hashlib
|
| 428 |
+
from dmipy_sim.replay.time_segments import MANIFEST, TimeSegmentLayout
|
| 429 |
+
from .. import classes as C
|
| 430 |
super().__init__(cfg)
|
| 431 |
d = cfg["data"]
|
| 432 |
+
uri = local or f"hf://{d['repo']}/{d['layout']}"
|
| 433 |
+
names = list(self.shapes)
|
| 434 |
+
self.layout = TimeSegmentLayout.open(uri, revision=d.get("revision") or None, partitions=names)
|
| 435 |
self.resident = P.resident(cfg)
|
| 436 |
+
want = d.get("manifest_sha256")
|
| 437 |
+
with open(os.path.join(self.layout.path, MANIFEST), "rb") as f:
|
| 438 |
+
got = hashlib.sha256(f.read()).hexdigest()
|
| 439 |
if want and got != want:
|
| 440 |
+
raise ValueError(f"the layout at {uri} has manifest {got[:12]}, the config pins {want[:12]}")
|
| 441 |
+
self.manifest_sha256 = got
|
| 442 |
+
missing = [s for s in names if s not in self.layout.partitions]
|
| 443 |
if missing:
|
| 444 |
+
raise ValueError(f"the layout at {uri} lacks the timing classes {missing}; it holds {list(self.layout.partitions)}")
|
| 445 |
+
for name, sh in self.shapes.items(): # each class's waveform is the stored partition's
|
| 446 |
+
got_name, _ = self.layout.fit(C.template(sh), partition=name)
|
| 447 |
+
if not self.layout.partitions[name].is_constant and C.kind(sh) not in ("ogse", "tensor"):
|
| 448 |
+
raise ValueError(f"class {name}: the layout stores shaped terms, the config a square-pulse {C.kind(sh)}")
|
| 449 |
+
m = self.layout.manifest
|
| 450 |
+
self.tiers = bool(m.get("tiers"))
|
| 451 |
+
self.M0 = self.layout.weights.sum(1).reshape(self.layout.grid.shape) # every voxel's walker weight: the bare b = 0 signal
|
| 452 |
+
if self.tiers: # the pools with walkers: the pool ids of the rows that weigh something
|
| 453 |
+
t = m["tiers"]
|
|
|
|
|
|
|
| 454 |
spec_pools = sorted(t["substrate"]["pools"], key=lambda q: q["id"]) if t.get("substrate") else []
|
| 455 |
+
ids = self.seeded_pool_ids()
|
| 456 |
+
seeded = tuple(q["name"] for q in spec_pools if q["id"] in ids)
|
| 457 |
if set(seeded) != set(self.pools):
|
| 458 |
raise ValueError(f"the layout's seeded pools are {seeded}, the config's [physics] pools {self.pools}")
|
| 459 |
+
self.unseeded = tuple(q["name"] for q in spec_pools if q["id"] not in ids)
|
| 460 |
+
self.check_grid(self.layout.grid.shape, "the layout")
|
| 461 |
+
|
| 462 |
+
def seeded_pool_ids(self, chunk=1 << 16):
|
| 463 |
+
"""The pool ids of the layout's rows with weight (a padding row weighs nothing), read once in chunks of tiles."""
|
| 464 |
+
w = self.layout._column("w"); pool = self.layout._column("pool"); ids = set()
|
| 465 |
+
for s in range(0, self.layout.n_tiles, chunk):
|
| 466 |
+
q = np.asarray(pool[s:s + chunk]); ids |= set(np.unique(q[np.asarray(w[s:s + chunk]) > 0]).tolist())
|
| 467 |
+
return ids
|
| 468 |
+
|
| 469 |
+
def background(self, name):
|
| 470 |
+
"""Whether the class's sequence group carries the background vectors a magnet's own gradient needs."""
|
| 471 |
+
return bool(self.layout.manifest["groups"][self.layout.manifest["partitions"][name]["group"]].get("background"))
|
| 472 |
|
| 473 |
def validate(self, protocol, physics):
|
| 474 |
+
from .. import classes as C
|
| 475 |
meas = P.measurements(protocol, self.shapes)
|
| 476 |
if physics and not physics.bare and not self.tiers:
|
| 477 |
raise ValueError("this layout holds bare diffusion only: switch the tissue panel off")
|
|
|
|
| 479 |
raise ValueError(f"the tissue names the pools {physics.pools}; this layout's are {self.pools}")
|
| 480 |
if physics and physics.scanner is not None:
|
| 481 |
L = P.limits(physics.scanner)
|
| 482 |
+
shaped = [n for n in np.unique(meas.shape) if not self.layout.partitions[n].is_constant]
|
| 483 |
+
if shaped:
|
| 484 |
+
raise ValueError(f"a machine's delivered gradient is one vector per constant segment, and the classes {shaped} are "
|
| 485 |
+
"shaped waveforms (an OGSE): choose the ideal scanner for them")
|
| 486 |
+
if L.has_field_law and not all(self.background(n) for n in np.unique(meas.shape)):
|
| 487 |
+
raise ValueError(f"the {physics.scanner}'s own gradient needs the layout's background vectors, and this layout "
|
| 488 |
+
"lacks them for a class of this run")
|
| 489 |
+
for name in np.unique(meas.shape):
|
| 490 |
+
C.kind(self.shapes[name])
|
| 491 |
return meas
|
| 492 |
|
| 493 |
+
def sequence(self, name, bvals, dirs, encodings=None):
|
| 494 |
+
"""The rows on class ``name`` (b in s/mm^2) as the class's waveform at their amplitudes and directions; on a
|
| 495 |
+
b-tensor class each row's ``encodings`` entry (``b0``, ``LTE``, ``STE``) about its direction."""
|
| 496 |
+
from .. import classes as C
|
| 497 |
+
sh = self.shapes[name]
|
| 498 |
+
if C.kind(sh) == "tensor":
|
| 499 |
+
if encodings is None:
|
| 500 |
+
raise ValueError(f"class {name} is a b-tensor class: its rows name their encodings")
|
| 501 |
+
return T._sequence(C.tensor_rows(sh, encodings, bvals, dirs))
|
| 502 |
+
return C.play(sh, np.asarray(bvals, np.float64) * 1e6, dirs)
|
| 503 |
+
|
| 504 |
+
def delivered(self, name, seq, physics):
|
| 505 |
+
"""What the run's machine delivers at every voxel with rows for the class ``name`` playing ``seq``: the layout's
|
| 506 |
+
:meth:`~dmipy_sim.replay.time_segments.TimeSegmentLayout.delivery` with the grid's centre at the physics'
|
| 507 |
+
offset; None on the ideal scanner."""
|
| 508 |
if physics is None or physics.scanner is None:
|
| 509 |
return None
|
| 510 |
+
return self.layout.delivery(seq, P.limits(physics.scanner), self.layout.grid.centred_at(physics.offset_m), partition=name)
|
| 511 |
|
| 512 |
def replay(self, meas, physics=None, prepared=None):
|
| 513 |
t0 = time.perf_counter()
|
|
|
|
| 517 |
b0 = physics.b0_direction if physics else P.B0_ALONG_Z
|
| 518 |
for name in np.unique(meas.shape):
|
| 519 |
rows = meas.shape == name
|
| 520 |
+
seq = self.sequence(name, meas.bvals[rows], meas.dirs[rows], None if meas.encoding is None else meas.encoding[rows])
|
| 521 |
+
img, f = self.layout.image(seq, partition=str(name), backend=self.backend, resident=self.resident, tissue=tissue,
|
| 522 |
+
scanner=scanner, b0_direction=b0, delivered=self.delivered(str(name), seq, physics))
|
| 523 |
+
S[..., rows] = img * self.M0[..., None] # the voxel's signal: its walkers' weight times the normalised one
|
| 524 |
+
floor = np.fmax(floor, f)
|
|
|
|
| 525 |
dwi, factor = P.s0_normalised(S, meas.b0, self.M0)
|
| 526 |
return dwi, floor, factor, time.perf_counter() - t0
|
| 527 |
|
| 528 |
def release(self):
|
| 529 |
if not self.resident:
|
| 530 |
+
self.layout.release()
|
| 531 |
|
| 532 |
def ingredients(self, meas, physics=None, prepared=None):
|
| 533 |
"""What each tier multiplies into the sum, per voxel, for the run's first class (the layout's
|
| 534 |
+
:meth:`~dmipy_sim.replay.time_segments.TimeSegmentLayout.tier_maps`): ``intra_fraction`` (the walker weight in
|
| 535 |
+
the intra-axonal pool over the voxel's), ``wall_contact_um`` (the walkers' boundary local time l under the
|
| 536 |
+
class's gate, a length; the layout stores it signed as the exponent's term, -l, so the contact tier's weight is
|
| 537 |
+
exp(rho2 c / D) = exp(-rho2 l / D)), ``contact_survival`` (that factor at the run's rho2, None without the
|
| 538 |
+
contact tier), ``field_rad`` (the spread over the voxel's walkers of the dephasing phase the sheath's field
|
| 539 |
+
gives them by the echo at the run's field and direction, the exact per-walker phase the kernel applies, in
|
| 540 |
+
radians; None without the field tier), and ``D_walk`` (m^2/s)."""
|
|
|
|
| 541 |
if not self.tiers:
|
| 542 |
return None
|
| 543 |
tissue, scanner = P.tissue_and_scanner(physics, self.unseeded)
|
| 544 |
b0 = physics.b0_direction if physics else P.B0_ALONG_Z
|
| 545 |
+
maps = self.layout.tier_maps(str(meas.shape[0]), tissue, scanner, backend=self.backend, resident=self.resident, b0_direction=b0)
|
| 546 |
contact = maps["contact"]
|
| 547 |
+
return dict(D_walk=float(self.layout.manifest["tiers"]["D_walk"]), intra_fraction=maps["pool"]["intra"],
|
| 548 |
wall_contact_um=None if contact is None else -contact * 1e6,
|
| 549 |
contact_survival=maps["contact_weight"] if (physics and physics.contact) else None,
|
| 550 |
field_rad=maps["phase_std"] if (physics and physics.field) else None)
|
|
|
|
| 556 |
|
| 557 |
def warm(self):
|
| 558 |
"""DiSCo's own protocol replayed once at the default physics (every class it plays compiled at the row
|
| 559 |
+
counts the default run uses), then one direction on every other class (on a b-tensor class one row of each
|
| 560 |
+
encoding). When the columns are not kept resident
|
| 561 |
+
(a pool that drops the device between calls), the padded host arrays are loaded into this process instead, so
|
| 562 |
+
a forked worker inherits them and pays the transfer alone."""
|
| 563 |
+
if not self.resident and self.backend == "torch":
|
| 564 |
+
self.layout.preload(list(self.shapes))
|
| 565 |
return
|
| 566 |
physics = self.default_physics()
|
| 567 |
disco = disco_protocol(self.cfg)[0]
|
| 568 |
self.replay(P.measurements(disco, self.shapes), physics)
|
| 569 |
+
played = {s.shape for s in disco.shells}
|
| 570 |
+
rest = [n for n, sh in self.shapes.items() if n not in played and sh.get("kind") != "tensor"]
|
| 571 |
if rest:
|
| 572 |
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)
|
| 573 |
+
for name in [n for n, sh in self.shapes.items() if sh.get("kind") == "tensor"]: # a b-tensor class: one row per encoding
|
| 574 |
+
shells = tuple(P.Shell(name, 1000.0, 1, encoding=e) for e in ("LTE", "STE"))
|
| 575 |
+
self.replay(P.measurements(P.Protocol(shells, n_b0=1, name="warm"), self.shapes), physics)
|
| 576 |
|
| 577 |
def accuracy(self, res=None):
|
| 578 |
+
from .. import classes as C
|
| 579 |
+
m = self.layout.manifest; src = m["source"]; cert = m["certificate"]; env = src["envelope"]
|
| 580 |
+
rows = [["source", f"the {src['kind']}'s coefficients ({len(src.get('blocks') or [])} blocks); classes appended from the "
|
| 581 |
+
"quantised pack carry its quantisation in their own term" if any(p.get("source") for p in m["partitions"].values())
|
| 582 |
+
else f"the {src['kind']}'s coefficients ({len(src.get('blocks') or [])} blocks)"],
|
| 583 |
+
["floor target (the words' budget)", f"{float(src['floor_target']):.4g}"],
|
| 584 |
+
["layout's worst signal error over its classes and tiers (err_max)", f"{float(cert['err_max']):.2e}"],
|
| 585 |
+
["envelope: gradient per axis / B0 / ρ₂/D", f"{float(env['G_max']) * 1e3:.0f} mT/m ({env.get('scanner') or env.get('bound')}) / "
|
| 586 |
+
f"{float(env['B0_max_T']):g} T / {float(env['rho2_over_D_max']):g} 1/m"],
|
| 587 |
+
["layout manifest sha256", self.manifest_sha256[:16]],
|
| 588 |
+
["layout written by", f"{(m.get('code') or {}).get('commit') or '?'} on {m.get('created', '?')}"],
|
| 589 |
+
["pulses", "one waveform per class: square lobes (slew rate ∞) for the PGSE and stimulated-echo classes, "
|
| 590 |
+
"the cosine OGSE with its 200 T/m/s edges as built at b = 1000"],
|
| 591 |
["tiers stored", "pool, contact, field" if self.tiers else "none (bare diffusion only)"],
|
| 592 |
+
["background vectors (a magnet's own gradient)", "stored, certified to {:g} mT/m".format(float(src["background_amplitude"]) * 1e3)
|
| 593 |
+
if any(g.get("background") for g in m["groups"].values()) else "not stored: a machine with its own gradient is refused"]]
|
| 594 |
if res is not None and res.physics is not None and res.physics.scanner is not None:
|
| 595 |
rows.append(["scanner", f"{res.physics.scanner}, the phantom's centre at "
|
| 596 |
f"({', '.join(f'{x * 100:.1f}' for x in res.physics.offset_m)}) cm from isocentre: every catalogued term "
|
|
|
|
| 598 |
rows += super().accuracy(res)
|
| 599 |
if res is not None:
|
| 600 |
for name in np.unique(res.meas.shape):
|
| 601 |
+
p = m["partitions"][str(name)]
|
| 602 |
+
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; "
|
| 603 |
+
f"{4 * int(p['n_words'])} B per walker, rounding variance {float(p['rounding']['variance']):.2e} rad²"])
|
|
|
|
| 604 |
return rows
|
| 605 |
|
| 606 |
|
| 607 |
class Columns(Disco):
|
| 608 |
"""Full mode: the columnar replay pack (:class:`dmipy_sim.replay.columnar.ColumnarPack`) at
|
| 609 |
+
:func:`space.pipeline.columns_uri`, read once per run: one ScannerSequence per run (one echo time, one RF schedule;
|
| 610 |
+
the stored classes' waveforms, square pulses for a free timing), the tissue and field of the run, the field along
|
| 611 |
+
the pack's z."""
|
| 612 |
mode = "full"
|
| 613 |
RATE = {True: 45e6, False: 500e6} # bytes/s read from the Hub, from a local disk
|
| 614 |
|
|
|
|
| 621 |
self.remote = self.uri.startswith("hf://")
|
| 622 |
|
| 623 |
def kinds(self, meas):
|
| 624 |
+
"""The pulse kinds (``pgse`` / ``pgste`` / ``ogse``) of the classes the rows play; a free timing is PGSE."""
|
| 625 |
+
from .. import classes as C
|
| 626 |
+
return sorted({C.kind(self.shapes[n]) if n in self.shapes else "pgse" for n in np.unique(meas.shape)})
|
| 627 |
|
| 628 |
def validate(self, protocol, physics):
|
| 629 |
meas = P.measurements(protocol, self.shapes)
|
| 630 |
if len(np.unique(np.round(meas.TE, 9))) != 1:
|
| 631 |
raise ValueError("full mode replays one echo time per run")
|
| 632 |
+
kinds = self.kinds(meas)
|
| 633 |
+
if len(kinds) != 1 and not set(kinds) <= {"pgse", "ogse"}:
|
| 634 |
+
raise ValueError("full mode replays one RF schedule per run (spin echoes, or a stimulated echo)")
|
| 635 |
if physics and not physics.along_z:
|
| 636 |
raise ValueError("full mode replays the field along z only")
|
| 637 |
if physics and physics.scanner is not None:
|
|
|
|
| 640 |
return meas
|
| 641 |
|
| 642 |
def sequence(self, meas):
|
| 643 |
+
"""The one ScannerSequence of the measurements, rows in order: a stored class's rows play its waveform
|
| 644 |
+
(:mod:`space.classes`, the layout's own), a free shell square PGSE lobes at its delta / Delta, all at the rows'
|
| 645 |
+
common TE on the layout's grid."""
|
| 646 |
import dmipy_sim as d
|
| 647 |
+
from dmipy_sim.acquisition.scanner_sequence import ScannerSequence
|
| 648 |
+
from .. import classes as C
|
| 649 |
TE = float(np.unique(np.round(meas.TE, 9))[0])
|
| 650 |
+
G = np.zeros((len(meas.bvals), C.N_T, 3), np.float32); tmpl = None
|
| 651 |
+
for name in np.unique(meas.shape):
|
| 652 |
+
rows = meas.shape == name
|
| 653 |
+
if name in self.shapes and C.kind(self.shapes[name]) == "tensor":
|
| 654 |
+
seq = T._sequence(C.tensor_rows(self.shapes[name], meas.encoding[rows], meas.bvals[rows], meas.dirs[rows]))
|
| 655 |
+
elif name in self.shapes:
|
| 656 |
+
seq = C.play(self.shapes[name], meas.bvals[rows] * 1e6, meas.dirs[rows])
|
| 657 |
+
else:
|
| 658 |
+
seq = d.pgse(meas.dirs[rows].tolist(), meas.delta[rows], meas.Delta[rows], bvalues=meas.bvals[rows] * 1e6, TE=TE,
|
| 659 |
+
n_t=C.N_T, slew_rate=np.inf)
|
| 660 |
+
if tmpl is not None and (seq.dt != tmpl.dt or seq.rf.to_dicts() != tmpl.rf.to_dicts()):
|
| 661 |
+
raise ValueError("full mode replays one RF schedule and grid per run")
|
| 662 |
+
tmpl = seq; G[rows] = np.asarray(seq.G, np.float32)
|
| 663 |
+
return ScannerSequence(G=G, dt=tmpl.dt, rf=tmpl.rf, readout=tmpl.readout)
|
| 664 |
|
| 665 |
def plan(self, meas, physics=None):
|
| 666 |
"""What the replay will read (the pack's plan: bands, modes, tiers, rows, bytes) and ``estimated_seconds``
|
|
|
|
| 684 |
m = self.pack.meta
|
| 685 |
rows = [["source pack", str(m.get("id"))], ["source pack's certified median floor", f"{self.pack.floor:.4g}"],
|
| 686 |
["bands kept", str(self.pack.K)], ["band tolerance (× the pack's floor)", f"{P.BAND_TOL:g}"],
|
| 687 |
+
["pulses", "the stored classes' waveforms, square (slew rate ∞) for a free timing; one TE and one RF schedule per run"]]
|
| 688 |
plan = getattr(self, "last_plan", None)
|
| 689 |
if plan:
|
| 690 |
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 |
+
import dmipy_sim as d
|
| 51 |
+
return d.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_sim.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_sim.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-sim's own b-tensor of the sequence, and the peak gradient per axis (T/m) of every row."""
|
| 140 |
+
from dmipy_sim.acquisition.waveforms import btensor_invariants, calc_btensor
|
| 141 |
+
B = calc_btensor(seq)
|
| 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,66 @@ 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 tensor_waveform(G, dt, gate, label):
|
| 361 |
+
"""The class's three shapes as played (physical gradient, mT/m) over the echo time, the 180 marked."""
|
| 362 |
+
plt = _mpl()
|
| 363 |
+
t = np.arange(G.shape[0]) * dt * 1e3
|
| 364 |
+
fig, ax = plt.subplots(figsize=(7.5, 2.8))
|
| 365 |
+
for k, c in enumerate(AXIS_COLOURS):
|
| 366 |
+
ax.plot(t, G[:, k] * 1e3, color=c, lw=1.2, label=f"ψ{k + 1}")
|
| 367 |
+
flip = np.nonzero(np.diff(np.asarray(gate)) != 0)[0]
|
| 368 |
+
for f in flip:
|
| 369 |
+
ax.axvline(t[f] + 0.5 * (t[1] - t[0]), color="0.5", ls=":", lw=1)
|
| 370 |
+
ax.set_xlabel("t (ms)"); ax.set_ylabel("G (mT/m)"); ax.set_title(label, fontsize=9); ax.legend(fontsize=8, ncol=3)
|
| 371 |
+
return _image(fig)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def map_picker(sl, scale, *, vmin=0.0, vmax=1.0, cmap="viridis"):
|
| 375 |
+
"""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
|
| 376 |
+
its voxel exactly (``x // scale``, ``ny - 1 - y // scale``); NaN grey."""
|
| 377 |
+
from PIL import Image
|
| 378 |
+
import matplotlib
|
| 379 |
+
a = np.asarray(sl, np.float64).T[::-1] # rows top to bottom = y from high to low
|
| 380 |
+
rgba = matplotlib.colormaps[cmap]((np.clip(np.nan_to_num(a, nan=vmin), vmin, vmax) - vmin) / (vmax - vmin))
|
| 381 |
+
rgba[~np.isfinite(a)] = (0.5, 0.5, 0.5, 1.0)
|
| 382 |
+
img = (np.repeat(np.repeat(rgba[..., :3], int(scale), axis=0), int(scale), axis=1) * 255).astype(np.uint8)
|
| 383 |
+
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
|
|
@@ -219,7 +221,7 @@ def test_the_estimated_seconds_grow_with_the_run_and_stay_in_the_pools_window():
|
|
| 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 +232,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 +286,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
|
|
|
|
| 221 |
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
|
| 222 |
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)
|
| 223 |
assert f"reserves {ladder} s" in text and "logged out" in text.split("started by")[1].split(".")[0]
|
| 224 |
+
assert len(A.OUTPUTS) == 36 # the 30 of a connectome run and the b-tensor run's six
|
| 225 |
|
| 226 |
|
| 227 |
def test_the_explorer_draws_layers_differences_and_metrics_from_its_state():
|
|
|
|
| 232 |
rng = np.random.default_rng(0)
|
| 233 |
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)
|
| 234 |
md = rng.random((4, 4, 2)).astype(np.float16); fa = rng.random((4, 4, 2)).astype(np.float16)
|
| 235 |
+
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")},
|
| 236 |
floor=np.full((4, 4, 2), 0.01, np.float32), meas=m, mask=np.ones((4, 4, 2), bool),
|
| 237 |
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)),
|
| 238 |
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)),
|
| 239 |
snr=None, physics=None)
|
| 240 |
for mode in A.EXPLORE_MODES:
|
| 241 |
+
for metric in A.METRICS[:3]:
|
| 242 |
img = A.explore(ex, "A = bare + field", mode, metric, 1, 5)
|
| 243 |
assert img is not None and img.size[0] > 100, (mode, metric)
|
| 244 |
+
assert A.explore(ex, "A = bare + field", "signal", A.METRICS[3], 1, 5) is None # no µFA off a b-tensor run
|
| 245 |
assert A.explore(ex, "bare diffusion", "minus the previous layer", A.METRICS[0], 0, 0) is not None
|
| 246 |
pool, contact, fld = A.ingredient_views(ex, 1)
|
| 247 |
assert pool is not None and contact is not None and fld is None
|
|
|
|
| 286 |
assert light.dwi.dtype == np.float32 and light.clean.dtype == np.float32 and res.dwi.dtype == np.float64
|
| 287 |
np.testing.assert_allclose(light.dwi, dwi, rtol=1e-6); assert light.snr == 30.0 and light.meas is m
|
| 288 |
assert A._light(P.Result(p, m, dwi, floor, None, None, None, None, None, {})).clean is None
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def _tensor_rs():
|
| 292 |
+
"""A results state of a b-tensor run on a 6 x 5 x 2 grid: A and B, their maps, the measurement rows."""
|
| 293 |
+
p = P.preset_protocol(CFG, next(n for n, v in CFG["presets"].items() if "tensor" in v))
|
| 294 |
+
m = P.measurements(p, CFG["shapes"])
|
| 295 |
+
rng = np.random.default_rng(1)
|
| 296 |
+
bvals = sorted(set(float(b) for b in m.bvals if b > 0))
|
| 297 |
+
|
| 298 |
+
def maps(scale):
|
| 299 |
+
return dict(fa=rng.random((6, 5, 2)) * scale, md=rng.random((6, 5, 2)), ufa=rng.random((6, 5, 2)) * scale, bvals=bvals,
|
| 300 |
+
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})
|
| 301 |
+
dwi = rng.uniform(0.2, 1.0, (6, 5, 2, len(m.bvals))).astype(np.float32)
|
| 302 |
+
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,
|
| 303 |
+
extras=dict(fractions=None), tensor=A._tensor_state(maps(s)))
|
| 304 |
+
return dict(A=run(1.0), B=run(0.5)), m
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def test_a_b_tensor_runs_rows_maps_and_voxel_click():
|
| 308 |
+
"""On a b-tensor run the measurement slider counts within the chosen encoding; the µFA slice is a picker whose
|
| 309 |
+
click pixel names its voxel (y up); the clicked voxel's powder curves draw for A and B; the maps draw for both."""
|
| 310 |
+
rs, m = _tensor_rs()
|
| 311 |
+
enc = np.asarray(m.encoding)
|
| 312 |
+
assert A.row_of(m, "every row", 3) == 3
|
| 313 |
+
assert enc[A.row_of(m, "STE", 0)] == "STE" and enc[A.row_of(m, "LTE", 10 ** 6)] == "LTE"
|
| 314 |
+
assert A.row_of(m, "STE", 0) == int(np.flatnonzero(enc == "STE")[0])
|
| 315 |
+
pick = V.map_picker(rs["A"]["tensor"]["ufa"][:, :, 1], A.PICK)
|
| 316 |
+
assert pick.size == (6 * A.PICK, 5 * A.PICK)
|
| 317 |
+
seen = []
|
| 318 |
+
import space.viewers as VV
|
| 319 |
+
orig = VV.powder_curve
|
| 320 |
+
try:
|
| 321 |
+
VV.powder_curve = lambda tm, ijk, other=None: seen.append((tuple(ijk), other is not None)) or "img"
|
| 322 |
+
evt = types.SimpleNamespace(index=(2 * A.PICK + 3, 0)) # x in voxel 2, the top row = y 4
|
| 323 |
+
assert A.pick_voxel(rs, 1, evt) == "img" and seen[-1] == ((2, 4, 1), True)
|
| 324 |
+
finally:
|
| 325 |
+
VV.powder_curve = orig
|
| 326 |
+
assert V.powder_curve(rs["A"]["tensor"], (1, 1, 0), other=rs["B"]["tensor"]).size[0] > 100
|
| 327 |
+
n_dirs = np.ones((6, 5, 2), int); n_dirs[2:4, 1:3] = 2
|
| 328 |
+
A._CROSSINGS["c"] = (n_dirs, np.ones((6, 5, 2)))
|
| 329 |
+
try:
|
| 330 |
+
a, picker, b = A.tensor_views(rs, 0)
|
| 331 |
+
assert a is not None and picker is not None and b is not None
|
| 332 |
+
finally:
|
| 333 |
+
A._CROSSINGS.clear()
|
| 334 |
+
assert A.tensor_views(dict(A=dict(tensor=None)), 0) == (None, None, None)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def test_the_explorer_on_a_b_tensor_run_draws_the_encodings_rows_and_b_minus_a_of_mufa():
|
| 338 |
+
rs, m = _tensor_rs()
|
| 339 |
+
a = rs["A"]["tensor"]; b = rs["B"]["tensor"]
|
| 340 |
+
ex = dict(layers=[("A = bare diffusion", rs["A"]["dwi"].astype(np.float16)), ("B = A with SNR → 10", rs["B"]["dwi"].astype(np.float16))],
|
| 341 |
+
metrics={"A = bare diffusion": (a["md"], a["fa"], a["ufa"]), "B = A with SNR → 10": (b["md"], b["fa"], b["ufa"])},
|
| 342 |
+
floor=np.full((6, 5, 2), 0.01, np.float32), meas=m, mask=np.ones((6, 5, 2), bool), ingredients=[None, None, None],
|
| 343 |
+
differences=[], snr=None, physics=None)
|
| 344 |
+
for metric in A.METRICS:
|
| 345 |
+
for mode in A.EXPLORE_MODES:
|
| 346 |
+
assert A.explore(ex, "A = bare diffusion", mode, metric, 1, 2, "STE") is not None, (metric, mode)
|
| 347 |
+
assert A.explore(ex, "A = bare diffusion", "B − A", A.METRICS[3], 0, 0) is not None
|
| 348 |
+
with pytest.raises(ValueError, match="encodings of its one class"):
|
| 349 |
+
P.retime(P.preset_protocol(CFG, next(n for n, v in CFG["presets"].items() if "tensor" in v)), "d12-D24")
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def test_every_stage_of_a_run_has_its_text():
|
| 353 |
+
"""The progress text names every stage either kind of run passes (the connectome's and the b-tensor's)."""
|
| 354 |
+
texts = S.describe(CFG)["stages"]
|
| 355 |
+
assert set(P.TENSOR_STAGES) <= set(texts) and set(D.Disco.STAGES) <= set(texts)
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def test_a_b_tensor_headline_reads_the_strand_voxels_by_crossing_count(monkeypatch):
|
| 359 |
+
rs, m = _tensor_rs()
|
| 360 |
+
n_dirs = np.zeros((6, 5, 2), int); n_dirs[:2] = 1; n_dirs[2:4] = 2; n_dirs[4:] = 3
|
| 361 |
+
length = np.ones((6, 5, 2)); length[0] = 0
|
| 362 |
+
monkeypatch.setitem(A._CROSSINGS, "c", (n_dirs, length))
|
| 363 |
+
tm = rs["A"]["tensor"]
|
| 364 |
+
res = types.SimpleNamespace(extras=dict(tensor=tm), physics=None, dwi=rs["A"]["dwi"], snr=30.0, seconds=dict(total=1.0),
|
| 365 |
+
protocol=types.SimpleNamespace(n_meas=len(m.bvals)), floor=rs["A"]["floor"])
|
| 366 |
+
text = A.tensor_text("A", res)
|
| 367 |
+
assert "voxels the strands cross" in text and "2 directions" in text and "3+ directions" in text and "SNR 30" in text
|
tests/test_pipeline.py
CHANGED
|
@@ -13,12 +13,51 @@ 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 +161,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 +307,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 +369,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 +385,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 |
from dmipy_sim.phantom import Grid
|
|
|
|
| 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 +423,17 @@ 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 dmipy_sim.acquisition.waveforms import calc_b
|
| 43 |
+
from space import classes as C
|
| 44 |
+
u = np.array([[1.0, 0.0, 0.0], [0.0, 0.6, 0.8], [0.0, 0.0, 1.0]])
|
| 45 |
+
for name, sh in CFG["shapes"].items():
|
| 46 |
+
if C.kind(sh) == "tensor": # its rows are level matrices: tests/test_tensor.py
|
| 47 |
+
continue
|
| 48 |
+
b = np.array([500.0, 1000.0, 0.0]) if C.kind(sh) == "ogse" else np.array([1000.0, 3000.0, 0.0])
|
| 49 |
+
seq = C.play(sh, b * 1e6, u)
|
| 50 |
+
np.testing.assert_allclose(calc_b(seq), b * 1e6, rtol=1e-5, atol=1e3)
|
| 51 |
+
G = np.asarray(seq.G, np.float64)
|
| 52 |
+
assert not G[2].any()
|
| 53 |
+
if C.kind(sh) == "pgse":
|
| 54 |
+
assert abs(C.amplitude(sh, 1000) / P.gradient_needed(1000, sh["delta"], sh["Delta"]) - 1) < 0.02, name
|
| 55 |
+
sh = CFG["shapes"]["cos-f50-T20"]
|
| 56 |
+
G = np.abs(np.asarray(C.play(sh, [0.5e9, 1e9], u[:2]).G, np.float64)).max(axis=2)
|
| 57 |
+
np.testing.assert_allclose(G[0] / G[0].max(), G[1] / G[1].max(), atol=1e-6) # one shape at both b
|
| 58 |
+
assert 0.2 < C.amplitude(sh, 1000) < 0.3 # 0.25 T/m: a 50 Hz cosine is weak in b
|
| 59 |
+
|
| 60 |
+
|
| 61 |
def test_a_protocols_measurements_are_its_rows_in_order():
|
| 62 |
p = P.Protocol((P.Shell("d12-D24", 1000, 30), P.Shell("d8-D20", 6000, 12)), n_b0=2)
|
| 63 |
m = P.measurements(p, CFG["shapes"])
|
|
|
|
| 161 |
|
| 162 |
def test_the_backend_and_residency_come_from_the_config_or_the_environment(monkeypatch):
|
| 163 |
monkeypatch.delenv("DISCO_BACKEND", raising=False); monkeypatch.delenv("DISCO_RESIDENT", raising=False)
|
| 164 |
+
assert P.backend(CFG) == CFG["compute"]["backend"] == "jax" and P.resident(CFG) is False
|
| 165 |
+
monkeypatch.setenv("DISCO_BACKEND", "torch"); monkeypatch.setenv("DISCO_RESIDENT", "1")
|
| 166 |
+
assert P.backend(CFG) == "torch" and P.resident(CFG) is True
|
| 167 |
monkeypatch.setenv("DISCO_BACKEND", "numpy")
|
| 168 |
with pytest.raises(ValueError, match="backend"):
|
| 169 |
P.backend(CFG)
|
|
|
|
| 307 |
|
| 308 |
|
| 309 |
def test_the_layout_at_the_pinned_revision_holds_the_configs_classes_and_tiers():
|
| 310 |
+
"""The manifest the Space will download: every [shapes] class is a stored partition whose terms are that class's
|
| 311 |
+
waveform (its template fits them), and the tiers when the tissue panel starts on (needs the Hub; skipped offline)."""
|
| 312 |
+
import hashlib
|
| 313 |
import json
|
| 314 |
+
from dmipy_sim.replay.time_segments import Partition, Shape
|
| 315 |
+
from space import classes as C
|
| 316 |
hf = pytest.importorskip("huggingface_hub")
|
| 317 |
d = CFG["data"]
|
| 318 |
import requests
|
| 319 |
from huggingface_hub.errors import HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled
|
| 320 |
try:
|
| 321 |
+
path = hf.hf_hub_download(d["repo"], f"{d['layout']}/manifest.json", repo_type="dataset", revision=d["revision"])
|
| 322 |
except (requests.ConnectionError, HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled) as e: # offline, or the Hub unreachable
|
| 323 |
pytest.skip(f"the Hub is not reachable: {e!r}"[:120])
|
| 324 |
+
raw = open(path, "rb").read(); m = json.loads(raw)
|
| 325 |
+
if d.get("manifest_sha256"):
|
| 326 |
+
assert hashlib.sha256(raw).hexdigest() == d["manifest_sha256"]
|
| 327 |
+
missing = [c for c in CFG["shapes"] if c not in m["partitions"]]
|
| 328 |
assert not missing, f"the layout at {d['revision'][:12]} lacks {missing}"
|
| 329 |
+
for name, sh in CFG["shapes"].items():
|
| 330 |
+
p = m["partitions"][name]
|
| 331 |
+
part = Partition(name, tuple(tuple(t["t"]) for t in p["segments"]), tuple(int(t["gate"]) for t in p["segments"]),
|
| 332 |
+
tuple(Shape.from_meta(t["shape"], name) for t in p["segments"]))
|
| 333 |
+
part.levels(C.template(sh)) # refused by name unless the waveform is the class's
|
|
|
|
| 334 |
if CFG["physics"]["default_on"]:
|
| 335 |
assert m.get("tiers"), "the tissue panel starts on but the layout has no tiers"
|
| 336 |
|
|
|
|
| 369 |
calls.append((shape, tissue is not None, scanner, backend, resident, tuple(b0_direction)))
|
| 370 |
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]]]),
|
| 371 |
contact_weight=np.full((2, 1, 1), 0.9), phase=np.zeros((2, 1, 1)), phase_std=np.full((2, 1, 1), 0.1))
|
| 372 |
+
lay = D.Layout.__new__(D.Layout); lay.layout = _M(); lay.tiers = True; lay.backend = "torch"; lay.unseeded = ("myelin",); lay.resident = True
|
| 373 |
meas = P.measurements(P.Protocol((P.Shell("d12-D24", 1000, 6),), n_b0=1), P.config()["shapes"])
|
| 374 |
ph = P.Physics.at(3.0)
|
| 375 |
out = D.Layout.ingredients(lay, meas, ph)
|
|
|
|
| 385 |
|
| 386 |
|
| 387 |
def test_a_machine_reaches_the_layout_as_a_delivery_at_the_placement():
|
| 388 |
+
"""On a stand-in layout: the ideal scanner asks the image for the commanded acquisition (the class's waveform, no
|
| 389 |
+
delivery); a machine asks the layout's delivery for every class with the grid centred at the physics' offset, and
|
| 390 |
+
hands it to that class's image; the image's normalised signal is scaled by every voxel's walker weight; a machine
|
| 391 |
+
with its own gradient on a class without background vectors, and any machine on a shaped class (the OGSE), is
|
| 392 |
+
refused by name."""
|
| 393 |
from dmipy_sim.phantom import Grid
|
| 394 |
+
from space import classes as C
|
| 395 |
seen = []
|
| 396 |
+
|
| 397 |
+
class _Part:
|
| 398 |
+
def __init__(self, constant):
|
| 399 |
+
self.is_constant = constant
|
| 400 |
+
|
| 401 |
class _M:
|
| 402 |
grid = Grid(shape=(2, 1, 1), voxel_size_m=(25e-6,) * 3, origin_m=(0.0, 0.0, 0.0))
|
| 403 |
+
partitions = {n: _Part(C.kind(sh) != "ogse") for n, sh in CFG["shapes"].items()}
|
| 404 |
+
manifest = {"partitions": {n: {"group": "g0"} for n in CFG["shapes"]}, "groups": {"g0": {}}}
|
| 405 |
+
|
| 406 |
+
def delivery(self, seq, scanner, grid, *, partition):
|
| 407 |
+
seen.append(("delivery", partition, scanner.name, tuple(np.round(np.asarray(grid.isocenter_m) - np.asarray(self.grid.isocenter_m), 6))))
|
| 408 |
+
return ("delivered", partition)
|
| 409 |
+
|
| 410 |
+
def image(self, seq, *, partition, backend, resident, tissue, scanner, b0_direction, delivered=None):
|
| 411 |
+
seen.append(("image", partition, delivered, scanner, tuple(b0_direction), np.asarray(seq.G).shape[0]))
|
| 412 |
+
return np.full((2, 1, 1, np.asarray(seq.G).shape[0]), 0.5), np.zeros((2, 1, 1))
|
| 413 |
+
lay = D.Layout.__new__(D.Layout); lay.layout = _M(); lay.tiers = True; lay.backend = "jax"; lay.unseeded = ("myelin",); lay.resident = False
|
| 414 |
+
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"]
|
| 415 |
meas = P.measurements(P.Protocol((P.Shell("d12-D24", 1000, 3), P.Shell("d8-D20", 2000, 3)), n_b0=1), CFG["shapes"])
|
| 416 |
+
dwi, floor, factor, _ = D.Layout.replay(lay, meas, P.Physics.at(3.0))
|
| 417 |
+
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]
|
| 418 |
+
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
|
| 419 |
seen.clear()
|
| 420 |
ph = P.Physics.at(3.0, scanner="siemens_magnetom_prisma_3T", offset_m=(0.0, 0.0, 0.05))
|
| 421 |
D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 1000, 3),), n_b0=1), ph)
|
|
|
|
| 423 |
assert [s for s in seen if s[0] == "delivery"] == [("delivery", "d12-D24", "siemens_magnetom_prisma_3T", (0.0, 0.0, -0.05)),
|
| 424 |
("delivery", "d8-D20", "siemens_magnetom_prisma_3T", (0.0, 0.0, -0.05))]
|
| 425 |
assert [s[2] for s in seen if s[0] == "image"] == [("delivered", "d12-D24"), ("delivered", "d8-D20")]
|
| 426 |
+
with pytest.raises(ValueError, match="shaped waveforms"):
|
| 427 |
+
D.Layout.validate(lay, P.Protocol((P.Shell("cos-f50-T20", 1000, 3),), n_b0=1), ph)
|
| 428 |
swoop = P.Physics.at(0.064, scanner="hyperfine_swoop_64mT", b0_direction=(0.0, 1.0, 0.0))
|
| 429 |
+
with pytest.raises(ValueError, match="background vectors"):
|
|
|
|
|
|
|
|
|
|
| 430 |
D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 100, 3),), n_b0=1), swoop)
|
| 431 |
+
lay.layout.manifest["groups"]["g0"]["background"] = {"column": "bg_g0.npy"}
|
| 432 |
+
D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 100, 3),), n_b0=1), swoop)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def test_each_preset_starts_at_its_own_snr():
|
| 436 |
+
"""The b-tensor preset starts at SNR 100 (µFA is noise-limited); every other preset at ``[noise] snr``."""
|
| 437 |
+
for name, p in CFG["presets"].items():
|
| 438 |
+
assert P.preset_snr(CFG, name) == (100.0 if "tensor" in p else float(CFG["noise"]["snr"])) and CFG["noise"]["snr"] == 30
|
| 439 |
+
assert P.preset_snr(CFG, "DiSCo 364") == 30.0
|
tests/test_tensor.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-sim, 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_sim.acquisition.waveforms import btensor_invariants, calc_btensor
|
| 21 |
+
seq, enc, bs = _rows()
|
| 22 |
+
B = calc_btensor(seq)
|
| 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 |
+
from dmipy_sim.acquisition.waveforms import calc_btensor
|
| 50 |
+
B = calc_btensor(seq) * 1e-9 # s/m^2 -> ms/um^2
|
| 51 |
+
D_iso, mufa = 1.0, 0.8
|
| 52 |
+
x = mufa ** 2 * D_iso ** 2 / (3 - 2 * mufa ** 2) # (D_iso D_Delta)^2 of an axisymmetric tensor of that muFA
|
| 53 |
+
D_delta = np.sqrt(x) / D_iso
|
| 54 |
+
rng = np.random.default_rng(0)
|
| 55 |
+
u = rng.normal(size=(4000, 3)); u /= np.linalg.norm(u, axis=1)[:, None]
|
| 56 |
+
Dpar, Dperp = D_iso * (1 + 2 * D_delta), D_iso * (1 - D_delta)
|
| 57 |
+
D = Dperp * np.eye(3)[None] + (Dpar - Dperp) * u[:, :, None] * u[:, None, :]
|
| 58 |
+
S = np.exp(-np.einsum("mij,cij->mc", B, D)).mean(1)
|
| 59 |
+
S = S / S[0]
|
| 60 |
+
pw = T.powder(S[None, None, None, :], enc, bs, list(bvals))
|
| 61 |
+
md, got = T.md_ufa(pw, list(bvals))
|
| 62 |
+
assert abs(float(md.ravel()[0]) - D_iso) < 0.03 * D_iso
|
| 63 |
+
assert abs(float(got.ravel()[0]) - mufa) < 0.03
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_the_tensor_preset_is_linear_and_spherical_rows_on_the_class_and_flags_a_prisma():
|
| 67 |
+
"""The preset: one b = 0 row, then LTE and STE interleaved at b 100, 700, 1400, 2000 with 6, 6, 10, 16 axes, every
|
| 68 |
+
row's b-tensor the one asked for as dmipy-sim reads it off the sequence the Layout plays; rotated, the rows need
|
| 69 |
+
more than a Prisma's 80 mT/m on one axis, so the page flags that machine and passes the ideal scanner."""
|
| 70 |
+
from dmipy_sim.acquisition.waveforms import btensor_invariants, calc_btensor
|
| 71 |
+
from space import classes as C
|
| 72 |
+
name = next(n for n in CFG["presets"] if "tensor" in CFG["presets"][n])
|
| 73 |
+
p = P.preset_protocol(CFG, name)
|
| 74 |
+
assert p.tensor and p.n_b0 == 1 and [(s.encoding, s.b, s.n_dirs) for s in p.shells] == [
|
| 75 |
+
(e, b, n) for b, n in ((100.0, 6), (700.0, 6), (1400.0, 10), (2000.0, 16)) for e in ("LTE", "STE")]
|
| 76 |
+
m = P.measurements(p, CFG["shapes"])
|
| 77 |
+
G = C.tensor_rows(TC, m.encoding, m.bvals, m.dirs)
|
| 78 |
+
b, b_delta, _ = btensor_invariants(calc_btensor(T._sequence(G)))
|
| 79 |
+
np.testing.assert_allclose(b[1:] / 1e6, m.bvals[1:], rtol=1e-6)
|
| 80 |
+
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)
|
| 81 |
+
rows = P.playable(p, CFG["shapes"], "siemens_magnetom_prisma_3T")
|
| 82 |
+
assert not all(r[-1] for r in rows) and all(r[-1] for r in P.playable(p, CFG["shapes"], None))
|
| 83 |
+
assert 0.10 < max(r[4] for r in rows) < 0.14 # ~124 mT/m at b 2000
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_the_maps_hold_under_noise_on_one_b0_row():
|
| 87 |
+
"""The same voxel as above, 3000 times, with Rician noise at SNR 15 and ONE b = 0 row, every row normalised by it
|
| 88 |
+
as the page does: the joint fit's shared intercept takes up the b = 0 row's noise, so the median muFA stays within
|
| 89 |
+
0.08 of the truth and MD within 5 % (a fit through ln S/S0 = 0 at b = 0 reads that noise as curvature)."""
|
| 90 |
+
bvals = (100.0, 700.0, 1400.0, 2000.0)
|
| 91 |
+
seq, enc, bs = _rows(bvals=bvals, n=10)
|
| 92 |
+
from dmipy_sim.acquisition.waveforms import calc_btensor
|
| 93 |
+
B = calc_btensor(seq) * 1e-9
|
| 94 |
+
D_iso, mufa = 0.8, 0.7
|
| 95 |
+
x = mufa ** 2 * D_iso ** 2 / (3 - 2 * mufa ** 2); D_delta = np.sqrt(x) / D_iso
|
| 96 |
+
rng = np.random.default_rng(1)
|
| 97 |
+
u = rng.normal(size=(4000, 3)); u /= np.linalg.norm(u, axis=1)[:, None]
|
| 98 |
+
Dpar, Dperp = D_iso * (1 + 2 * D_delta), D_iso * (1 - D_delta)
|
| 99 |
+
D = Dperp * np.eye(3)[None] + (Dpar - Dperp) * u[:, :, None] * u[:, None, :]
|
| 100 |
+
S = np.exp(-np.einsum("mij,cij->mc", B, D)).mean(1)
|
| 101 |
+
sd = 1.0 / 15.0
|
| 102 |
+
noisy = np.sqrt((S[None] + rng.normal(0, sd, (3000, len(S)))) ** 2 + rng.normal(0, sd, (3000, len(S))) ** 2)
|
| 103 |
+
noisy = noisy / noisy[:, :1] # the one b = 0 row
|
| 104 |
+
pw = T.powder(noisy[:, None, None, :], enc, bs, list(bvals))
|
| 105 |
+
counts = {(e, b): int(((enc == e) & (bs == b)).sum()) for e in ("LTE", "STE") for b in bvals}
|
| 106 |
+
md, got = T.md_ufa(pw, list(bvals), counts, n_b0=1)
|
| 107 |
+
assert abs(float(np.nanmedian(got)) - mufa) < 0.08, float(np.nanmedian(got))
|
| 108 |
+
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/deploy.py
CHANGED
|
@@ -44,6 +44,12 @@ 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 +85,7 @@ 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 |
-
shutil.copy(os.path.join(root,
|
| 83 |
folder = staged
|
| 84 |
elif a.base_tag:
|
| 85 |
staged = tempfile.mkdtemp(prefix="disco-space-")
|
|
@@ -91,7 +97,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:
|
|
|
|
| 44 |
return "README-zero.md" if config == DEFAULT_CONFIG else f"README-{os.path.splitext(config)[0]}-zero.md"
|
| 45 |
|
| 46 |
|
| 47 |
+
def requirements_for(config):
|
| 48 |
+
"""The ZeroGPU requirements of a configuration: ``requirements-zero.txt`` for the default (the DiSCo page's pins),
|
| 49 |
+
``requirements-<stem>-zero.txt`` else (the brain Space keeps the pins its packs were accepted on)."""
|
| 50 |
+
return "requirements-zero.txt" if config == DEFAULT_CONFIG else f"requirements-{os.path.splitext(config)[0]}-zero.txt"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
def set_config(api, space, config):
|
| 54 |
"""``DISCO_CONFIG`` on the Space: the configuration's name, absent for the default."""
|
| 55 |
if config == DEFAULT_CONFIG:
|
|
|
|
| 85 |
for name in ("app.py", "pyproject.toml"):
|
| 86 |
shutil.copy(os.path.join(root, name), staged)
|
| 87 |
shutil.copy(os.path.join(root, readme_for(a.config)), os.path.join(staged, "README.md"))
|
| 88 |
+
shutil.copy(os.path.join(root, requirements_for(a.config)), os.path.join(staged, "requirements.txt"))
|
| 89 |
folder = staged
|
| 90 |
elif a.base_tag:
|
| 91 |
staged = tempfile.mkdtemp(prefix="disco-space-")
|
|
|
|
| 97 |
g.write(f.read().replace("BASE_TAG", a.base_tag))
|
| 98 |
folder = staged
|
| 99 |
info = api.upload_folder(folder_path=folder, repo_id=a.space, repo_type="space", ignore_patterns=IGNORE,
|
| 100 |
+
delete_patterns=["Dockerfile", "Dockerfile.base", "Dockerfile.space", "entrypoint.sh", "README-*zero.md", "requirements-*zero.txt"] if a.zero
|
| 101 |
else (["Dockerfile.base", "Dockerfile.space"] if a.base_tag else None),
|
| 102 |
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 ""))
|
| 103 |
if staged:
|
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()
|