Skip to content

Commit c6cd014

Browse files
authored
Merge pull request #84 from Hendrik-code/new_features_robert
New features robert
2 parents 73d82a3 + 369e597 commit c6cd014

23 files changed

Lines changed: 981 additions & 491 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,6 @@ poetry.lock
163163
tutorials/tutorial_data_processing/*
164164
tutorials/*PixelPandemonium/*
165165
tutorials/dataset-PixelPandemonium/*
166-
*.html
166+
*.html
167+
_*.py
168+
dicom_select

TPTBox/core/bids_files.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1394,7 +1394,7 @@ def __getitem__(self, item: str) -> list[BIDS_FILE]:
13941394
try:
13951395
return self.data_dict[item]
13961396
except KeyError as e:
1397-
raise KeyError(f"BIDS_Family does not contain key {item}, only {self.keys()}") from e
1397+
raise KeyError(f"BIDS_Family does not contain key '{item}', only {list(self.keys())}; {self.family_id=}") from e
13981398

13991399
def __setitem__(self, key, value):
14001400
self.data_dict[key] = value

TPTBox/core/dicom/dicom2nii_utils.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,11 @@ def save_json(json_ob, file, check_exist=False):
201201
"""
202202

203203
def convert(obj):
204-
if isinstance(obj, np.int64):
204+
if isinstance(obj, np.integer):
205205
return int(obj)
206-
raise TypeError
206+
if isinstance(obj, np.floating):
207+
return float(obj)
208+
raise TypeError(type(obj))
207209

208210
if check_exist and test_name_conflict(json_ob, file):
209211
raise FileExistsError(file)

TPTBox/core/nii_poi_abstract.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def to_gird(self) -> Grid:
5656
@property
5757
def shape_int(self):
5858
assert self.shape is not None, "need shape information"
59-
return tuple(np.rint(list(self.shape)).astype(int))
59+
return tuple(np.rint(list(self.shape)).astype(int).tolist())
6060

6161
@property
6262
def spacing(self):
@@ -68,11 +68,11 @@ def spacing(self, value: ZOOMS):
6868

6969
def __str__(self) -> str:
7070
try:
71-
origin = tuple(np.around(self.origin, decimals=2))
71+
origin = tuple(np.around(self.origin, decimals=2).tolist())
7272
except Exception:
7373
origin = self.origin
7474
try:
75-
zoom = tuple(np.around(self.zoom, decimals=2))
75+
zoom = tuple(np.around(self.zoom, decimals=2).tolist())
7676
except Exception:
7777
zoom = self.zoom
7878

TPTBox/core/nii_wrapper.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,16 @@ def rotation(self)->np.ndarray:
437437
rotation = rotation_zoom / zoom
438438
return rotation
439439

440+
@property
441+
def direction(self) -> list:
442+
direction_matrix = self.rotation
443+
direction_flat = direction_matrix.flatten().tolist()
444+
return direction_flat
445+
@property
446+
def direction_itk(self) -> list:
447+
a = np.array(self.direction)
448+
a[:len(a)//3*2]*=-1
449+
return a.tolist()
440450

441451
@orientation.setter
442452
def orientation(self, value: AX_CODES):
@@ -488,7 +498,7 @@ def get_array(self) -> np.ndarray:
488498
return self._arr.copy()
489499
def numpy(self, *_args):
490500
return self.get_array()
491-
def set_array(self,arr:np.ndarray|Self, inplace=False,verbose:logging=False)-> Self: # noqa: ARG002
501+
def set_array(self,arr:np.ndarray|Self, inplace=False,verbose:logging=False,seg=None)-> Self: # noqa: ARG002
492502
"""Creates a NII where the array is replaces with the input array.
493503
494504
Note: This function works "Out-of-place" by default, like all other methods.
@@ -500,6 +510,7 @@ def set_array(self,arr:np.ndarray|Self, inplace=False,verbose:logging=False)-> S
500510
Returns:
501511
self
502512
"""
513+
503514
if hasattr(arr,"get_array"):
504515
arr = arr.get_array() # type: ignore
505516
if arr.dtype == bool:
@@ -520,10 +531,12 @@ def set_array(self,arr:np.ndarray|Self, inplace=False,verbose:logging=False)-> S
520531
#if all(a is None for a in self.header.get_slope_inter()):
521532
# nii.header.set_slope_inter(1,self.get_c_val()) # type: ignore
522533
if inplace:
534+
if seg is not None:
535+
self.seg = seg
523536
self.nii = nii
524537
return self
525538
else:
526-
return self.copy(nii) # type: ignore
539+
return self.copy(nii,seg=seg) # type: ignore
527540

528541
def set_array_(self,arr:np.ndarray,verbose:logging=True):
529542
return self.set_array(arr,inplace=True,verbose=verbose)
@@ -1255,13 +1268,13 @@ def filter_connected_components(self, labels: int |list[int]|None=None,min_volum
12551268
"""
12561269
assert self.seg, "This only works on segmentations"
12571270
arr = np_filter_connected_components(self.get_seg_array(), largest_k_components=max_count_component,label_ref=labels,connectivity=connectivity,return_original_labels=keep_label,min_volume=min_volume,max_volume=max_volume,removed_to_label=removed_to_label,)
1258-
#if keep_label and labels is not None:
1259-
# if isinstance(labels,int):
1260-
# labels = [labels]
1261-
# old_labels = [i for i in self.unique() if i not in labels]
1262-
# if len(old_labels) != 0:
1263-
# s = self.extract_label(old_labels,keep_label=True)
1264-
# nii[s != 0] = s[s!=0]
1271+
if keep_label and labels is not None:
1272+
if isinstance(labels,int):
1273+
labels = [labels]
1274+
old_labels = [i for i in self.unique() if i not in labels]
1275+
if len(old_labels) != 0:
1276+
s = self.extract_label(old_labels,keep_label=True).get_array()
1277+
arr[s != 0] = s[s!=0]
12651278
#print("filter",nii.unique())
12661279
#assert max_count_component is None or nii.max() <= max_count_component, nii.unique()
12671280
return self.set_array(arr, inplace=inplace)
@@ -1648,10 +1661,10 @@ def map_labels(self, label_map:LABEL_MAP , verbose:logging=True, inplace=False):
16481661
def map_labels_(self, label_map: LABEL_MAP, verbose:logging=True):
16491662
return self.map_labels(label_map,verbose=verbose,inplace=True)
16501663

1651-
def copy(self, nib:Nifti1Image|_unpacked_nii|None = None):
1664+
def copy(self, nib:Nifti1Image|_unpacked_nii|None = None,seg=None):
16521665
if nib is None:
16531666
nib = (self.get_array().copy(), self.affine.copy(), self.header.copy())
1654-
return NII(nib,seg=self.seg,c_val = self.c_val,info = self.info)
1667+
return NII(nib,seg=self.seg if seg is None else seg,c_val = self.c_val,info = self.info)
16551668

16561669
def clone(self):
16571670
return self.copy()
@@ -1745,9 +1758,10 @@ def __array__(self,dtype=None):
17451758
return self._arr
17461759
else:
17471760
return self._arr.astype(dtype, copy=False)
1748-
def __array_wrap__(self, array):
1761+
def __array_wrap__(self, array,context=None, return_scalar=False):
1762+
assert not return_scalar,context
17491763
if array.shape != self.shape:
1750-
raise SyntaxError(f"Function call induce a shape change of nii image. Before {self.shape} after {array.shape}.")
1764+
raise SyntaxError(f"Function call induce a shape change of nii image. Before {self.shape} after {array.shape}. {context}")
17511765
return self.set_array(array)
17521766
def __getitem__(self, key)-> Any:
17531767
if isinstance(key,Sequence):

TPTBox/core/np_utils.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ def np_extract_label(
7474

7575

7676
def cc3dstatistics(arr: UINTARRAY, use_crop: bool = True) -> dict:
77+
"""
78+
Computes connected component statistics for a labeled array using connected components 3D (cc3d).
79+
80+
Args:
81+
arr (UINTARRAY): A 3D array of unsigned integers or booleans where each connected component
82+
is labeled with a unique integer. Typically output from a labeling function.
83+
use_crop (bool): If True, the function attempts to crop the input array around non-zero regions
84+
to improve performance and focus statistics on the area of interest. Defaults to True.
85+
86+
Returns:
87+
dict: A dictionary containing statistics of the connected components, such as their sizes,
88+
bounding boxes, and possibly centroids, depending on implementation of `_cc3dstats`.
89+
90+
Raises:
91+
AssertionError: If the input array is not of an unsigned integer or boolean dtype.
92+
"""
7793
assert np.issubdtype(arr.dtype, np.unsignedinteger) or np.issubdtype(arr.dtype, np.bool_), (
7894
f"cc3dstatistics expects uint type, got {arr.dtype}"
7995
)
@@ -164,7 +180,7 @@ def np_unique_withoutzero(arr: UINTARRAY) -> list[int]:
164180
return [i for i in np_unique(arr) if i != 0]
165181

166182

167-
def np_center_of_mass(arr: UINTARRAY) -> dict[int, COORDINATE]:
183+
def np_center_of_mass(arr: UINTARRAY) -> dict[int, np.ndarray]:
168184
"""Calculates center of mass, mapping label in array to a coordinate (float) (exluding zero)
169185
170186
Args:
@@ -662,7 +678,7 @@ def np_connected_components(
662678
verbose: If true, will print out if the array does not have any CC
663679
664680
Returns:
665-
arr_cc: UINTARRAY, N: int
681+
arr_cc: UINTARRAY, N: number of cc
666682
"""
667683
assert np.min(arr) == 0, f"min value of mask not zero, got {np.min(arr)}"
668684
assert np.max(arr) >= 0, f"wrong normalization, max value is not >= 0, got {np_unique(arr)}"
@@ -757,7 +773,7 @@ def np_filter_connected_components(
757773

758774
arr2 = arr.copy()
759775
labels: Sequence[int] = _to_labels(arr, label_ref)
760-
arr2[np.isin(arr, labels, invert=True)] = 0 # type:ignore
776+
arr2[np.isin(arr2, labels, invert=True)] = 0 # type:ignore
761777

762778
labels_out, n = _connected_components(arr2, connectivity=connectivity, return_N=True)
763779
if largest_k_components is None:
@@ -831,7 +847,7 @@ def np_translate_to_center_of_array(image: np.ndarray) -> np.ndarray:
831847

832848

833849
def np_translate_arr(arr: np.ndarray, translation_vector: tuple[int, int] | tuple[int, int, int]) -> np.ndarray:
834-
"""Translates nonzero values of an input array according to a 2D or 3D translation vector. Values that would be shifted beyond the boundary are removed!
850+
"""Translates values of an input array according to a 2D or 3D translation vector. Values that would be shifted beyond the boundary are removed!
835851
836852
Args:
837853
arr: input array

TPTBox/core/poi_fun/poi_global.py

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
from copy import deepcopy
45
from pathlib import Path
56

@@ -10,6 +11,7 @@
1011
from TPTBox.core.poi_fun.poi_abstract import Abstract_POI, POI_Descriptor
1112
from TPTBox.core.poi_fun.save_load import FORMAT_GLOBAL, load_poi, save_poi
1213
from TPTBox.core.vert_constants import Abstract_lvl, logging
14+
from TPTBox.logger.log_file import log
1315

1416
###### GLOBAL POI #####
1517

@@ -138,7 +140,7 @@ def to_other(self, msk: Has_Grid, verbose=False) -> poi.POI:
138140
v = (-v[0], -v[1], v[2]) # noqa: PLW2901
139141
v_out = msk.global_to_local(v)
140142
if verbose:
141-
print(v, "-->", v_out)
143+
log.print(v, "-->", v_out)
142144
out[k1, k2] = tuple(v_out)
143145

144146
return poi.POI(centroids=out, **msk._extract_affine(), info=self.info, format=self.format)
@@ -155,8 +157,8 @@ def copy(self, centroids: POI_Descriptor | None = None) -> Self:
155157
@classmethod
156158
def load(cls, poi: poi.POI_Reference, itk_coords: bool | None = None) -> Self:
157159
poi_obj = load_poi(poi)
158-
if not isinstance(poi_obj, POI_Global):
159-
poi_obj = poi_obj.to_global(itk_coords if itk_coords is not None else False)
160+
if not poi_obj.is_global:
161+
poi_obj = poi_obj.to_global(itk_coords if itk_coords is not None else False) # type: ignore
160162
if itk_coords is not None:
161163
assert itk_coords == poi_obj.itk_coords, "not implemented swichting to/from itk_coords to nii "
162164
return poi_obj # type: ignore
@@ -173,3 +175,79 @@ def save(
173175
return save_poi(
174176
self, out_path, make_parents, additional_info, save_hint=save_hint, resample_reference=resample_reference, verbose=verbose
175177
)
178+
179+
def save_mrk(self, filepath: str | Path, color=None, split_by_region=True, split_by_subregion=False):
180+
"""
181+
Save the POI data to a .mrk.json file in Slicer Markups format.
182+
Automatically sets coordinate system based on itk_coords.
183+
Includes level_one_info and level_two_info in the description.
184+
Preserves metadata from `info` dictionary.
185+
"""
186+
if color is None:
187+
color = self.info.get("color", [1.0, 0.0, 0.0])
188+
filepath = Path(filepath)
189+
if not filepath.name.endswith(".mrk.json"):
190+
filepath = filepath.parent / (filepath.stem + ".mrk.json")
191+
coordinate_system = "LPS" if self.itk_coords else "RAS"
192+
193+
# Create list of control points
194+
from TPTBox import NII
195+
from TPTBox.mesh3D.mesh_colors import get_color_by_label
196+
197+
list_markups = {}
198+
for region, subregion, coords in self.centroids.items():
199+
try:
200+
name = self.level_two_info(subregion).name
201+
except Exception:
202+
name = subregion
203+
try:
204+
name2 = self.level_one_info(region).name
205+
except Exception:
206+
name2 = region
207+
key = "P"
208+
color2 = color
209+
if split_by_region:
210+
key += str(region) + "_"
211+
color2 = get_color_by_label(region).rgb.tolist()
212+
if split_by_subregion:
213+
key += str(subregion)
214+
color2 = get_color_by_label(region).rgb.tolist()
215+
if key not in list_markups:
216+
list_markups[key] = {
217+
"type": "Fiducial",
218+
"coordinateSystem": coordinate_system,
219+
"locked": False,
220+
"labelFormat": "%N-%d",
221+
"controlPoints": [],
222+
"display": {
223+
"visibility": True,
224+
"opacity": 1.0,
225+
"color": color2.copy(),
226+
"propertiesLabelVisibility": False,
227+
},
228+
"description": "", # self.info,
229+
}
230+
231+
list_markups[key]["controlPoints"].append(
232+
{
233+
"id": f"{region}-{subregion}",
234+
"label": f"{region}-{subregion}",
235+
"description": name,
236+
"associatedNodeID": name2,
237+
"position": list(coords),
238+
"orientation": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
239+
"selected": False,
240+
"locked": False,
241+
"visibility": True,
242+
"positionStatus": "defined",
243+
}
244+
)
245+
mrk_data = {
246+
"markups": list(list_markups.values()),
247+
"schema": "https://raw.githubusercontent.com/slicer/slicer/master/Modules/Loadable/Markups/Resources/Schema/markups-schema-v1.0.3.json#",
248+
# "coordinateSystem": coordinate_system,
249+
}
250+
251+
with open(filepath, "w") as f:
252+
json.dump(mrk_data, f, indent=2)
253+
log.on_save(f"Saved .mrk.json to {filepath}")

0 commit comments

Comments
 (0)