Skip to content

Commit 93ea430

Browse files
authored
Merge pull request #88 from Hendrik-code/development_robert
Development robert
2 parents fab3d9d + 6085b40 commit 93ea430

39 files changed

Lines changed: 2686 additions & 463 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,5 @@ tutorials/*PixelPandemonium/*
165165
tutorials/dataset-PixelPandemonium/*
166166
*.html
167167
_*.py
168-
dicom_select
168+
dicom_select
169+
examples

README.md

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,23 +87,36 @@ nii.shape #shape
8787
```
8888
### Stitching
8989
Python function and script for arbitrary image stitching. [See Details](TPTBox/stitching/)
90+
91+
![Example of a stitching](TPTBox/stitching/stitching.jpg)
9092
### Spineps and Points of Interests (POI) integration
93+
94+
![Example of two lumbar vertebrae. The left example is derived from 1 mm isotropic CT, the right from sagittal MRI with a resolution of 3.3 mm in the left–right direction. Top row: Subregion of the vertebra used for analysis. Middle row: Extreme points. Bottom row: Corpus edge and ligamentum flavum points.](TPTBox/images/poi_preview.png)
9195
For our Spine segmentation pipline follow the installation of [SPINEPS](https://github.com/Hendrik-code/spineps).
96+
Image Source: Rule-based Key-Point Extraction for MR-Guided Biomechanical Digital Twins of the Spine;
97+
98+
9299

93100
SPINEPS will produce two mask: instance and semantic labels. With these we can compute our POIs. There are either center of mass points or surface points with bioloical meaning. See [Validation of a Patient-Specific Musculoskeletal Model for Lumbar Load Estimation Generated by an Automated Pipeline From Whole Body CT](https://pubmed.ncbi.nlm.nih.gov/35898642/)
94101
```python
95-
from TPTBox import NII, POI, Location, calc_poi_from_subreg_vert
102+
from TPTBox import NII, POI, Location, POI_Global, calc_poi_from_subreg_vert
96103
from TPTBox.core.vert_constants import v_name2idx
104+
from TPTBox.segmentation.spineps import run_spineps_single
105+
106+
# This requires that spineps is installed
107+
output_paths = run_spineps_single(
108+
"file-path-of_T2w.nii.gz",
109+
model_semantic="t2w",
110+
ignore_compatibility_issues=True,
111+
)
112+
out_spine = output_paths["out_spine"]
113+
out_vert = output_paths["out_vert"]
114+
semantic_nii = NII.load(out_spine, seg=True)
115+
instance_nii = NII.load(out_vert, seg=True)
97116

98-
p = "/dataset-DATASET/derivatives/A/"
99-
semantic_nii = NII.load(f"{p}sub-A_sequ-stitched_acq-sag_mod-T2w_seg-spine_msk.nii.gz", seg=True)
100-
instance_nii = NII.load(f"{p}sub-A_sequ-stitched_acq-sag_mod-T2w_seg-vert_msk.nii.gz", seg=True)
101-
poi_path = f"{p}sub-A_sequ-stitched_acq-sag_mod-T2w_seg-spine_ctd.json"
102-
poi = POI.load(poi_path)
103117
poi = calc_poi_from_subreg_vert(
104118
instance_nii,
105119
semantic_nii,
106-
# buffer_file=poi_path,
107120
subreg_id=[
108121
Location.Vertebra_Full,
109122
Location.Arcus_Vertebrae,
@@ -157,18 +170,34 @@ poi = calc_poi_from_subreg_vert(
157170
Location.Vertebra_Direction_Right,
158171
],
159172
)
160-
# poi.save(poi_path)
161173
poi = poi.round(2)
162174
print("Vertebra T4 Vertebra Corpus Center of mass:", poi[v_name2idx["T4"], Location.Vertebra_Corpus])
163-
# rescale/reorante like nii
175+
print("The id number of T4 Vertebra_Corpus is ", v_name2idx["T4"], Location.Vertebra_Corpus.value)
176+
177+
# rescale/reorante local poi like nii
164178
poi_new = poi.reorient(("P", "I", "R")).rescale((1, 1, 1))
179+
# Local and global POIs can be rescaled to a target spacing with:
165180
poi_new = poi.resample_from_to(other_nii_or_poi)
166181

182+
# local to global poi
183+
global_poi = poi.to_global(itk_coords=True)
184+
# You can save global pois in mrk.json format for import and editing in slicer.
185+
global_poi.save_mrk("FILE.mrk.json", glyphScale=3.0)
186+
# Import as a Markup in slicer; To make points editable you must click on the "lock" symbol under Markups - Control Points - Interaction
187+
188+
# Save in our format:
189+
poi.save(poi_path)
190+
# Loading local/global Poi
191+
poi = POI.load(poi_path)
192+
poi = POI_Global.load(poi_path)
193+
194+
195+
167196
```
168197

169198

170199
### Snapshot2D Spine
171-
200+
![Snapshot2D Spine example](TPTBox/images/snp2D_example.png)
172201
The snapshot function automatically generates sag, cor, axial cuts in the center of a segmentation.
173202

174203
```python
@@ -188,17 +217,21 @@ create_snapshot(snp_path="snapshot.jpg", frames=[ct_frame, mr_frame])
188217

189218

190219
### Snapshot3D
191-
220+
![Snapshot3D example](TPTBox/images/snp3D_example.jpg)
192221
Requires additonal python packages: vtk fury xvfbwrapper
193222

194223
```python
195-
from TPTBox.mesh3D.snapshot3D import make_snapshot3D
224+
from TPTBox.mesh3D.snapshot3D import make_snapshot3D, make_snapshot3D_parallel
225+
196226
# all segmentation; view give the rotation of an image
197-
make_snapshot3D("sub-101000_msk.nii.gz","snapshot3D.png",view=["A", "L", "P", "R"])
227+
make_snapshot3D("sub-101000_msk.nii.gz", "snapshot3D.png", view=["A", "L", "P", "R"])
198228
# Select witch segmentation per panel are chosen.
199-
make_snapshot3D("sub-101000_msk.nii.gz","snapshot3D_v2.png",view=["A"], ids_list=[[1,2],[3]])
229+
make_snapshot3D("sub-101000_msk.nii.gz", "snapshot3D_v2.png", view=["A"], ids_list=[[1, 2], [3]])
230+
# we proviede a implementation to process multiple images at the same time.
231+
make_snapshot3D_parallel(["a.nii.gz", "b.nii.gz"], ["snp_a.png", "snp_b.png"], view=["A"])
200232
```
201233

234+
<!---
202235
### Logger
203236
204237
```python
@@ -219,3 +252,4 @@ TBD
219252
220253
> [!IMPORTANT]
221254
> Importantly
255+
-->

TPTBox/core/bids_constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@
152152
"logit",
153153
"localizer",
154154
"difference",
155+
"labels",
155156
]
156157
# https://bids-specification.readthedocs.io/en/stable/appendices/entity-table.html
157158
formats_relaxed = [*formats, "t2", "t1", "t2c", "t1c", "cta", "mr", "snapshot", "t1dixon", "dwi"]
@@ -175,6 +176,7 @@
175176
file_types = [
176177
"nii.gz",
177178
"json",
179+
"mrk.json",
178180
"png",
179181
"jpg",
180182
"tsv",
@@ -190,6 +192,7 @@
190192
"xlsx",
191193
"bvec",
192194
"bval",
195+
"html",
193196
]
194197
# Description see: https://bids-specification.readthedocs.io/en/stable/99-appendices/09-entities.html
195198

TPTBox/core/bids_files.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import json
55
import os
6+
import random
67
import sys
78
import typing
89
from collections.abc import Sequence
@@ -358,10 +359,14 @@ def add_file_2_subject(self, bids: BIDS_FILE | Path, ds=None) -> None:
358359
) if self.verbose else None
359360
self.subjects[subject].add(bids)
360361

361-
def enumerate_subjects(self, sort=False) -> list[tuple[str, Subject_Container]]:
362+
def enumerate_subjects(self, sort=False, shuffle=False) -> list[tuple[str, Subject_Container]]:
362363
# TODO Enumerate should put out numbers...
363364
if sort:
364365
return sorted(self.subjects.items())
366+
if shuffle:
367+
s = list(self.subjects.items())
368+
random.shuffle(s)
369+
return s
365370
return self.subjects.items() # type: ignore
366371

367372
def iter_subjects(self, sort=False) -> list[tuple[str, Subject_Container]]:
@@ -686,7 +691,7 @@ def get_changed_path( # noqa: C901
686691
from_info=False,
687692
auto_add_run_id=False,
688693
additional_folder: str | None = None,
689-
dataset_path: str | None = None,
694+
dataset_path: str | Path | None = None,
690695
make_parent=False,
691696
no_sorting_mode: bool = False,
692697
non_strict_mode: bool = False,

TPTBox/core/nii_poi_abstract.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import nibabel as nib
88
import nibabel.orientations as nio
99
import numpy as np
10+
from scipy.spatial.transform import Rotation
1011
from typing_extensions import Self
1112

1213
from TPTBox.core.np_utils import np_count_nonzero
@@ -113,6 +114,65 @@ def _extract_affine(self: Has_Grid, rm_key=(), **args):
113114
out.pop(k)
114115
return out
115116

117+
def change_affine(
118+
self,
119+
translation=None,
120+
rotation_degrees=None,
121+
scaling=None,
122+
degrees=True,
123+
inplace=False,
124+
):
125+
"""
126+
Apply a transformation (translation, rotation, scaling) to the affine matrix.
127+
128+
Parameters:
129+
translation: (n,) array-like in mm
130+
rotation_degrees: (n,) array-like (pitch, yaw, roll) in degrees
131+
scaling: (n,) array-like scaling factors along x, y, z
132+
"""
133+
warnings.warn("change_affine is untested", stacklevel=2)
134+
n = self.affine.shape[0]
135+
transform = np.eye(n)
136+
137+
# Scaling
138+
if scaling is not None:
139+
assert len(scaling) == n - 1, f"Scaling must be a {n - 1}-element array-like."
140+
S = np.diag([*list(scaling), 1])
141+
transform = S @ transform
142+
143+
# Rotation
144+
if rotation_degrees is not None:
145+
assert len(rotation_degrees) == n - 1, f"Rotation must be a {n - 1}-element array-like."
146+
rot = Rotation.from_euler("xyz", rotation_degrees, degrees=degrees).as_matrix()
147+
R_mat = np.eye(n)
148+
R_mat[: n - 1, : n - 1] = rot
149+
transform = R_mat @ transform
150+
151+
# Translation
152+
if translation is not None:
153+
T = np.eye(n)
154+
T[: n - 1, n - 1] = translation
155+
transform = T @ transform
156+
if not inplace:
157+
self = self.copy() # noqa: PLW0642
158+
# Update the affine
159+
self.affine = transform @ self.affine
160+
return self
161+
162+
def change_affine_(self, translation=None, rotation_degrees=None, scaling=None, degrees=True):
163+
return self.change_affine(
164+
translation=translation,
165+
rotation_degrees=rotation_degrees,
166+
scaling=scaling,
167+
degrees=degrees,
168+
inplace=True,
169+
)
170+
171+
def copy(self) -> Self:
172+
raise NotImplementedError(
173+
"The copy method must be implemented in the subclass. It should return a new instance of the same type with the same attributes."
174+
)
175+
116176
def assert_affine(
117177
self,
118178
other: Self | Has_Grid | None = None,
@@ -181,11 +241,11 @@ def assert_affine(
181241
found_errors.append(f"rotation mismatch {self.rotation}, {rotation}") if not rotation_match else None
182242
if zoom is not None and (not ignore_missing_values or self.zoom is not None):
183243
if self.zoom is None:
184-
found_errors.append(f"zoom mismatch {self.zoom}, {zoom}")
244+
found_errors.append(f"spacing mismatch {self.zoom}, {zoom}")
185245
else:
186246
zms_diff = (self.zoom[i] - zoom[i] for i in range(3))
187247
zms_match = np.all([abs(a) <= error_tolerance for a in zms_diff])
188-
found_errors.append(f"zoom mismatch {self.zoom}, {zoom}") if not zms_match else None
248+
found_errors.append(f"spacing mismatch {self.zoom}, {zoom}") if not zms_match else None
189249
if orientation is not None and (not ignore_missing_values or self.affine is not None):
190250
if self.orientation is None:
191251
found_errors.append(f"orientation mismatch {self.orientation}, {orientation}")
@@ -256,7 +316,14 @@ def get_empty_POI(self, points: dict | None = None):
256316
from TPTBox import POI
257317

258318
p = {} if points is None else points
259-
return POI(p, orientation=self.orientation, zoom=self.zoom, shape=self.shape, rotation=self.rotation, origin=self.origin)
319+
return POI(
320+
p,
321+
orientation=self.orientation,
322+
zoom=self.zoom,
323+
shape=self.shape,
324+
rotation=self.rotation,
325+
origin=self.origin,
326+
)
260327

261328
def make_empty_POI(self, points: dict | None = None):
262329
from TPTBox import POI
@@ -267,7 +334,15 @@ def make_empty_POI(self, points: dict | None = None):
267334
args["level_one_info"] = self.level_one_info
268335
args["level_two_info"] = self.level_two_info
269336

270-
return POI(p, orientation=self.orientation, zoom=self.zoom, shape=self.shape, rotation=self.rotation, origin=self.origin, **args)
337+
return POI(
338+
p,
339+
orientation=self.orientation,
340+
zoom=self.zoom,
341+
shape=self.shape,
342+
rotation=self.rotation,
343+
origin=self.origin,
344+
**args,
345+
)
271346

272347
def make_empty_nii(self, seg=False, _arr=None):
273348
from TPTBox import NII
@@ -332,6 +407,30 @@ def to_deepali_grid(self, align_corners: bool = True):
332407
grid = grid.align_corners_(align_corners)
333408
return grid
334409

410+
@classmethod
411+
def from_deepali_grid(cls, grid):
412+
try:
413+
from deepali.core import Grid as dp_Grid
414+
except Exception:
415+
log.print_error()
416+
log.on_fail("run 'pip install hf-deepali' to install deepali")
417+
raise
418+
grid_: dp_Grid = grid
419+
size = grid_.size()
420+
spacing = grid_.spacing().cpu().numpy()
421+
origin = grid_.origin().cpu().numpy()
422+
direction = grid_.direction().cpu().numpy()
423+
# Convert to ITK LPS convention
424+
origin[:2] *= -1
425+
direction[:2] *= -1
426+
# Replace small values and -0 by 0
427+
epsilon = sys.float_info.epsilon
428+
origin[np.abs(origin) < epsilon] = 0
429+
direction[np.abs(direction) < epsilon] = 0
430+
grid = Grid(shape=size, origin=origin, spacing=spacing, rotation=direction) # type: ignore
431+
432+
return grid
433+
335434
def get_num_dims(self):
336435
return len(self.shape)
337436

@@ -342,9 +441,14 @@ def __init__(self, **qargs) -> None:
342441
for k, v in qargs.items():
343442
if k == "spacing":
344443
k = "zoom" # noqa: PLW2901
444+
if k == "direction":
445+
k = "rotation" # noqa: PLW2901
345446
if k == "rotation":
346447
v = np.array(v) # noqa: PLW2901
347448
if len(v.shape) == 1:
348449
s = int(np.sqrt(v.shape[0]))
349450
v = v.reshape(s, s) # noqa: PLW2901
350451
setattr(self, k, v)
452+
453+
ort = nio.io_orientation(self.affine)
454+
self.orientation = nio.ornt2axcodes(ort) # type: ignore

0 commit comments

Comments
 (0)