Skip to content

POI — Points of Interest

POI maps (vertebra_id, subregion_id) tuples to 3D coordinates and provides save/load, coordinate-space conversion, and iteration helpers.

POI

TPTBox.core.poi.POI dataclass

Bases: Abstract_POI, Has_Grid

This class represents a collection of POIs used to define points of interest in medical imaging data.

Attributes:

Name Type Description
orientation Ax_Codes

A tuple of three string values representing the orientation of the image.

centroids dict

A dictionary of POI points, where the keys are the labels for the POI points, and values are tuples of three float values representing the x, y, and z coordinates of the POI.

zoom Zooms | None

A tuple of three float values representing the zoom level of the image. Defaults to None if not provided.

shape tuple[float, float, float] | None

A tuple of three integer values representing the shape of the image. Defaults to None if not provided.

format int | None

An integer value representing the format of the image. Defaults to None if not provided.

info dict

Additional information stored as key-value pairs. Defaults to an empty dictionary.

rotation Rotation | None

A 3x3 numpy array representing the rotation matrix for the image orientation. Defaults to None if not provided.

origin Coordinate | None

A tuple of three float values representing the origin of the image in millimeters along the x, y, and z axes. Defaults to None if not provided.

Properties

is_global (bool): Property indicating whether the POI is a global POI. Always returns False. zoom (Zooms | None): Property getter for the zoom level. affine: Property representing the affine transformation for the image.

Examples:

>>> # Create a POI object with 2D dictionary input
>>> from BIDS.core.poi import POI
>>> poi_data = {
...     (1, 0): (10.0, 20.0, 30.0),
...     (2, 1): (15.0, 25.0, 35.0),
... }
>>> poi_obj = POI(centroids=poi_data, orientation=("R", "A", "S"), zoom=(1.0, 1.0, 1.0), shape=(256, 256, 100))
>>> # Access attributes
>>> print(poi_obj.orientation)
('R', 'A', 'S')
>>> print(poi_obj.centroids)
{1: {0: (10.0, 20.0, 30.0)}, 2: {1: (15.0, 25.0, 35.0)}}
>>> print(poi_obj.zoom)
(1.0, 1.0, 1.0)
>>> print(poi_obj.shape)
(256, 256, 100)
>>> # Update attributes
>>> poi_obj.rescale_((2.0, 2.0, 2.0))
>>> poi_obj.centroids[(3, 0)] = (5.0, 15.0, 25.0)
>>> print(poi_obj)
POI(centroids={1: {0: (5.0, 10.0, 15.0)}, 2: {1: (7.5, 12.5, 17.5)}, 3: {0: (5.0, 15.0, 25.0)}}, orientation=('R', 'A', 'S'), zoom=(2.0, 2.0, 2.0), info={}, origin=None)
>>> # Create a copy of the object
>>> poi_copy = poi_obj.copy()
>>> # Perform operations
>>> poi_obj = poi_obj.map_labels({(1): (4), (2): (4)})
>>> poi_obj.round_(0)
>>> print(poi_obj)
POI(centroids={4: {0: (5.0, 10.0, 15.0), 1: (8.0, 12.0, 18.0)}, 3: {0: (5.0, 15.0, 25.0)}}, orientation=('R', 'A', 'S'), zoom=(2.0, 2.0, 2.0), info={}, origin=None)
Source code in TPTBox/core/poi.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
@dataclass
class POI(Abstract_POI, Has_Grid):
    """This class represents a collection of POIs used to define points of interest in medical imaging data.

    Attributes:
        orientation (Ax_Codes): A tuple of three string values representing the orientation of the image.
        centroids (dict): A dictionary of POI points, where the keys are the labels for the POI
            points, and values are tuples of three float values representing the x, y, and z coordinates
            of the POI.
        zoom (Zooms | None): A tuple of three float values representing the zoom level of the image.
            Defaults to None if not provided.
        shape (tuple[float, float, float] | None): A tuple of three integer values representing the shape of the image.
            Defaults to None if not provided.
        format (int | None): An integer value representing the format of the image. Defaults to None if not provided.
        info (dict): Additional information stored as key-value pairs. Defaults to an empty dictionary.
        rotation (Rotation | None): A 3x3 numpy array representing the rotation matrix for the image orientation.
            Defaults to None if not provided.
        origin (Coordinate | None): A tuple of three float values representing the origin of the image in millimeters
            along the x, y, and z axes. Defaults to None if not provided.

    Properties:
        is_global (bool): Property indicating whether the POI is a global POI. Always returns False.
        zoom (Zooms | None): Property getter for the zoom level.
        affine: Property representing the affine transformation for the image.

    Examples:
        >>> # Create a POI object with 2D dictionary input
        >>> from BIDS.core.poi import POI
        >>> poi_data = {
        ...     (1, 0): (10.0, 20.0, 30.0),
        ...     (2, 1): (15.0, 25.0, 35.0),
        ... }
        >>> poi_obj = POI(centroids=poi_data, orientation=("R", "A", "S"), zoom=(1.0, 1.0, 1.0), shape=(256, 256, 100))

        >>> # Access attributes
        >>> print(poi_obj.orientation)
        ('R', 'A', 'S')
        >>> print(poi_obj.centroids)
        {1: {0: (10.0, 20.0, 30.0)}, 2: {1: (15.0, 25.0, 35.0)}}
        >>> print(poi_obj.zoom)
        (1.0, 1.0, 1.0)
        >>> print(poi_obj.shape)
        (256, 256, 100)

        >>> # Update attributes
        >>> poi_obj.rescale_((2.0, 2.0, 2.0))
        >>> poi_obj.centroids[(3, 0)] = (5.0, 15.0, 25.0)
        >>> print(poi_obj)
        POI(centroids={1: {0: (5.0, 10.0, 15.0)}, 2: {1: (7.5, 12.5, 17.5)}, 3: {0: (5.0, 15.0, 25.0)}}, orientation=('R', 'A', 'S'), zoom=(2.0, 2.0, 2.0), info={}, origin=None)

        >>> # Create a copy of the object
        >>> poi_copy = poi_obj.copy()

        >>> # Perform operations
        >>> poi_obj = poi_obj.map_labels({(1): (4), (2): (4)})
        >>> poi_obj.round_(0)
        >>> print(poi_obj)
        POI(centroids={4: {0: (5.0, 10.0, 15.0), 1: (8.0, 12.0, 18.0)}, 3: {0: (5.0, 15.0, 25.0)}}, orientation=('R', 'A', 'S'), zoom=(2.0, 2.0, 2.0), info={}, origin=None)
    """

    orientation: AX_CODES = ("R", "A", "S")
    zoom: ZOOMS = field(init=True, default=None)  # type: ignore
    shape: TRIPLE = field(default=None, repr=True, compare=False)  # type: ignore
    rotation: ROTATION = field(default=None, repr=False, compare=False)  # type: ignore
    origin: COORDINATE = None  # type: ignore
    # internal
    _rotation: ROTATION = field(init=False, default=None, repr=False, compare=False)  # type: ignore
    _zoom: ZOOMS = field(init=False, default=(1, 1, 1), repr=False, compare=False)
    _vert_orientation_pir = {}  # Elusive; will not be saved; will not be copied. For Buffering results  # noqa: RUF012

    def _set_inplace(self, poi: Self) -> Self:
        """Copy all grid/affine attributes and centroids from ``poi`` into ``self``."""
        self.orientation = poi.orientation
        self.centroids = poi.centroids
        self.zoom = poi.zoom
        self.shape = poi.shape
        self.origin = poi.origin
        self.rotation = poi.rotation
        return self

    @property
    def is_global(self) -> bool:
        """Always False for voxel-space POIs; True only for POI_Global objects."""
        return False

    @property
    def rotation(self) -> ROTATION:
        """3×3 rotation matrix of the image grid, or None if not set."""
        return self._rotation

    @property
    def zoom(self) -> ZOOMS:
        """Voxel spacing in mm along each axis, or None if not set."""
        return self._zoom

    @property
    def spacing(self) -> ZOOMS:
        """Alias for :attr:`zoom`."""
        return self._zoom

    @rotation.setter
    def rotation(self, value):
        if isinstance(value, property):
            pass
        elif value is None:
            self._rotation = None  # type: ignore
        else:
            self._rotation = np.array(value)

    @zoom.setter
    def zoom(self, value):
        if isinstance(value, property):
            pass
        elif value is None:
            self._zoom = None  # type: ignore
        else:
            self._zoom = tuple(round(float(v), ROUNDING_LVL) for v in value)  # type: ignore

    @spacing.setter
    def spacing(self, value):
        self.zoom = value

    def clone(self, **qargs) -> Self:
        """Return a copy of this POI; alias for :meth:`copy`."""
        return self.copy(**qargs)

    __hash__ = None  # type: ignore # explicitly mark as unhashable

    def copy(
        self,
        centroids: POI_DICT | POI_Descriptor | None = None,
        orientation: AX_CODES | None = None,
        zoom: ZOOMS | Sentinel = Sentinel(),  # noqa: B008
        shape: TRIPLE | tuple[float, ...] | Sentinel = Sentinel(),  # noqa: B008
        rotation: ROTATION | Sentinel = Sentinel(),  # noqa: B008
        origin: COORDINATE | Sentinel = Sentinel(),  # noqa: B008
    ) -> Self:
        """Create a copy of the POI object with optional attribute overrides.

        Args:
            centroids (POI_Dict | POI_Descriptor | None, optional): The POIs to use in the copied object.
                Defaults to None, in which case the original POIs will be used.
            orientation (Ax_Codes | None, optional): The orientation code to use in the copied object.
                Defaults to None, in which case the original orientation will be used.
            zoom (Zooms | None | Sentinel, optional): The zoom values to use in the copied object.
                Defaults to Sentinel(), in which case the original zoom values will be used.
            shape (tuple[float, float, float] | None | Sentinel, optional): The shape values to use in the copied object.
                Defaults to Sentinel(), in which case the original shape values will be used.
            rotation (Rotation | None | Sentinel, optional): The rotation matrix to use in the copied object.
                Defaults to Sentinel(), in which case the original rotation matrix will be used.
            origin (Coordinate | None | Sentinel, optional): The origin coordinates to use in the copied object.
                Defaults to Sentinel(), in which case the original origin coordinates will be used.

        Returns:
            POI: A new POI object with the specified attribute overrides.

        Examples:
            >>> POI_obj = POI(...)
            >>> POI_obj_copy = POI_obj.copy(zoom=(2.0, 2.0, 2.0), rotation=rotation_matrix)
        """
        if isinstance(shape, tuple):
            shape = tuple(round(float(v), 7) for v in shape)  # type: ignore

        return POI(
            centroids=centroids.copy() if centroids is not None else self.centroids.copy(),
            orientation=orientation if orientation is not None else self.orientation,
            zoom=zoom if not isinstance(zoom, Sentinel) else self.zoom,
            shape=shape if not isinstance(shape, Sentinel) else self.shape,  # type: ignore
            rotation=rotation if not isinstance(rotation, Sentinel) else self.rotation,
            origin=origin if not isinstance(origin, Sentinel) else self.origin,
            info=deepcopy(self.info),
            format=self.format,
        )

    def local_to_global(self, x: COORDINATE, itk_coords=False) -> COORDINATE:
        """Converts local coordinates to global coordinates using zoom, rotation, and origin.

        Args:
            x (Coordinate | list[float]): The local coordinate(s) to convert.

        Returns:
            Coordinate: The converted global coordinate(s).

        Raises:
            AssertionError: If the attributes 'zoom', 'rotation', or 'origin' are missing.

        Notes:
            The 'zoom' and 'rotation' attributes should be set before calling this method.

        Examples:
            >>> POI_obj = Centroids(...)
            >>> POI_obj.zoom = (2.0, 2.0, 2.0)
            >>> POI_obj.rotation = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
            >>> POI_obj.origin = (10.0, 20.0, 30.0)
            >>> local_coordinate = (1.0, 2.0, 3.0)
            >>> global_coordinate = POI_obj.local_to_global(local_coordinate)
        """
        assert self.zoom is not None, "Attribute 'zoom' must be set before calling local_to_global."
        assert self.rotation is not None, "Attribute 'rotation' must be set before calling local_to_global."
        assert self.origin is not None, "Attribute 'origin' must be set before calling local_to_global."

        a = self.rotation @ (np.array(x) * np.array(self.zoom)) + self.origin
        if itk_coords:
            a = (-a[0], -a[1], a[2])
        # return tuple(a.tolist())
        return tuple(round(float(v), ROUNDING_LVL) for v in a)

    def global_to_local(self, x: COORDINATE) -> COORDINATE:
        """Converts global coordinates to local coordinates using zoom, rotation, and origin.

        Args:
            x (Coordinate | list[float]): The global coordinate(s) to convert.

        Returns:
            Coordinate: The converted local coordinate(s).

        Raises:
            AssertionError: If the attributes 'zoom', 'rotation', or 'origin' are missing.

        Notes:
            The 'zoom' and 'rotation' attributes should be set before calling this method.

        Examples:
            >>> POI_obj = Centroids(...)
            >>> POI_obj.zoom = (2.0, 2.0, 2.0)
            >>> POI_obj.rotation = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
            >>> POI_obj.origin = (10.0, 20.0, 30.0)
            >>> global_coordinate = (20.0, 30.0, 40.0)
            >>> local_coordinate = POI_obj.global_to_local(global_coordinate)
        """
        assert self.zoom is not None, "Attribute 'zoom' must be set before calling global_to_local."
        assert self.rotation is not None, "Attribute 'rotation' must be set before calling global_to_local."
        assert self.origin is not None, "Attribute 'origin' must be set before calling global_to_local."

        a = self.rotation.T @ (np.array(x) - self.origin) / np.array(self.zoom)
        # return tuple(a.tolist())
        return tuple(round(float(v), ROUNDING_LVL) for v in a)  # type: ignore

    def apply_crop_reverse(
        self: Self,
        o_shift: tuple[slice, slice, slice] | Sequence[slice],
        shape: tuple[int, int, int] | Sequence[int],
        inplace=False,
    ) -> Self:
        """A Poi crop can be trivially reversed with out any loss. See apply_crop for more information."""
        return self.apply_crop(
            tuple(slice(-shift.start, sh - shift.start) for shift, sh in zip_strict(o_shift, shape)),
            inplace=inplace,
        )

    def apply_crop(self: Self, o_shift: tuple[slice, slice, slice] | Sequence[slice], inplace=False) -> Self:
        """Adjust POI coordinates after a crop operation by shifting the origin.

        Points outside the cropped frame are NOT removed.
        See :meth:`~TPTBox.NII.compute_crop_slice`.

        Args:
            o_shift (tuple[slice, slice, slice]): translation of the origin, cause by the crop
            inplace (bool, optional): inplace. Defaults to True.

        Returns:
            Self
        """
        """Crop the POIs based on the given origin shift due to the image crop.

        When you crop an image, you have to also crop the POIs.
        There are actually no boundaries to be moved, but the origin must be moved to the new 0, 0, 0.
        Points outside the frame are NOT removed. See NII.compute_crop_slice().

        Args:
            o_shift (tuple[slice, slice, slice]): Translation of the origin caused by the crop.
            inplace (bool, optional): If True, perform the operation in-place. Defaults to False.

        Returns:
            Centroids: If inplace is True, returns the modified self. Otherwise, returns a new Centroids object.

        Notes:
            The input 'o_shift' should be a tuple of slices for each dimension, specifying the crop range.
            The 'shape' and 'origin' attributes are updated based on the crop information.
        Raises:
            AttributeError: If the old deprecated format for 'o_shift' (a tuple of floats) is used.

        Examples:
            >>> POI_obj = Centroids(...)
            >>> crop_slice = (slice(10, 20), slice(5, 15), slice(0, 8))
            >>> new_POIs = POI_obj.crop(crop_slice)
        """
        origin: COORDINATE = None  # type: ignore
        shape = None  # type: ignore
        o_shift = tuple(o if o.start is not None else slice(0, None) for o in o_shift)
        try:

            def shift(x, y, z):
                return (
                    float(x - o_shift[0].start),
                    float(y - o_shift[1].start),
                    float(z - o_shift[2].start),
                )

            poi_out = self.apply_all(shift, inplace=inplace)
            if self.shape is not None:
                in_shape = self.shape

                def map_v(sli: slice, i):
                    end = sli.stop
                    if end is None:
                        return in_shape[i]
                    if end >= 0:
                        return end
                    else:
                        return end + in_shape[i]

                shape: TRIPLE = tuple(int(map_v(o_shift[i], i) - o_shift[i].start) for i in range(3))  # type: ignore
            if self.origin is not None:
                origin = self.local_to_global(tuple(float(y.start) for y in o_shift))  # type: ignore
                # origin = tuple(float(x + y.start) for x, y in zip(self.origin, o_shift))

        except AttributeError:
            warnings.warn(
                "using o_shift with only a tuple of floats is deprecated. Use tuple(slice(start,end),...) instead. end can be None for no change. Input: "
                + str(o_shift),
                DeprecationWarning,
                stacklevel=4,
            )
            o: tuple[float, float, float] = o_shift  # type: ignore

            def shift2(x, y, z):
                return x - o[0], y - o[1], z - o[2]

            poi_out = self.apply_all(shift2, inplace=inplace)
            shape = None  # type: ignore

        if inplace:
            self.shape = shape
            self.origin = origin
            return self
        out = self.copy(centroids=poi_out.centroids, shape=shape, rotation=self.rotation, origin=origin)
        return out

    def apply_crop_(self, o_shift: tuple[slice, slice, slice] | Sequence[slice]) -> Self:
        """In-place variant of :meth:`apply_crop`."""
        return self.apply_crop(o_shift, inplace=True)

    def shift_all_coordinates(
        self, translation_vector: tuple[slice, slice, slice] | Sequence[slice] | None, inplace=True, **kwargs
    ) -> Self:
        """Shift all POI coordinates by a translation expressed as crop slices.

        Args:
            translation_vector: Per-axis slices encoding the origin shift, or ``None``
                to return ``self`` unchanged.
            inplace (bool, optional): Whether to modify in place. Defaults to True.

        Returns:
            Self: The updated POI (same object when ``inplace=True``).
        """
        if translation_vector is None:
            return self
        return self.apply_crop(translation_vector, inplace=inplace, **kwargs)

    def reorient(
        self, axcodes_to: AX_CODES = ("P", "I", "R"), decimals=ROUNDING_LVL, verbose: logging = False, inplace=False, _shape=None
    ) -> Self:
        """Reorients the POIs of an image from the current orientation to the specified orientation.

        This method updates the position of the POIs, zoom level, and shape of the image accordingly.

        Args:
            axcodes_to (Ax_Codes, optional): An Ax_Codes object representing the desired orientation of the POIs.
                Defaults to ("P", "I", "R").
            decimals (int, optional): Number of decimal places to round the coordinates of the POIs after reorientation.
                Defaults to ROUNDING_LVL.
            verbose (bool, optional): If True, print a message indicating the current and new orientation of the POIs.
                Defaults to False.
            inplace (bool, optional): If True, update the current POIs object with the reoriented values.
                If False, return a new POI object with reoriented values. Defaults to False.
            _shape (tuple[int] | None, optional): The shape of the image. Required if the shape is not already present in the POI object.

        Returns:
            POI: If inplace is True, returns the updated POI object.
                If inplace is False, returns a new POI object with reoriented values.

        Raises:
            ValueError: If the given _shape is not compatible with the shape already present in the POI object.
            AssertionError: If shape is not provided (either in the POI object or as _shape argument).

        Examples:
            >>> poi_obj = POI(...)
            >>> new_orientation = ("A", "P", "L")  # Desired orientation for reorientation
            >>> new_poi_obj = poi_obj.reorient(axcodes_to=new_orientation, decimals=4, inplace=False)
        """
        ctd_arr = np.transpose(np.asarray(list(self.centroids.values())))
        v_list = list(self.centroids.keys())

        ornt_fr = nio.axcodes2ornt(self.orientation)  # original poi orientation
        ornt_to = nio.axcodes2ornt(axcodes_to)
        if (ornt_fr == ornt_to).all():
            log.print("ctd is already rotated to image with ", axcodes_to, verbose=verbose)
            return self if inplace else self.copy()
        trans = nio.ornt_transform(ornt_fr, ornt_to).astype(int)
        perm: list[int] = trans[:, 0].tolist()

        if self.shape is not None:
            shape = tuple([self.shape[perm.index(i)] for i in range(len(perm))])

            if _shape != shape and _shape is not None:
                raise ValueError(f"Different shapes {shape} <-> {_shape}, types {type(shape)} <-> {type(_shape)}")
        else:
            shape = _shape
        assert shape is not None, "Require shape information for flipping dimensions. Set self.shape or use reorient_to"
        shp = np.asarray(shape)
        if ctd_arr.shape[0] == 0:
            log.print("No pois present", verbose=verbose, ltype=Log_Type.WARNING)
            points = self.centroids if inplace else self.centroids.copy()
        else:
            ctd_arr[perm] = ctd_arr.copy()
            for ax in trans:
                if ax[1] == -1:
                    size = shp[ax[0]]
                    ctd_arr[ax[0]] = np.around(size - ctd_arr[ax[0]], decimals) - 1
            points = POI_Descriptor()
            ctd_arr = np.transpose(ctd_arr).tolist()
            for v, point in zip_strict(v_list, ctd_arr):
                points[v] = tuple(point)

            log.print("[*] Centroids reoriented from", nio.ornt2axcodes(ornt_fr), "to", axcodes_to, verbose=verbose)
        if self.zoom is not None:
            zoom_i = np.array(self.zoom)
            zoom_i[perm] = zoom_i.copy()
            zoom: ZOOMS = tuple(zoom_i)
        else:
            zoom = None  # type: ignore
        perm2 = trans[:, 0]
        flip = trans[:, 1]
        if self.origin is not None and self.shape is not None:
            # When the axis is flipped the origin is changing by that side.
            # flip is -1 when when a side (of shape) is moved from the origin
            # if flip = -1 new local point is affine_matmul_(shape[1]-1) else 0
            change = ((-flip) + 1) / 2  # 1 if flip else 0
            change = tuple(a * (s - 1) for a, s in zip_strict(change, self.shape))
            origin: COORDINATE = self.local_to_global(change)
        else:
            origin = None  # type: ignore
        if self.rotation is not None:
            rotation_change = np.zeros((3, 3))
            rotation_change[0, perm2[0]] = flip[0]
            rotation_change[1, perm2[1]] = flip[1]
            rotation_change[2, perm2[2]] = flip[2]
            rotation = self.rotation
            rotation: ROTATION = rotation.copy() @ rotation_change
        else:
            rotation = None  # type: ignore
        if inplace:
            self.orientation = axcodes_to
            self.centroids = points
            self.zoom = zoom
            self.shape = shape
            self.origin = origin
            self.rotation = rotation
            return self
        return self.copy(orientation=axcodes_to, centroids=points, zoom=zoom, shape=shape, origin=origin, rotation=rotation)

    def reorient_(self, axcodes_to: AX_CODES = ("P", "I", "R"), decimals=3, verbose: logging = False, _shape=None) -> Self:
        """In-place variant of :meth:`reorient`."""
        return self.reorient(axcodes_to, decimals=decimals, verbose=verbose, inplace=True, _shape=_shape)

    def rescale(self, voxel_spacing: ZOOMS = (1, 1, 1), decimals=ROUNDING_LVL, verbose: logging = True, inplace=False) -> Self:
        """Rescale the POI coordinates to a new voxel spacing in the current x-y-z-orientation.

        Args:
            voxel_spacing (tuple[float, float, float], optional): New voxel spacing in millimeters. Defaults to (1, 1, 1).
            decimals (int, optional): Number of decimal places to round the rescaled coordinates to. Defaults to ROUNDING_LVL.
            verbose (bool, optional): Whether to print a message indicating that the POI coordinates have been rescaled. Defaults to True.
            inplace (bool, optional): Whether to modify the current instance or return a new instance. Defaults to False.

        Returns:
            POI: If inplace=True, returns the modified POI instance. Otherwise, returns a new POI instance with rescaled POI coordinates.

        Raises:
            AssertionError: If the 'zoom' attribute is not set in the Centroids instance.

        Examples:
            >>> POI_obj = POI(...)
            >>> new_voxel_spacing = (2.0, 2.0, 2.0)  # Desired voxel spacing for rescaling
            >>> rescaled_POI_obj = POI_obj.rescale(voxel_spacing=new_voxel_spacing, decimals=4, inplace=False)
        """
        assert self.zoom is not None, "This Centroids instance doesn't have a zoom set. Use POI.zoom = nii.zoom"

        zms = self.zoom
        shp: list[float] = list(self.shape) if self.shape is not None else None  # type: ignore
        ctd_arr = np.transpose(np.asarray(list(self.centroids.values())))
        v_list = list(self.centroids.keys())
        voxel_spacing = tuple([v if v != -1 else z for v, z in zip_strict(voxel_spacing, zms)])
        for i in range(3):
            fkt = zms[i] / voxel_spacing[i]
            if len(v_list) != 0:
                ctd_arr[i] = np.around(ctd_arr[i] * fkt, decimals=decimals)
            if shp is not None:
                shp[i] *= fkt
        points = POI_Descriptor()
        ctd_arr = np.transpose(ctd_arr).tolist()
        for v, point in zip_strict(v_list, ctd_arr):
            points[v] = tuple(point)
        log.print(
            "Rescaled centroid coordinates to spacing (x, y, z) =",
            voxel_spacing,
            "mm",
            verbose=verbose,
        )
        if shp is not None:
            shp = tuple(float(v) for v in shp)  # type: ignore

        if inplace:
            self.centroids = points
            self.zoom = voxel_spacing
            self.shape = shp
            return self
        return self.copy(centroids=points, zoom=voxel_spacing, shape=shp)

    def rescale_(self, voxel_spacing: ZOOMS = (1, 1, 1), decimals=3, verbose: logging = False) -> Self:
        """In-place variant of :meth:`rescale`."""
        return self.rescale(voxel_spacing=voxel_spacing, decimals=decimals, verbose=verbose, inplace=True)

    def to_global(self, itk_coords=False) -> POI_Global:
        """Converts the Centroids object to a global POI_Global object.

        This method converts the local POI coordinates to global coordinates using the Centroids' zoom,
        rotation, and origin attributes and returns a new POI_Global object.

        Returns:
            POI_Global: A new POI_Global object with the converted global POI coordinates.

        Examples:
            >>> POI_obj = Centroids(...)
            >>> global_obj = POI_obj.to_global()
        """
        from TPTBox import POI_Global

        return POI_Global(
            self, itk_coords=itk_coords, level_one_info=self.level_one_info, level_two_info=self.level_two_info, info=self.info.copy()
        )

    def resample_from_to(self, ref: Has_Grid) -> POI:
        """Resample this POI to the grid of another image by converting to global and back.

        Args:
            ref (Has_Grid): Target image grid (any object providing affine/orientation info).

        Returns:
            POI: A new POI in the voxel space of ``ref``.
        """
        return self.to_global().to_other(ref)

    def resample_from_to_(self, ref: Has_Grid) -> Self:
        """In-place variant of :meth:`resample_from_to`."""
        return self._set_inplace(self.resample_from_to(ref))

    def save(
        self,
        out_path: Path | str,
        make_parents=False,
        additional_info: dict | None = None,
        save_hint=2,
        resample_reference: Has_Grid | None = None,
        verbose: logging = True,
    ) -> None:
        """Saves the POIs to a JSON file.

        Args:
            out_path (Path | str): The path where the JSON file will be saved.
            make_parents (bool, optional): If True, create any necessary parent directories for the output file.
                Defaults to False.
            verbose (bool, optional): If True, print status messages to the console. Defaults to True.
            save_hint: 0 Default, 1 Gruber, 2 POI (readable), 10 ISO-POI (outdated)

        Returns:
            None

        Raises:
            TypeError: If any of the POIs have an invalid type.

        Example:
            >>> POIs = Centroids(...)
            >>> POIs.save("output/POIs.json")
        """
        return save_load.save_poi(
            self, out_path, make_parents, additional_info, verbose=verbose, save_hint=save_hint, resample_reference=resample_reference
        )

    def make_point_cloud_nii(self, affine=None, s=8, sphere=False) -> tuple[NII, NII]:
        """Create point cloud NIfTI images from the POI coordinates.

        This method generates two NIfTI images, one for the regions and another for the subregions,
        representing the point cloud with a specified neighborhood size.

        Args:
            affine (np.ndarray, optional): The affine transformation matrix for the NIfTI image.
                Defaults to None. If None, the POI object's affine will be used.
            s (int, optional): The neighborhood size. Defaults to 8.

        Returns:
            tuple[NII, NII]: A tuple containing two NII objects representing the point cloud for regions and subregions.

        Raises:
            AssertionError: If the 'shape' or 'zoom' attributes are not set in the Centroids instance.

        Examples:
            >>> POI_obj = Centroids(...)
            >>> neighborhood_size = 10
            >>> region_cloud, subregion_cloud = POI_obj.make_point_cloud_nii(s=neighborhood_size)
        """
        assert self.shape is not None, "need shape information"
        assert self.zoom is not None, "need shape information"
        if affine is None:
            affine = self.affine
        arr = np.zeros(self.shape_int)
        arr2 = np.zeros(self.shape_int)
        s1 = max(s // 2, 1)
        s2 = max(s - s1, 1)
        from math import ceil, floor

        if sphere:
            zoom = np.asarray(self.zoom)

            # sphere radius in mm
            radius = s / 2

            # kernel size in voxels
            rx = int(np.ceil(radius / zoom[0]))
            ry = int(np.ceil(radius / zoom[1]))
            rz = int(np.ceil(radius / zoom[2]))

            # create local sphere kernel ONCE
            gx, gy, gz = np.ogrid[-rx : rx + 1, -ry : ry + 1, -rz : rz + 1]
            sphere_mask = ((gx * zoom[0]) ** 2 + (gy * zoom[1]) ** 2 + (gz * zoom[2]) ** 2) <= radius**2

            for region, subregion, (x, y, z) in self.items():
                x, y, z = round(x), round(y), round(z)  # noqa: PLW2901

                # image bounds
                x0 = max(x - rx, 0)
                x1 = min(x + rx + 1, self.shape[0])

                y0 = max(y - ry, 0)
                y1 = min(y + ry + 1, self.shape[1])

                z0 = max(z - rz, 0)
                z1 = min(z + rz + 1, self.shape[2])

                # kernel bounds
                kx0 = x0 - (x - rx)
                kx1 = kx0 + (x1 - x0)

                ky0 = y0 - (y - ry)
                ky1 = ky0 + (y1 - y0)

                kz0 = z0 - (z - rz)
                kz1 = kz0 + (z1 - z0)

                local_mask = sphere_mask[kx0:kx1, ky0:ky1, kz0:kz1]

                arr[x0:x1, y0:y1, z0:z1][local_mask] = region
                arr2[x0:x1, y0:y1, z0:z1][local_mask] = subregion
        else:
            for region, subregion, (x, y, z) in self.items():
                arr[
                    max((floor(x - s1 / self.zoom[0])) + 1, 0) : min((ceil(x + s2 / self.zoom[0] + 1)), self.shape[0]),
                    max((floor(y - s1 / self.zoom[1])) + 1, 0) : min((ceil(y + s2 / self.zoom[1] + 1)), self.shape[1]),
                    max((floor(z - s1 / self.zoom[2])) + 1, 0) : min((ceil(z + s2 / self.zoom[2] + 1)), self.shape[2]),
                ] = region
                arr2[
                    max((floor(x - s1 / self.zoom[0])) + 1, 0) : min((ceil(x + s2 / self.zoom[0] + 1)), self.shape[0]),
                    max((floor(y - s1 / self.zoom[1])) + 1, 0) : min((ceil(y + s2 / self.zoom[1] + 1)), self.shape[1]),
                    max((floor(z - s1 / self.zoom[2])) + 1, 0) : min((ceil(z + s2 / self.zoom[2] + 1)), self.shape[2]),
                ] = subregion
        nii = nib.Nifti1Image(arr, affine=affine)
        nii2 = nib.Nifti1Image(arr2, affine=affine)
        return NII(nii, seg=True), NII(nii2, seg=True)

    def filter_points_inside_shape(self, inplace=False) -> Self:
        """Filter out POI points that are outside the defined shape.

        This method checks each POI point and removes any point whose coordinates
        are outside the defined shape.

        Returns:
            POI: A new POI object containing POI points that are inside the defined shape.

        Examples:
            >>> POI_obj = POI(...)
            >>> filtered_POIs = POI_obj.filter_points_inside_shape()
        """
        if self.shape is None:
            raise ValueError("Cannot filter points outside shape as the shape attribute is not defined.")

        filtered_centroids = POI_Descriptor()
        for region, subregion, (x, y, z) in self.centroids.items():
            if 0 <= x < self.shape[0] and 0 <= y < self.shape[1] and 0 <= z < self.shape[2]:
                filtered_centroids[(region, subregion)] = (x, y, z)
        if inplace:
            self.centroids = filtered_centroids
            return self
        return self.copy(filtered_centroids)

    @classmethod
    def load(cls, poi: POI_Reference, reference: Has_Grid | None = None, allow_global=False) -> POI:
        """Load a Centroids object from various input sources.

        This method provides a convenient way to load a Centroids object from different sources,
        including BIDS files, file paths, image references, or existing POI objects.

        Args:
            poi (Centroid_Reference): The input source from which to load the Centroids object.
                It can be one of the following types:
                - BIDS_FILE: A BIDS file representing the Centroids object.
                - Path: The path to the file containing the Centroids object.
                - str: The string representation of the Centroids object file path.
                - Tuple[Image_Reference, Image_Reference, list[int]]: A tuple containing two Image_Reference objects
                and a list of integers representing the POI data.
                - POI: An existing POI object to be loaded.

        Returns:
            POI: The loaded Centroids object.

        Examples:
            >>> # Load from a BIDS file
            >>> bids_file_path = BIDS_FILE("/path/to/POIs.json", "/path/to/dataset/")
            >>> loaded_poi = POI.load(bids_file_path)

            >>> # Load from a file path
            >>> file_path = "/path/to/POIs.json"
            >>> loaded_poi = POI.load(file_path)

            >>> # Load from an image reference tuple and POI data
            >>> image_ref1 = Image_Reference(...)
            >>> image_ref2 = Image_Reference(...)
            >>> POI_data = [1, 2, 3]
            >>> loaded_poi = POI.load((image_ref1, image_ref2, POI_data))

            >>> # Load from an existing POI object
            >>> existing_poi = POI(...)
            >>> loaded_poi = POI.load(existing_poi)
        """
        from TPTBox import POI_Global

        poi_obj: POI = poi if isinstance(poi, (POI, POI_Global)) else save_load.load_poi(poi)  # type: ignore
        if reference is not None:
            if isinstance(poi_obj, POI_Global):
                poi_obj = poi_obj.resample_from_to(reference)
            else:
                if poi_obj.orientation == ("U", "U", "U"):
                    poi_obj.orientation = reference.orientation
                if poi_obj.spacing is None:
                    poi_obj.spacing = reference.spacing
                if poi_obj.rotation is None:
                    poi_obj.rotation = reference.rotation
                if poi_obj.shape is None:
                    poi_obj.shape = reference.shape
                if poi_obj.origin is None:
                    poi_obj.origin = reference.origin
                reference.assert_affine(poi_obj, shape_tolerance=0.001)
        if isinstance(poi_obj, POI_Global) and not allow_global:
            warnings.warn(
                f"{poi} is a POI with global coordinates, but you loaded it with POI.load(), \n"
                + "Use POI_Global.load() if you want to load a POI_Global \n"
                + "Use reference=... to resample the global POI to a Grid \n"
                + "or allow_global = True if you want allow a mix of POI and POI_Global\n",
                UserWarning,
                stacklevel=4,
            )
        return poi_obj  # type: ignore

    def __eq__(self, value: object) -> bool:
        if not isinstance(value, POI):
            return False
        else:
            value2: Self = value  # type: ignore
        if not self.assert_affine(value2, raise_error=False):
            return False
        return self.centroids == value2.centroids

is_global property

is_global: bool

Always False for voxel-space POIs; True only for POI_Global objects.

rotation property writable

rotation: ROTATION

3×3 rotation matrix of the image grid, or None if not set.

zoom property writable

zoom: ZOOMS

Voxel spacing in mm along each axis, or None if not set.

spacing property writable

spacing: ZOOMS

Alias for :attr:zoom.

clone

clone(**qargs) -> Self

Return a copy of this POI; alias for :meth:copy.

Source code in TPTBox/core/poi.py
def clone(self, **qargs) -> Self:
    """Return a copy of this POI; alias for :meth:`copy`."""
    return self.copy(**qargs)

copy

copy(centroids: POI_DICT | POI_Descriptor | None = None, orientation: AX_CODES | None = None, zoom: ZOOMS | Sentinel = Sentinel(), shape: TRIPLE | tuple[float, ...] | Sentinel = Sentinel(), rotation: ROTATION | Sentinel = Sentinel(), origin: COORDINATE | Sentinel = Sentinel()) -> Self

Create a copy of the POI object with optional attribute overrides.

Parameters:

Name Type Description Default
centroids POI_Dict | POI_Descriptor | None

The POIs to use in the copied object. Defaults to None, in which case the original POIs will be used.

None
orientation Ax_Codes | None

The orientation code to use in the copied object. Defaults to None, in which case the original orientation will be used.

None
zoom Zooms | None | Sentinel

The zoom values to use in the copied object. Defaults to Sentinel(), in which case the original zoom values will be used.

Sentinel()
shape tuple[float, float, float] | None | Sentinel

The shape values to use in the copied object. Defaults to Sentinel(), in which case the original shape values will be used.

Sentinel()
rotation Rotation | None | Sentinel

The rotation matrix to use in the copied object. Defaults to Sentinel(), in which case the original rotation matrix will be used.

Sentinel()
origin Coordinate | None | Sentinel

The origin coordinates to use in the copied object. Defaults to Sentinel(), in which case the original origin coordinates will be used.

Sentinel()

Returns:

Name Type Description
POI Self

A new POI object with the specified attribute overrides.

Examples:

>>> POI_obj = POI(...)
>>> POI_obj_copy = POI_obj.copy(zoom=(2.0, 2.0, 2.0), rotation=rotation_matrix)
Source code in TPTBox/core/poi.py
def copy(
    self,
    centroids: POI_DICT | POI_Descriptor | None = None,
    orientation: AX_CODES | None = None,
    zoom: ZOOMS | Sentinel = Sentinel(),  # noqa: B008
    shape: TRIPLE | tuple[float, ...] | Sentinel = Sentinel(),  # noqa: B008
    rotation: ROTATION | Sentinel = Sentinel(),  # noqa: B008
    origin: COORDINATE | Sentinel = Sentinel(),  # noqa: B008
) -> Self:
    """Create a copy of the POI object with optional attribute overrides.

    Args:
        centroids (POI_Dict | POI_Descriptor | None, optional): The POIs to use in the copied object.
            Defaults to None, in which case the original POIs will be used.
        orientation (Ax_Codes | None, optional): The orientation code to use in the copied object.
            Defaults to None, in which case the original orientation will be used.
        zoom (Zooms | None | Sentinel, optional): The zoom values to use in the copied object.
            Defaults to Sentinel(), in which case the original zoom values will be used.
        shape (tuple[float, float, float] | None | Sentinel, optional): The shape values to use in the copied object.
            Defaults to Sentinel(), in which case the original shape values will be used.
        rotation (Rotation | None | Sentinel, optional): The rotation matrix to use in the copied object.
            Defaults to Sentinel(), in which case the original rotation matrix will be used.
        origin (Coordinate | None | Sentinel, optional): The origin coordinates to use in the copied object.
            Defaults to Sentinel(), in which case the original origin coordinates will be used.

    Returns:
        POI: A new POI object with the specified attribute overrides.

    Examples:
        >>> POI_obj = POI(...)
        >>> POI_obj_copy = POI_obj.copy(zoom=(2.0, 2.0, 2.0), rotation=rotation_matrix)
    """
    if isinstance(shape, tuple):
        shape = tuple(round(float(v), 7) for v in shape)  # type: ignore

    return POI(
        centroids=centroids.copy() if centroids is not None else self.centroids.copy(),
        orientation=orientation if orientation is not None else self.orientation,
        zoom=zoom if not isinstance(zoom, Sentinel) else self.zoom,
        shape=shape if not isinstance(shape, Sentinel) else self.shape,  # type: ignore
        rotation=rotation if not isinstance(rotation, Sentinel) else self.rotation,
        origin=origin if not isinstance(origin, Sentinel) else self.origin,
        info=deepcopy(self.info),
        format=self.format,
    )

local_to_global

local_to_global(x: COORDINATE, itk_coords=False) -> COORDINATE

Converts local coordinates to global coordinates using zoom, rotation, and origin.

Parameters:

Name Type Description Default
x Coordinate | list[float]

The local coordinate(s) to convert.

required

Returns:

Name Type Description
Coordinate COORDINATE

The converted global coordinate(s).

Raises:

Type Description
AssertionError

If the attributes 'zoom', 'rotation', or 'origin' are missing.

Notes

The 'zoom' and 'rotation' attributes should be set before calling this method.

Examples:

>>> POI_obj = Centroids(...)
>>> POI_obj.zoom = (2.0, 2.0, 2.0)
>>> POI_obj.rotation = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
>>> POI_obj.origin = (10.0, 20.0, 30.0)
>>> local_coordinate = (1.0, 2.0, 3.0)
>>> global_coordinate = POI_obj.local_to_global(local_coordinate)
Source code in TPTBox/core/poi.py
def local_to_global(self, x: COORDINATE, itk_coords=False) -> COORDINATE:
    """Converts local coordinates to global coordinates using zoom, rotation, and origin.

    Args:
        x (Coordinate | list[float]): The local coordinate(s) to convert.

    Returns:
        Coordinate: The converted global coordinate(s).

    Raises:
        AssertionError: If the attributes 'zoom', 'rotation', or 'origin' are missing.

    Notes:
        The 'zoom' and 'rotation' attributes should be set before calling this method.

    Examples:
        >>> POI_obj = Centroids(...)
        >>> POI_obj.zoom = (2.0, 2.0, 2.0)
        >>> POI_obj.rotation = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
        >>> POI_obj.origin = (10.0, 20.0, 30.0)
        >>> local_coordinate = (1.0, 2.0, 3.0)
        >>> global_coordinate = POI_obj.local_to_global(local_coordinate)
    """
    assert self.zoom is not None, "Attribute 'zoom' must be set before calling local_to_global."
    assert self.rotation is not None, "Attribute 'rotation' must be set before calling local_to_global."
    assert self.origin is not None, "Attribute 'origin' must be set before calling local_to_global."

    a = self.rotation @ (np.array(x) * np.array(self.zoom)) + self.origin
    if itk_coords:
        a = (-a[0], -a[1], a[2])
    # return tuple(a.tolist())
    return tuple(round(float(v), ROUNDING_LVL) for v in a)

global_to_local

global_to_local(x: COORDINATE) -> COORDINATE

Converts global coordinates to local coordinates using zoom, rotation, and origin.

Parameters:

Name Type Description Default
x Coordinate | list[float]

The global coordinate(s) to convert.

required

Returns:

Name Type Description
Coordinate COORDINATE

The converted local coordinate(s).

Raises:

Type Description
AssertionError

If the attributes 'zoom', 'rotation', or 'origin' are missing.

Notes

The 'zoom' and 'rotation' attributes should be set before calling this method.

Examples:

>>> POI_obj = Centroids(...)
>>> POI_obj.zoom = (2.0, 2.0, 2.0)
>>> POI_obj.rotation = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
>>> POI_obj.origin = (10.0, 20.0, 30.0)
>>> global_coordinate = (20.0, 30.0, 40.0)
>>> local_coordinate = POI_obj.global_to_local(global_coordinate)
Source code in TPTBox/core/poi.py
def global_to_local(self, x: COORDINATE) -> COORDINATE:
    """Converts global coordinates to local coordinates using zoom, rotation, and origin.

    Args:
        x (Coordinate | list[float]): The global coordinate(s) to convert.

    Returns:
        Coordinate: The converted local coordinate(s).

    Raises:
        AssertionError: If the attributes 'zoom', 'rotation', or 'origin' are missing.

    Notes:
        The 'zoom' and 'rotation' attributes should be set before calling this method.

    Examples:
        >>> POI_obj = Centroids(...)
        >>> POI_obj.zoom = (2.0, 2.0, 2.0)
        >>> POI_obj.rotation = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
        >>> POI_obj.origin = (10.0, 20.0, 30.0)
        >>> global_coordinate = (20.0, 30.0, 40.0)
        >>> local_coordinate = POI_obj.global_to_local(global_coordinate)
    """
    assert self.zoom is not None, "Attribute 'zoom' must be set before calling global_to_local."
    assert self.rotation is not None, "Attribute 'rotation' must be set before calling global_to_local."
    assert self.origin is not None, "Attribute 'origin' must be set before calling global_to_local."

    a = self.rotation.T @ (np.array(x) - self.origin) / np.array(self.zoom)
    # return tuple(a.tolist())
    return tuple(round(float(v), ROUNDING_LVL) for v in a)  # type: ignore

apply_crop_reverse

apply_crop_reverse(o_shift: tuple[slice, slice, slice] | Sequence[slice], shape: tuple[int, int, int] | Sequence[int], inplace=False) -> Self

A Poi crop can be trivially reversed with out any loss. See apply_crop for more information.

Source code in TPTBox/core/poi.py
def apply_crop_reverse(
    self: Self,
    o_shift: tuple[slice, slice, slice] | Sequence[slice],
    shape: tuple[int, int, int] | Sequence[int],
    inplace=False,
) -> Self:
    """A Poi crop can be trivially reversed with out any loss. See apply_crop for more information."""
    return self.apply_crop(
        tuple(slice(-shift.start, sh - shift.start) for shift, sh in zip_strict(o_shift, shape)),
        inplace=inplace,
    )

apply_crop

apply_crop(o_shift: tuple[slice, slice, slice] | Sequence[slice], inplace=False) -> Self

Adjust POI coordinates after a crop operation by shifting the origin.

Points outside the cropped frame are NOT removed. See :meth:~TPTBox.NII.compute_crop_slice.

Parameters:

Name Type Description Default
o_shift tuple[slice, slice, slice]

translation of the origin, cause by the crop

required
inplace bool

inplace. Defaults to True.

False

Returns:

Type Description
Self

Self

Source code in TPTBox/core/poi.py
def apply_crop(self: Self, o_shift: tuple[slice, slice, slice] | Sequence[slice], inplace=False) -> Self:
    """Adjust POI coordinates after a crop operation by shifting the origin.

    Points outside the cropped frame are NOT removed.
    See :meth:`~TPTBox.NII.compute_crop_slice`.

    Args:
        o_shift (tuple[slice, slice, slice]): translation of the origin, cause by the crop
        inplace (bool, optional): inplace. Defaults to True.

    Returns:
        Self
    """
    """Crop the POIs based on the given origin shift due to the image crop.

    When you crop an image, you have to also crop the POIs.
    There are actually no boundaries to be moved, but the origin must be moved to the new 0, 0, 0.
    Points outside the frame are NOT removed. See NII.compute_crop_slice().

    Args:
        o_shift (tuple[slice, slice, slice]): Translation of the origin caused by the crop.
        inplace (bool, optional): If True, perform the operation in-place. Defaults to False.

    Returns:
        Centroids: If inplace is True, returns the modified self. Otherwise, returns a new Centroids object.

    Notes:
        The input 'o_shift' should be a tuple of slices for each dimension, specifying the crop range.
        The 'shape' and 'origin' attributes are updated based on the crop information.
    Raises:
        AttributeError: If the old deprecated format for 'o_shift' (a tuple of floats) is used.

    Examples:
        >>> POI_obj = Centroids(...)
        >>> crop_slice = (slice(10, 20), slice(5, 15), slice(0, 8))
        >>> new_POIs = POI_obj.crop(crop_slice)
    """
    origin: COORDINATE = None  # type: ignore
    shape = None  # type: ignore
    o_shift = tuple(o if o.start is not None else slice(0, None) for o in o_shift)
    try:

        def shift(x, y, z):
            return (
                float(x - o_shift[0].start),
                float(y - o_shift[1].start),
                float(z - o_shift[2].start),
            )

        poi_out = self.apply_all(shift, inplace=inplace)
        if self.shape is not None:
            in_shape = self.shape

            def map_v(sli: slice, i):
                end = sli.stop
                if end is None:
                    return in_shape[i]
                if end >= 0:
                    return end
                else:
                    return end + in_shape[i]

            shape: TRIPLE = tuple(int(map_v(o_shift[i], i) - o_shift[i].start) for i in range(3))  # type: ignore
        if self.origin is not None:
            origin = self.local_to_global(tuple(float(y.start) for y in o_shift))  # type: ignore
            # origin = tuple(float(x + y.start) for x, y in zip(self.origin, o_shift))

    except AttributeError:
        warnings.warn(
            "using o_shift with only a tuple of floats is deprecated. Use tuple(slice(start,end),...) instead. end can be None for no change. Input: "
            + str(o_shift),
            DeprecationWarning,
            stacklevel=4,
        )
        o: tuple[float, float, float] = o_shift  # type: ignore

        def shift2(x, y, z):
            return x - o[0], y - o[1], z - o[2]

        poi_out = self.apply_all(shift2, inplace=inplace)
        shape = None  # type: ignore

    if inplace:
        self.shape = shape
        self.origin = origin
        return self
    out = self.copy(centroids=poi_out.centroids, shape=shape, rotation=self.rotation, origin=origin)
    return out

apply_crop_

apply_crop_(o_shift: tuple[slice, slice, slice] | Sequence[slice]) -> Self

In-place variant of :meth:apply_crop.

Source code in TPTBox/core/poi.py
def apply_crop_(self, o_shift: tuple[slice, slice, slice] | Sequence[slice]) -> Self:
    """In-place variant of :meth:`apply_crop`."""
    return self.apply_crop(o_shift, inplace=True)

shift_all_coordinates

shift_all_coordinates(translation_vector: tuple[slice, slice, slice] | Sequence[slice] | None, inplace=True, **kwargs) -> Self

Shift all POI coordinates by a translation expressed as crop slices.

Parameters:

Name Type Description Default
translation_vector tuple[slice, slice, slice] | Sequence[slice] | None

Per-axis slices encoding the origin shift, or None to return self unchanged.

required
inplace bool

Whether to modify in place. Defaults to True.

True

Returns:

Name Type Description
Self Self

The updated POI (same object when inplace=True).

Source code in TPTBox/core/poi.py
def shift_all_coordinates(
    self, translation_vector: tuple[slice, slice, slice] | Sequence[slice] | None, inplace=True, **kwargs
) -> Self:
    """Shift all POI coordinates by a translation expressed as crop slices.

    Args:
        translation_vector: Per-axis slices encoding the origin shift, or ``None``
            to return ``self`` unchanged.
        inplace (bool, optional): Whether to modify in place. Defaults to True.

    Returns:
        Self: The updated POI (same object when ``inplace=True``).
    """
    if translation_vector is None:
        return self
    return self.apply_crop(translation_vector, inplace=inplace, **kwargs)

reorient

reorient(axcodes_to: AX_CODES = ('P', 'I', 'R'), decimals=ROUNDING_LVL, verbose: logging = False, inplace=False, _shape=None) -> Self

Reorients the POIs of an image from the current orientation to the specified orientation.

This method updates the position of the POIs, zoom level, and shape of the image accordingly.

Parameters:

Name Type Description Default
axcodes_to Ax_Codes

An Ax_Codes object representing the desired orientation of the POIs. Defaults to ("P", "I", "R").

('P', 'I', 'R')
decimals int

Number of decimal places to round the coordinates of the POIs after reorientation. Defaults to ROUNDING_LVL.

ROUNDING_LVL
verbose bool

If True, print a message indicating the current and new orientation of the POIs. Defaults to False.

False
inplace bool

If True, update the current POIs object with the reoriented values. If False, return a new POI object with reoriented values. Defaults to False.

False
_shape tuple[int] | None

The shape of the image. Required if the shape is not already present in the POI object.

None

Returns:

Name Type Description
POI Self

If inplace is True, returns the updated POI object. If inplace is False, returns a new POI object with reoriented values.

Raises:

Type Description
ValueError

If the given _shape is not compatible with the shape already present in the POI object.

AssertionError

If shape is not provided (either in the POI object or as _shape argument).

Examples:

>>> poi_obj = POI(...)
>>> new_orientation = ("A", "P", "L")  # Desired orientation for reorientation
>>> new_poi_obj = poi_obj.reorient(axcodes_to=new_orientation, decimals=4, inplace=False)
Source code in TPTBox/core/poi.py
def reorient(
    self, axcodes_to: AX_CODES = ("P", "I", "R"), decimals=ROUNDING_LVL, verbose: logging = False, inplace=False, _shape=None
) -> Self:
    """Reorients the POIs of an image from the current orientation to the specified orientation.

    This method updates the position of the POIs, zoom level, and shape of the image accordingly.

    Args:
        axcodes_to (Ax_Codes, optional): An Ax_Codes object representing the desired orientation of the POIs.
            Defaults to ("P", "I", "R").
        decimals (int, optional): Number of decimal places to round the coordinates of the POIs after reorientation.
            Defaults to ROUNDING_LVL.
        verbose (bool, optional): If True, print a message indicating the current and new orientation of the POIs.
            Defaults to False.
        inplace (bool, optional): If True, update the current POIs object with the reoriented values.
            If False, return a new POI object with reoriented values. Defaults to False.
        _shape (tuple[int] | None, optional): The shape of the image. Required if the shape is not already present in the POI object.

    Returns:
        POI: If inplace is True, returns the updated POI object.
            If inplace is False, returns a new POI object with reoriented values.

    Raises:
        ValueError: If the given _shape is not compatible with the shape already present in the POI object.
        AssertionError: If shape is not provided (either in the POI object or as _shape argument).

    Examples:
        >>> poi_obj = POI(...)
        >>> new_orientation = ("A", "P", "L")  # Desired orientation for reorientation
        >>> new_poi_obj = poi_obj.reorient(axcodes_to=new_orientation, decimals=4, inplace=False)
    """
    ctd_arr = np.transpose(np.asarray(list(self.centroids.values())))
    v_list = list(self.centroids.keys())

    ornt_fr = nio.axcodes2ornt(self.orientation)  # original poi orientation
    ornt_to = nio.axcodes2ornt(axcodes_to)
    if (ornt_fr == ornt_to).all():
        log.print("ctd is already rotated to image with ", axcodes_to, verbose=verbose)
        return self if inplace else self.copy()
    trans = nio.ornt_transform(ornt_fr, ornt_to).astype(int)
    perm: list[int] = trans[:, 0].tolist()

    if self.shape is not None:
        shape = tuple([self.shape[perm.index(i)] for i in range(len(perm))])

        if _shape != shape and _shape is not None:
            raise ValueError(f"Different shapes {shape} <-> {_shape}, types {type(shape)} <-> {type(_shape)}")
    else:
        shape = _shape
    assert shape is not None, "Require shape information for flipping dimensions. Set self.shape or use reorient_to"
    shp = np.asarray(shape)
    if ctd_arr.shape[0] == 0:
        log.print("No pois present", verbose=verbose, ltype=Log_Type.WARNING)
        points = self.centroids if inplace else self.centroids.copy()
    else:
        ctd_arr[perm] = ctd_arr.copy()
        for ax in trans:
            if ax[1] == -1:
                size = shp[ax[0]]
                ctd_arr[ax[0]] = np.around(size - ctd_arr[ax[0]], decimals) - 1
        points = POI_Descriptor()
        ctd_arr = np.transpose(ctd_arr).tolist()
        for v, point in zip_strict(v_list, ctd_arr):
            points[v] = tuple(point)

        log.print("[*] Centroids reoriented from", nio.ornt2axcodes(ornt_fr), "to", axcodes_to, verbose=verbose)
    if self.zoom is not None:
        zoom_i = np.array(self.zoom)
        zoom_i[perm] = zoom_i.copy()
        zoom: ZOOMS = tuple(zoom_i)
    else:
        zoom = None  # type: ignore
    perm2 = trans[:, 0]
    flip = trans[:, 1]
    if self.origin is not None and self.shape is not None:
        # When the axis is flipped the origin is changing by that side.
        # flip is -1 when when a side (of shape) is moved from the origin
        # if flip = -1 new local point is affine_matmul_(shape[1]-1) else 0
        change = ((-flip) + 1) / 2  # 1 if flip else 0
        change = tuple(a * (s - 1) for a, s in zip_strict(change, self.shape))
        origin: COORDINATE = self.local_to_global(change)
    else:
        origin = None  # type: ignore
    if self.rotation is not None:
        rotation_change = np.zeros((3, 3))
        rotation_change[0, perm2[0]] = flip[0]
        rotation_change[1, perm2[1]] = flip[1]
        rotation_change[2, perm2[2]] = flip[2]
        rotation = self.rotation
        rotation: ROTATION = rotation.copy() @ rotation_change
    else:
        rotation = None  # type: ignore
    if inplace:
        self.orientation = axcodes_to
        self.centroids = points
        self.zoom = zoom
        self.shape = shape
        self.origin = origin
        self.rotation = rotation
        return self
    return self.copy(orientation=axcodes_to, centroids=points, zoom=zoom, shape=shape, origin=origin, rotation=rotation)

reorient_

reorient_(axcodes_to: AX_CODES = ('P', 'I', 'R'), decimals=3, verbose: logging = False, _shape=None) -> Self

In-place variant of :meth:reorient.

Source code in TPTBox/core/poi.py
def reorient_(self, axcodes_to: AX_CODES = ("P", "I", "R"), decimals=3, verbose: logging = False, _shape=None) -> Self:
    """In-place variant of :meth:`reorient`."""
    return self.reorient(axcodes_to, decimals=decimals, verbose=verbose, inplace=True, _shape=_shape)

rescale

rescale(voxel_spacing: ZOOMS = (1, 1, 1), decimals=ROUNDING_LVL, verbose: logging = True, inplace=False) -> Self

Rescale the POI coordinates to a new voxel spacing in the current x-y-z-orientation.

Parameters:

Name Type Description Default
voxel_spacing tuple[float, float, float]

New voxel spacing in millimeters. Defaults to (1, 1, 1).

(1, 1, 1)
decimals int

Number of decimal places to round the rescaled coordinates to. Defaults to ROUNDING_LVL.

ROUNDING_LVL
verbose bool

Whether to print a message indicating that the POI coordinates have been rescaled. Defaults to True.

True
inplace bool

Whether to modify the current instance or return a new instance. Defaults to False.

False

Returns:

Name Type Description
POI Self

If inplace=True, returns the modified POI instance. Otherwise, returns a new POI instance with rescaled POI coordinates.

Raises:

Type Description
AssertionError

If the 'zoom' attribute is not set in the Centroids instance.

Examples:

>>> POI_obj = POI(...)
>>> new_voxel_spacing = (2.0, 2.0, 2.0)  # Desired voxel spacing for rescaling
>>> rescaled_POI_obj = POI_obj.rescale(voxel_spacing=new_voxel_spacing, decimals=4, inplace=False)
Source code in TPTBox/core/poi.py
def rescale(self, voxel_spacing: ZOOMS = (1, 1, 1), decimals=ROUNDING_LVL, verbose: logging = True, inplace=False) -> Self:
    """Rescale the POI coordinates to a new voxel spacing in the current x-y-z-orientation.

    Args:
        voxel_spacing (tuple[float, float, float], optional): New voxel spacing in millimeters. Defaults to (1, 1, 1).
        decimals (int, optional): Number of decimal places to round the rescaled coordinates to. Defaults to ROUNDING_LVL.
        verbose (bool, optional): Whether to print a message indicating that the POI coordinates have been rescaled. Defaults to True.
        inplace (bool, optional): Whether to modify the current instance or return a new instance. Defaults to False.

    Returns:
        POI: If inplace=True, returns the modified POI instance. Otherwise, returns a new POI instance with rescaled POI coordinates.

    Raises:
        AssertionError: If the 'zoom' attribute is not set in the Centroids instance.

    Examples:
        >>> POI_obj = POI(...)
        >>> new_voxel_spacing = (2.0, 2.0, 2.0)  # Desired voxel spacing for rescaling
        >>> rescaled_POI_obj = POI_obj.rescale(voxel_spacing=new_voxel_spacing, decimals=4, inplace=False)
    """
    assert self.zoom is not None, "This Centroids instance doesn't have a zoom set. Use POI.zoom = nii.zoom"

    zms = self.zoom
    shp: list[float] = list(self.shape) if self.shape is not None else None  # type: ignore
    ctd_arr = np.transpose(np.asarray(list(self.centroids.values())))
    v_list = list(self.centroids.keys())
    voxel_spacing = tuple([v if v != -1 else z for v, z in zip_strict(voxel_spacing, zms)])
    for i in range(3):
        fkt = zms[i] / voxel_spacing[i]
        if len(v_list) != 0:
            ctd_arr[i] = np.around(ctd_arr[i] * fkt, decimals=decimals)
        if shp is not None:
            shp[i] *= fkt
    points = POI_Descriptor()
    ctd_arr = np.transpose(ctd_arr).tolist()
    for v, point in zip_strict(v_list, ctd_arr):
        points[v] = tuple(point)
    log.print(
        "Rescaled centroid coordinates to spacing (x, y, z) =",
        voxel_spacing,
        "mm",
        verbose=verbose,
    )
    if shp is not None:
        shp = tuple(float(v) for v in shp)  # type: ignore

    if inplace:
        self.centroids = points
        self.zoom = voxel_spacing
        self.shape = shp
        return self
    return self.copy(centroids=points, zoom=voxel_spacing, shape=shp)

rescale_

rescale_(voxel_spacing: ZOOMS = (1, 1, 1), decimals=3, verbose: logging = False) -> Self

In-place variant of :meth:rescale.

Source code in TPTBox/core/poi.py
def rescale_(self, voxel_spacing: ZOOMS = (1, 1, 1), decimals=3, verbose: logging = False) -> Self:
    """In-place variant of :meth:`rescale`."""
    return self.rescale(voxel_spacing=voxel_spacing, decimals=decimals, verbose=verbose, inplace=True)

to_global

to_global(itk_coords=False) -> POI_Global

Converts the Centroids object to a global POI_Global object.

This method converts the local POI coordinates to global coordinates using the Centroids' zoom, rotation, and origin attributes and returns a new POI_Global object.

Returns:

Name Type Description
POI_Global POI_Global

A new POI_Global object with the converted global POI coordinates.

Examples:

>>> POI_obj = Centroids(...)
>>> global_obj = POI_obj.to_global()
Source code in TPTBox/core/poi.py
def to_global(self, itk_coords=False) -> POI_Global:
    """Converts the Centroids object to a global POI_Global object.

    This method converts the local POI coordinates to global coordinates using the Centroids' zoom,
    rotation, and origin attributes and returns a new POI_Global object.

    Returns:
        POI_Global: A new POI_Global object with the converted global POI coordinates.

    Examples:
        >>> POI_obj = Centroids(...)
        >>> global_obj = POI_obj.to_global()
    """
    from TPTBox import POI_Global

    return POI_Global(
        self, itk_coords=itk_coords, level_one_info=self.level_one_info, level_two_info=self.level_two_info, info=self.info.copy()
    )

resample_from_to

resample_from_to(ref: Has_Grid) -> POI

Resample this POI to the grid of another image by converting to global and back.

Parameters:

Name Type Description Default
ref Has_Grid

Target image grid (any object providing affine/orientation info).

required

Returns:

Name Type Description
POI POI

A new POI in the voxel space of ref.

Source code in TPTBox/core/poi.py
def resample_from_to(self, ref: Has_Grid) -> POI:
    """Resample this POI to the grid of another image by converting to global and back.

    Args:
        ref (Has_Grid): Target image grid (any object providing affine/orientation info).

    Returns:
        POI: A new POI in the voxel space of ``ref``.
    """
    return self.to_global().to_other(ref)

resample_from_to_

resample_from_to_(ref: Has_Grid) -> Self

In-place variant of :meth:resample_from_to.

Source code in TPTBox/core/poi.py
def resample_from_to_(self, ref: Has_Grid) -> Self:
    """In-place variant of :meth:`resample_from_to`."""
    return self._set_inplace(self.resample_from_to(ref))

save

save(out_path: Path | str, make_parents=False, additional_info: dict | None = None, save_hint=2, resample_reference: Has_Grid | None = None, verbose: logging = True) -> None

Saves the POIs to a JSON file.

Parameters:

Name Type Description Default
out_path Path | str

The path where the JSON file will be saved.

required
make_parents bool

If True, create any necessary parent directories for the output file. Defaults to False.

False
verbose bool

If True, print status messages to the console. Defaults to True.

True
save_hint

0 Default, 1 Gruber, 2 POI (readable), 10 ISO-POI (outdated)

2

Returns:

Type Description
None

None

Raises:

Type Description
TypeError

If any of the POIs have an invalid type.

Example

POIs = Centroids(...) POIs.save("output/POIs.json")

Source code in TPTBox/core/poi.py
def save(
    self,
    out_path: Path | str,
    make_parents=False,
    additional_info: dict | None = None,
    save_hint=2,
    resample_reference: Has_Grid | None = None,
    verbose: logging = True,
) -> None:
    """Saves the POIs to a JSON file.

    Args:
        out_path (Path | str): The path where the JSON file will be saved.
        make_parents (bool, optional): If True, create any necessary parent directories for the output file.
            Defaults to False.
        verbose (bool, optional): If True, print status messages to the console. Defaults to True.
        save_hint: 0 Default, 1 Gruber, 2 POI (readable), 10 ISO-POI (outdated)

    Returns:
        None

    Raises:
        TypeError: If any of the POIs have an invalid type.

    Example:
        >>> POIs = Centroids(...)
        >>> POIs.save("output/POIs.json")
    """
    return save_load.save_poi(
        self, out_path, make_parents, additional_info, verbose=verbose, save_hint=save_hint, resample_reference=resample_reference
    )

make_point_cloud_nii

make_point_cloud_nii(affine=None, s=8, sphere=False) -> tuple[NII, NII]

Create point cloud NIfTI images from the POI coordinates.

This method generates two NIfTI images, one for the regions and another for the subregions, representing the point cloud with a specified neighborhood size.

Parameters:

Name Type Description Default
affine ndarray

The affine transformation matrix for the NIfTI image. Defaults to None. If None, the POI object's affine will be used.

None
s int

The neighborhood size. Defaults to 8.

8

Returns:

Type Description
tuple[NII, NII]

tuple[NII, NII]: A tuple containing two NII objects representing the point cloud for regions and subregions.

Raises:

Type Description
AssertionError

If the 'shape' or 'zoom' attributes are not set in the Centroids instance.

Examples:

>>> POI_obj = Centroids(...)
>>> neighborhood_size = 10
>>> region_cloud, subregion_cloud = POI_obj.make_point_cloud_nii(s=neighborhood_size)
Source code in TPTBox/core/poi.py
def make_point_cloud_nii(self, affine=None, s=8, sphere=False) -> tuple[NII, NII]:
    """Create point cloud NIfTI images from the POI coordinates.

    This method generates two NIfTI images, one for the regions and another for the subregions,
    representing the point cloud with a specified neighborhood size.

    Args:
        affine (np.ndarray, optional): The affine transformation matrix for the NIfTI image.
            Defaults to None. If None, the POI object's affine will be used.
        s (int, optional): The neighborhood size. Defaults to 8.

    Returns:
        tuple[NII, NII]: A tuple containing two NII objects representing the point cloud for regions and subregions.

    Raises:
        AssertionError: If the 'shape' or 'zoom' attributes are not set in the Centroids instance.

    Examples:
        >>> POI_obj = Centroids(...)
        >>> neighborhood_size = 10
        >>> region_cloud, subregion_cloud = POI_obj.make_point_cloud_nii(s=neighborhood_size)
    """
    assert self.shape is not None, "need shape information"
    assert self.zoom is not None, "need shape information"
    if affine is None:
        affine = self.affine
    arr = np.zeros(self.shape_int)
    arr2 = np.zeros(self.shape_int)
    s1 = max(s // 2, 1)
    s2 = max(s - s1, 1)
    from math import ceil, floor

    if sphere:
        zoom = np.asarray(self.zoom)

        # sphere radius in mm
        radius = s / 2

        # kernel size in voxels
        rx = int(np.ceil(radius / zoom[0]))
        ry = int(np.ceil(radius / zoom[1]))
        rz = int(np.ceil(radius / zoom[2]))

        # create local sphere kernel ONCE
        gx, gy, gz = np.ogrid[-rx : rx + 1, -ry : ry + 1, -rz : rz + 1]
        sphere_mask = ((gx * zoom[0]) ** 2 + (gy * zoom[1]) ** 2 + (gz * zoom[2]) ** 2) <= radius**2

        for region, subregion, (x, y, z) in self.items():
            x, y, z = round(x), round(y), round(z)  # noqa: PLW2901

            # image bounds
            x0 = max(x - rx, 0)
            x1 = min(x + rx + 1, self.shape[0])

            y0 = max(y - ry, 0)
            y1 = min(y + ry + 1, self.shape[1])

            z0 = max(z - rz, 0)
            z1 = min(z + rz + 1, self.shape[2])

            # kernel bounds
            kx0 = x0 - (x - rx)
            kx1 = kx0 + (x1 - x0)

            ky0 = y0 - (y - ry)
            ky1 = ky0 + (y1 - y0)

            kz0 = z0 - (z - rz)
            kz1 = kz0 + (z1 - z0)

            local_mask = sphere_mask[kx0:kx1, ky0:ky1, kz0:kz1]

            arr[x0:x1, y0:y1, z0:z1][local_mask] = region
            arr2[x0:x1, y0:y1, z0:z1][local_mask] = subregion
    else:
        for region, subregion, (x, y, z) in self.items():
            arr[
                max((floor(x - s1 / self.zoom[0])) + 1, 0) : min((ceil(x + s2 / self.zoom[0] + 1)), self.shape[0]),
                max((floor(y - s1 / self.zoom[1])) + 1, 0) : min((ceil(y + s2 / self.zoom[1] + 1)), self.shape[1]),
                max((floor(z - s1 / self.zoom[2])) + 1, 0) : min((ceil(z + s2 / self.zoom[2] + 1)), self.shape[2]),
            ] = region
            arr2[
                max((floor(x - s1 / self.zoom[0])) + 1, 0) : min((ceil(x + s2 / self.zoom[0] + 1)), self.shape[0]),
                max((floor(y - s1 / self.zoom[1])) + 1, 0) : min((ceil(y + s2 / self.zoom[1] + 1)), self.shape[1]),
                max((floor(z - s1 / self.zoom[2])) + 1, 0) : min((ceil(z + s2 / self.zoom[2] + 1)), self.shape[2]),
            ] = subregion
    nii = nib.Nifti1Image(arr, affine=affine)
    nii2 = nib.Nifti1Image(arr2, affine=affine)
    return NII(nii, seg=True), NII(nii2, seg=True)

filter_points_inside_shape

filter_points_inside_shape(inplace=False) -> Self

Filter out POI points that are outside the defined shape.

This method checks each POI point and removes any point whose coordinates are outside the defined shape.

Returns:

Name Type Description
POI Self

A new POI object containing POI points that are inside the defined shape.

Examples:

>>> POI_obj = POI(...)
>>> filtered_POIs = POI_obj.filter_points_inside_shape()
Source code in TPTBox/core/poi.py
def filter_points_inside_shape(self, inplace=False) -> Self:
    """Filter out POI points that are outside the defined shape.

    This method checks each POI point and removes any point whose coordinates
    are outside the defined shape.

    Returns:
        POI: A new POI object containing POI points that are inside the defined shape.

    Examples:
        >>> POI_obj = POI(...)
        >>> filtered_POIs = POI_obj.filter_points_inside_shape()
    """
    if self.shape is None:
        raise ValueError("Cannot filter points outside shape as the shape attribute is not defined.")

    filtered_centroids = POI_Descriptor()
    for region, subregion, (x, y, z) in self.centroids.items():
        if 0 <= x < self.shape[0] and 0 <= y < self.shape[1] and 0 <= z < self.shape[2]:
            filtered_centroids[(region, subregion)] = (x, y, z)
    if inplace:
        self.centroids = filtered_centroids
        return self
    return self.copy(filtered_centroids)

load classmethod

load(poi: POI_Reference, reference: Has_Grid | None = None, allow_global=False) -> POI

Load a Centroids object from various input sources.

This method provides a convenient way to load a Centroids object from different sources, including BIDS files, file paths, image references, or existing POI objects.

Parameters:

Name Type Description Default
poi Centroid_Reference

The input source from which to load the Centroids object. It can be one of the following types: - BIDS_FILE: A BIDS file representing the Centroids object. - Path: The path to the file containing the Centroids object. - str: The string representation of the Centroids object file path. - Tuple[Image_Reference, Image_Reference, list[int]]: A tuple containing two Image_Reference objects and a list of integers representing the POI data. - POI: An existing POI object to be loaded.

required

Returns:

Name Type Description
POI POI

The loaded Centroids object.

Examples:

>>> # Load from a BIDS file
>>> bids_file_path = BIDS_FILE("/path/to/POIs.json", "/path/to/dataset/")
>>> loaded_poi = POI.load(bids_file_path)
>>> # Load from a file path
>>> file_path = "/path/to/POIs.json"
>>> loaded_poi = POI.load(file_path)
>>> # Load from an image reference tuple and POI data
>>> image_ref1 = Image_Reference(...)
>>> image_ref2 = Image_Reference(...)
>>> POI_data = [1, 2, 3]
>>> loaded_poi = POI.load((image_ref1, image_ref2, POI_data))
>>> # Load from an existing POI object
>>> existing_poi = POI(...)
>>> loaded_poi = POI.load(existing_poi)
Source code in TPTBox/core/poi.py
@classmethod
def load(cls, poi: POI_Reference, reference: Has_Grid | None = None, allow_global=False) -> POI:
    """Load a Centroids object from various input sources.

    This method provides a convenient way to load a Centroids object from different sources,
    including BIDS files, file paths, image references, or existing POI objects.

    Args:
        poi (Centroid_Reference): The input source from which to load the Centroids object.
            It can be one of the following types:
            - BIDS_FILE: A BIDS file representing the Centroids object.
            - Path: The path to the file containing the Centroids object.
            - str: The string representation of the Centroids object file path.
            - Tuple[Image_Reference, Image_Reference, list[int]]: A tuple containing two Image_Reference objects
            and a list of integers representing the POI data.
            - POI: An existing POI object to be loaded.

    Returns:
        POI: The loaded Centroids object.

    Examples:
        >>> # Load from a BIDS file
        >>> bids_file_path = BIDS_FILE("/path/to/POIs.json", "/path/to/dataset/")
        >>> loaded_poi = POI.load(bids_file_path)

        >>> # Load from a file path
        >>> file_path = "/path/to/POIs.json"
        >>> loaded_poi = POI.load(file_path)

        >>> # Load from an image reference tuple and POI data
        >>> image_ref1 = Image_Reference(...)
        >>> image_ref2 = Image_Reference(...)
        >>> POI_data = [1, 2, 3]
        >>> loaded_poi = POI.load((image_ref1, image_ref2, POI_data))

        >>> # Load from an existing POI object
        >>> existing_poi = POI(...)
        >>> loaded_poi = POI.load(existing_poi)
    """
    from TPTBox import POI_Global

    poi_obj: POI = poi if isinstance(poi, (POI, POI_Global)) else save_load.load_poi(poi)  # type: ignore
    if reference is not None:
        if isinstance(poi_obj, POI_Global):
            poi_obj = poi_obj.resample_from_to(reference)
        else:
            if poi_obj.orientation == ("U", "U", "U"):
                poi_obj.orientation = reference.orientation
            if poi_obj.spacing is None:
                poi_obj.spacing = reference.spacing
            if poi_obj.rotation is None:
                poi_obj.rotation = reference.rotation
            if poi_obj.shape is None:
                poi_obj.shape = reference.shape
            if poi_obj.origin is None:
                poi_obj.origin = reference.origin
            reference.assert_affine(poi_obj, shape_tolerance=0.001)
    if isinstance(poi_obj, POI_Global) and not allow_global:
        warnings.warn(
            f"{poi} is a POI with global coordinates, but you loaded it with POI.load(), \n"
            + "Use POI_Global.load() if you want to load a POI_Global \n"
            + "Use reference=... to resample the global POI to a Grid \n"
            + "or allow_global = True if you want allow a mix of POI and POI_Global\n",
            UserWarning,
            stacklevel=4,
        )
    return poi_obj  # type: ignore

POI_Global

TPTBox.core.poi_fun.poi_global.POI_Global

Bases: Abstract_POI

POI container stored in world (mm) coordinates rather than voxel space.

Extends :class:~TPTBox.core.poi_fun.poi_abstract.Abstract_POI with coordinate-system conversion methods.

Source code in TPTBox/core/poi_fun/poi_global.py
class POI_Global(Abstract_POI):
    """POI container stored in world (mm) coordinates rather than voxel space.

    Extends :class:`~TPTBox.core.poi_fun.poi_abstract.Abstract_POI` with coordinate-system
    conversion methods.
    """

    def __init__(
        self,
        input_poi: poi.POI | POI_Descriptor | dict[str, dict[str, tuple[float, ...]]] = None,
        itk_coords: bool = False,
        level_one_info: type[Abstract_lvl] | None = None,  # Must be Enum and must has order_dict
        level_two_info: type[Abstract_lvl] | None = None,
        info: dict | None = None,
    ):
        if input_poi is None:
            input_poi = {}
        args = {}
        if level_one_info is not None:
            args["level_one_info"] = level_one_info
        if level_one_info is not None:
            args["level_two_info"] = level_two_info
        self.itk_coords = itk_coords
        _format = FORMAT_GLOBAL
        if isinstance(input_poi, dict):
            global_points = POI_Descriptor()
            for k1, d1 in input_poi.items():
                for k2, v in d1.items():
                    global_points[k1:k2] = v
        elif isinstance(input_poi, POI_Descriptor):
            global_points = input_poi

        elif isinstance(input_poi, poi.POI):
            local_poi = input_poi.copy()
            global_points = poi.POI_Descriptor(definition=local_poi.centroids.definition)
            for k1, k2, v in local_poi.items():
                global_points[k1:k2] = local_poi.local_to_global(v, itk_coords)
            info = input_poi.info.copy()
            _format = input_poi.format
        else:
            raise NotImplementedError(type(input_poi))
        if info is None:
            info = {}

        super().__init__(_centroids=global_points, format=_format, info=info, **args)

    def __str__(self) -> str:
        return str(self._centroids)

    @property
    def zoom(self) -> tuple[int, int, int]:
        """Always returns ``(1, 1, 1)`` — global POIs are in mm so zoom is unity."""
        return (1, 1, 1)

    @property
    def origin(self) -> tuple[int, int, int]:
        """Always returns ``(0, 0, 0)`` — global POIs use a world origin."""
        return (0, 0, 0)

    @property
    def orientation(self) -> tuple[str, str, str]:
        """Return the axis-code orientation for the active coordinate system.

        Returns:
            ``("L", "A", "S")`` for ITK/LPS coordinates, ``("R", "P", "S")``
            for NIfTI/RAS coordinates.
        """
        if self.itk_coords:
            return ("L", "A", "S")
        return ("R", "P", "S")

    @property
    def is_global(self) -> bool:
        """Check if the POI is global.

        Returns:
            bool: True if the POI is global, False otherwise.
        """
        return True

    def to_other_nii(self, ref: poi.Image_Reference) -> poi.POI | poi.NII:
        """Convert the POI to another NII file.

        Args:
            ref (poi.Image_Reference): The reference to the NII file.

        Returns:
            Union[poi.POI, poi.NII]: The converted POI as either a POI or NII object.
        """
        return self.to_other(poi.to_nii(ref))

    def to_other_poi(self, ref: poi.POI | Self) -> poi.POI | Self | None:
        """Convert the POI to another POI.

        Args:
            ref (poi.Centroid_Reference): The reference to the other POI.

        Returns:
            poi.POI: The converted POI.
        """
        p = poi.POI.load(ref)
        if isinstance(ref, poi.POI):
            return self.to_other(p)
        elif isinstance(ref, Self):
            return self.to_cord_system(ref.itk_coords)

    def to_global(self, itk_coords: bool | None = None) -> Self:
        """Return this object unchanged (already in global coordinates)."""
        return self.to_cord_system(itk_coords) if itk_coords is not None else self.copy()

    def to_local(self, msk: Has_Grid) -> poi.POI:
        """Convert this global POI to the voxel space of ``msk``.

        Args:
            msk: Reference grid (``NII`` or ``POI``) defining the target affine.

        Returns:
            ``POI`` in the local voxel coordinate system of ``msk``.
        """
        return self.resample_from_to(msk)

    def resample_from_to(self, msk: Has_Grid) -> poi.POI:
        """Alias for :meth:`to_local` / :meth:`to_other`.

        Args:
            msk: Reference grid defining the target affine.

        Returns:
            ``POI`` in the local coordinate system of ``msk``.
        """
        return self.to_other(msk)

    def to_cord_system(self, itk_coords: bool, inplace: bool = False) -> Self:
        """Convert between ITK (LPS) and NIfTI (RAS) coordinate systems.

        Flips the first two coordinate axes when switching between the two
        systems (LPS ↔ RAS only differs in the sign of x and y).

        Args:
            itk_coords: ``True`` for ITK/LPS output, ``False`` for NIfTI/RAS.
            inplace: Convert in place.  Defaults to ``False``.

        Returns:
            ``POI_Global`` in the requested coordinate system.
        """
        out = self if inplace else self.copy()
        if self.itk_coords == itk_coords:
            return out
        out.itk_coords = itk_coords
        for k1, k2, v in self.items():
            out[k1, k2] = (-v[0], -v[1], v[2])
        return out

    def to_other(self, msk: Has_Grid, verbose=False) -> poi.POI:
        """Convert the POI to another coordinate system.

        Args:
            msk (Union[poi.POI, poi.NII]): The reference to the other coordinate system.

        Returns:
            poi.POI: The converted POI.
        """
        out = poi.POI_Descriptor(definition=self._get_centroids().definition)
        for k1, k2, v in self.items():
            if self.itk_coords:
                assert len(v) == 3, "n-d vec not implemented for n != 3"
                v = (-v[0], -v[1], v[2])  # noqa: PLW2901
            v_out = msk.global_to_local(v)
            if verbose:
                log.print(v, "-->", v_out)
            out[k1, k2] = tuple(v_out)

        return poi.POI(centroids=out, **msk._extract_affine(), info=self.info, format=self.format)

    def copy(self, centroids: POI_Descriptor | None = None) -> Self:
        """Return a deep copy of this ``POI_Global``.

        Args:
            centroids: Optional replacement ``POI_Descriptor``.  When ``None``
                the current centroids are deep-copied.

        Returns:
            New ``POI_Global`` with the same metadata as this object.
        """
        if centroids is None:
            centroids = self.centroids.copy()
        p = POI_Global(centroids)
        p.level_one_info = self.level_one_info
        p.level_two_info = self.level_two_info
        p.format = self.format
        p.info = deepcopy(self.info)
        p.itk_coords = self.itk_coords
        return p  # type: ignore

    @classmethod
    def load(cls, poi: poi.POI_Reference, itk_coords: bool | None = None) -> Self:
        """Load a ``POI_Global`` from a file or POI reference.

        Args:
            poi: Path to a JSON or ``.mrk.json`` file, or any supported
                ``POI_Reference`` type.
            itk_coords: When ``None`` the coordinate system is inferred from
                the file.  When ``True`` or ``False``, the loaded POI is
                asserted to match.

        Returns:
            ``POI_Global`` in the requested coordinate system.

        Raises:
            AssertionError: If ``itk_coords`` is set and does not match the
                file's coordinate system.
        """
        poi_obj = load_poi(poi)

        if not poi_obj.is_global or itk_coords is not None:
            poi_obj = poi_obj.to_global(itk_coords if itk_coords is not None else False)  # type: ignore
        return poi_obj  # type: ignore

    def save(
        self,
        out_path: str | Path,
        make_parents: bool = False,
        additional_info: dict | None = None,
        save_hint: int = FORMAT_GLOBAL,
        resample_reference: Has_Grid | None = None,
        verbose: logging = True,
    ) -> None:
        """Save this ``POI_Global`` to a JSON file.

        Args:
            out_path: Output path (must end with ``.json``).
            make_parents: Create parent directories if missing.
                Defaults to ``False``.
            additional_info: Extra key-value pairs added to the file header.
            save_hint: Format identifier.  Defaults to ``FORMAT_GLOBAL``.
            resample_reference: When set, convert to local coordinates of this
                grid before saving.
            verbose: Emit a save log message.  Defaults to ``True``.
        """
        return save_poi(
            self, out_path, make_parents, additional_info, save_hint=save_hint, resample_reference=resample_reference, verbose=verbose
        )

    def save_mrk(
        self: Self,
        filepath: str | Path,
        color: list[float] | None = None,
        split_by_region: bool = False,
        split_by_subregion: bool = False,
        add_points: bool = True,
        add_lines: list[save_mkr.MKR_Lines] | None = None,
        display: save_mkr.MKR_Display | dict = None,  # type: ignore
        pointLabelsVisibility: bool = False,
        glyphScale: float = 5.0,
        main_key: str = "Point",
    ) -> None:
        """Save this ``POI_Global`` as a 3D Slicer ``.mrk.json`` markup file.

        Delegates to :func:`~TPTBox.core.poi_fun.save_mkr._save_mrk`.

        Args:
            filepath: Output path.  The extension is forced to ``.mrk.json``.
            color: Default group colour (RGB in ``[0, 1]`` range).
            split_by_region: Separate markup group per region.
                Defaults to ``False``.
            split_by_subregion: Separate markup group per subregion.
                Defaults to ``False``.
            add_points: Include Fiducial markups.  Defaults to ``True``.
            add_lines: Optional ``MKR_Lines`` definitions to add as line
                markups.
            display: Base display property overrides.
            pointLabelsVisibility: Show point labels in the 3D view.
                Defaults to ``False``.
            glyphScale: Glyph size factor.  Defaults to ``5.0``.
            main_key: Base markup group key.  Defaults to ``"Point"``.
        """
        save_mkr._save_mrk(
            poi=self,
            filepath=filepath,
            color=color,
            split_by_region=split_by_region,
            split_by_subregion=split_by_subregion,
            add_points=add_points,
            add_lines=add_lines,
            display=display,
            pointLabelsVisibility=pointLabelsVisibility,
            glyphScale=glyphScale,
            main_key=main_key,
        )

zoom property

zoom: tuple[int, int, int]

Always returns (1, 1, 1) — global POIs are in mm so zoom is unity.

origin property

origin: tuple[int, int, int]

Always returns (0, 0, 0) — global POIs use a world origin.

orientation property

orientation: tuple[str, str, str]

Return the axis-code orientation for the active coordinate system.

Returns:

Type Description
str

("L", "A", "S") for ITK/LPS coordinates, ("R", "P", "S")

str

for NIfTI/RAS coordinates.

is_global property

is_global: bool

Check if the POI is global.

Returns:

Name Type Description
bool bool

True if the POI is global, False otherwise.

to_other_nii

to_other_nii(ref: Image_Reference) -> poi.POI | poi.NII

Convert the POI to another NII file.

Parameters:

Name Type Description Default
ref Image_Reference

The reference to the NII file.

required

Returns:

Type Description
POI | NII

Union[poi.POI, poi.NII]: The converted POI as either a POI or NII object.

Source code in TPTBox/core/poi_fun/poi_global.py
def to_other_nii(self, ref: poi.Image_Reference) -> poi.POI | poi.NII:
    """Convert the POI to another NII file.

    Args:
        ref (poi.Image_Reference): The reference to the NII file.

    Returns:
        Union[poi.POI, poi.NII]: The converted POI as either a POI or NII object.
    """
    return self.to_other(poi.to_nii(ref))

to_other_poi

to_other_poi(ref: POI | Self) -> poi.POI | Self | None

Convert the POI to another POI.

Parameters:

Name Type Description Default
ref Centroid_Reference

The reference to the other POI.

required

Returns:

Type Description
POI | Self | None

poi.POI: The converted POI.

Source code in TPTBox/core/poi_fun/poi_global.py
def to_other_poi(self, ref: poi.POI | Self) -> poi.POI | Self | None:
    """Convert the POI to another POI.

    Args:
        ref (poi.Centroid_Reference): The reference to the other POI.

    Returns:
        poi.POI: The converted POI.
    """
    p = poi.POI.load(ref)
    if isinstance(ref, poi.POI):
        return self.to_other(p)
    elif isinstance(ref, Self):
        return self.to_cord_system(ref.itk_coords)

to_global

to_global(itk_coords: bool | None = None) -> Self

Return this object unchanged (already in global coordinates).

Source code in TPTBox/core/poi_fun/poi_global.py
def to_global(self, itk_coords: bool | None = None) -> Self:
    """Return this object unchanged (already in global coordinates)."""
    return self.to_cord_system(itk_coords) if itk_coords is not None else self.copy()

to_local

to_local(msk: Has_Grid) -> poi.POI

Convert this global POI to the voxel space of msk.

Parameters:

Name Type Description Default
msk Has_Grid

Reference grid (NII or POI) defining the target affine.

required

Returns:

Type Description
POI

POI in the local voxel coordinate system of msk.

Source code in TPTBox/core/poi_fun/poi_global.py
def to_local(self, msk: Has_Grid) -> poi.POI:
    """Convert this global POI to the voxel space of ``msk``.

    Args:
        msk: Reference grid (``NII`` or ``POI``) defining the target affine.

    Returns:
        ``POI`` in the local voxel coordinate system of ``msk``.
    """
    return self.resample_from_to(msk)

resample_from_to

resample_from_to(msk: Has_Grid) -> poi.POI

Alias for :meth:to_local / :meth:to_other.

Parameters:

Name Type Description Default
msk Has_Grid

Reference grid defining the target affine.

required

Returns:

Type Description
POI

POI in the local coordinate system of msk.

Source code in TPTBox/core/poi_fun/poi_global.py
def resample_from_to(self, msk: Has_Grid) -> poi.POI:
    """Alias for :meth:`to_local` / :meth:`to_other`.

    Args:
        msk: Reference grid defining the target affine.

    Returns:
        ``POI`` in the local coordinate system of ``msk``.
    """
    return self.to_other(msk)

to_cord_system

to_cord_system(itk_coords: bool, inplace: bool = False) -> Self

Convert between ITK (LPS) and NIfTI (RAS) coordinate systems.

Flips the first two coordinate axes when switching between the two systems (LPS ↔ RAS only differs in the sign of x and y).

Parameters:

Name Type Description Default
itk_coords bool

True for ITK/LPS output, False for NIfTI/RAS.

required
inplace bool

Convert in place. Defaults to False.

False

Returns:

Type Description
Self

POI_Global in the requested coordinate system.

Source code in TPTBox/core/poi_fun/poi_global.py
def to_cord_system(self, itk_coords: bool, inplace: bool = False) -> Self:
    """Convert between ITK (LPS) and NIfTI (RAS) coordinate systems.

    Flips the first two coordinate axes when switching between the two
    systems (LPS ↔ RAS only differs in the sign of x and y).

    Args:
        itk_coords: ``True`` for ITK/LPS output, ``False`` for NIfTI/RAS.
        inplace: Convert in place.  Defaults to ``False``.

    Returns:
        ``POI_Global`` in the requested coordinate system.
    """
    out = self if inplace else self.copy()
    if self.itk_coords == itk_coords:
        return out
    out.itk_coords = itk_coords
    for k1, k2, v in self.items():
        out[k1, k2] = (-v[0], -v[1], v[2])
    return out

to_other

to_other(msk: Has_Grid, verbose=False) -> poi.POI

Convert the POI to another coordinate system.

Parameters:

Name Type Description Default
msk Union[POI, NII]

The reference to the other coordinate system.

required

Returns:

Type Description
POI

poi.POI: The converted POI.

Source code in TPTBox/core/poi_fun/poi_global.py
def to_other(self, msk: Has_Grid, verbose=False) -> poi.POI:
    """Convert the POI to another coordinate system.

    Args:
        msk (Union[poi.POI, poi.NII]): The reference to the other coordinate system.

    Returns:
        poi.POI: The converted POI.
    """
    out = poi.POI_Descriptor(definition=self._get_centroids().definition)
    for k1, k2, v in self.items():
        if self.itk_coords:
            assert len(v) == 3, "n-d vec not implemented for n != 3"
            v = (-v[0], -v[1], v[2])  # noqa: PLW2901
        v_out = msk.global_to_local(v)
        if verbose:
            log.print(v, "-->", v_out)
        out[k1, k2] = tuple(v_out)

    return poi.POI(centroids=out, **msk._extract_affine(), info=self.info, format=self.format)

copy

copy(centroids: POI_Descriptor | None = None) -> Self

Return a deep copy of this POI_Global.

Parameters:

Name Type Description Default
centroids POI_Descriptor | None

Optional replacement POI_Descriptor. When None the current centroids are deep-copied.

None

Returns:

Type Description
Self

New POI_Global with the same metadata as this object.

Source code in TPTBox/core/poi_fun/poi_global.py
def copy(self, centroids: POI_Descriptor | None = None) -> Self:
    """Return a deep copy of this ``POI_Global``.

    Args:
        centroids: Optional replacement ``POI_Descriptor``.  When ``None``
            the current centroids are deep-copied.

    Returns:
        New ``POI_Global`` with the same metadata as this object.
    """
    if centroids is None:
        centroids = self.centroids.copy()
    p = POI_Global(centroids)
    p.level_one_info = self.level_one_info
    p.level_two_info = self.level_two_info
    p.format = self.format
    p.info = deepcopy(self.info)
    p.itk_coords = self.itk_coords
    return p  # type: ignore

load classmethod

load(poi: POI_Reference, itk_coords: bool | None = None) -> Self

Load a POI_Global from a file or POI reference.

Parameters:

Name Type Description Default
poi POI_Reference

Path to a JSON or .mrk.json file, or any supported POI_Reference type.

required
itk_coords bool | None

When None the coordinate system is inferred from the file. When True or False, the loaded POI is asserted to match.

None

Returns:

Type Description
Self

POI_Global in the requested coordinate system.

Raises:

Type Description
AssertionError

If itk_coords is set and does not match the file's coordinate system.

Source code in TPTBox/core/poi_fun/poi_global.py
@classmethod
def load(cls, poi: poi.POI_Reference, itk_coords: bool | None = None) -> Self:
    """Load a ``POI_Global`` from a file or POI reference.

    Args:
        poi: Path to a JSON or ``.mrk.json`` file, or any supported
            ``POI_Reference`` type.
        itk_coords: When ``None`` the coordinate system is inferred from
            the file.  When ``True`` or ``False``, the loaded POI is
            asserted to match.

    Returns:
        ``POI_Global`` in the requested coordinate system.

    Raises:
        AssertionError: If ``itk_coords`` is set and does not match the
            file's coordinate system.
    """
    poi_obj = load_poi(poi)

    if not poi_obj.is_global or itk_coords is not None:
        poi_obj = poi_obj.to_global(itk_coords if itk_coords is not None else False)  # type: ignore
    return poi_obj  # type: ignore

save

save(out_path: str | Path, make_parents: bool = False, additional_info: dict | None = None, save_hint: int = FORMAT_GLOBAL, resample_reference: Has_Grid | None = None, verbose: logging = True) -> None

Save this POI_Global to a JSON file.

Parameters:

Name Type Description Default
out_path str | Path

Output path (must end with .json).

required
make_parents bool

Create parent directories if missing. Defaults to False.

False
additional_info dict | None

Extra key-value pairs added to the file header.

None
save_hint int

Format identifier. Defaults to FORMAT_GLOBAL.

FORMAT_GLOBAL
resample_reference Has_Grid | None

When set, convert to local coordinates of this grid before saving.

None
verbose logging

Emit a save log message. Defaults to True.

True
Source code in TPTBox/core/poi_fun/poi_global.py
def save(
    self,
    out_path: str | Path,
    make_parents: bool = False,
    additional_info: dict | None = None,
    save_hint: int = FORMAT_GLOBAL,
    resample_reference: Has_Grid | None = None,
    verbose: logging = True,
) -> None:
    """Save this ``POI_Global`` to a JSON file.

    Args:
        out_path: Output path (must end with ``.json``).
        make_parents: Create parent directories if missing.
            Defaults to ``False``.
        additional_info: Extra key-value pairs added to the file header.
        save_hint: Format identifier.  Defaults to ``FORMAT_GLOBAL``.
        resample_reference: When set, convert to local coordinates of this
            grid before saving.
        verbose: Emit a save log message.  Defaults to ``True``.
    """
    return save_poi(
        self, out_path, make_parents, additional_info, save_hint=save_hint, resample_reference=resample_reference, verbose=verbose
    )

save_mrk

save_mrk(filepath: str | Path, color: list[float] | None = None, split_by_region: bool = False, split_by_subregion: bool = False, add_points: bool = True, add_lines: list[MKR_Lines] | None = None, display: MKR_Display | dict = None, pointLabelsVisibility: bool = False, glyphScale: float = 5.0, main_key: str = 'Point') -> None

Save this POI_Global as a 3D Slicer .mrk.json markup file.

Delegates to :func:~TPTBox.core.poi_fun.save_mkr._save_mrk.

Parameters:

Name Type Description Default
filepath str | Path

Output path. The extension is forced to .mrk.json.

required
color list[float] | None

Default group colour (RGB in [0, 1] range).

None
split_by_region bool

Separate markup group per region. Defaults to False.

False
split_by_subregion bool

Separate markup group per subregion. Defaults to False.

False
add_points bool

Include Fiducial markups. Defaults to True.

True
add_lines list[MKR_Lines] | None

Optional MKR_Lines definitions to add as line markups.

None
display MKR_Display | dict

Base display property overrides.

None
pointLabelsVisibility bool

Show point labels in the 3D view. Defaults to False.

False
glyphScale float

Glyph size factor. Defaults to 5.0.

5.0
main_key str

Base markup group key. Defaults to "Point".

'Point'
Source code in TPTBox/core/poi_fun/poi_global.py
def save_mrk(
    self: Self,
    filepath: str | Path,
    color: list[float] | None = None,
    split_by_region: bool = False,
    split_by_subregion: bool = False,
    add_points: bool = True,
    add_lines: list[save_mkr.MKR_Lines] | None = None,
    display: save_mkr.MKR_Display | dict = None,  # type: ignore
    pointLabelsVisibility: bool = False,
    glyphScale: float = 5.0,
    main_key: str = "Point",
) -> None:
    """Save this ``POI_Global`` as a 3D Slicer ``.mrk.json`` markup file.

    Delegates to :func:`~TPTBox.core.poi_fun.save_mkr._save_mrk`.

    Args:
        filepath: Output path.  The extension is forced to ``.mrk.json``.
        color: Default group colour (RGB in ``[0, 1]`` range).
        split_by_region: Separate markup group per region.
            Defaults to ``False``.
        split_by_subregion: Separate markup group per subregion.
            Defaults to ``False``.
        add_points: Include Fiducial markups.  Defaults to ``True``.
        add_lines: Optional ``MKR_Lines`` definitions to add as line
            markups.
        display: Base display property overrides.
        pointLabelsVisibility: Show point labels in the 3D view.
            Defaults to ``False``.
        glyphScale: Glyph size factor.  Defaults to ``5.0``.
        main_key: Base markup group key.  Defaults to ``"Point"``.
    """
    save_mkr._save_mrk(
        poi=self,
        filepath=filepath,
        color=color,
        split_by_region=split_by_region,
        split_by_subregion=split_by_subregion,
        add_points=add_points,
        add_lines=add_lines,
        display=display,
        pointLabelsVisibility=pointLabelsVisibility,
        glyphScale=glyphScale,
        main_key=main_key,
    )

Helper functions

TPTBox.core.poi

calc_centroids

calc_centroids(msk: Image_Reference, decimals=3, first_stage: int | Abstract_lvl = -1, second_stage: int | Abstract_lvl = 50, extend_to: POI | None = None, inplace: bool = False, bar=False, _crop=True) -> POI

Calculates the centroid coordinates of each region in the given mask image.

Parameters:

Name Type Description Default
msk Image_Reference

An Image_Reference object representing the input mask image.

required
decimals int

An optional integer specifying the number of decimal places to round the centroid coordinates to (default is 3).

3
vert_id int

An optional integer specifying the fixed vertical dimension for the centroids (default is -1).

required
subreg_id int

An optional integer specifying the fixed subregion dimension for the centroids (default is 50).

required
extend_to POI

An optional POI object to add the calculated centroids to (default is None).

None

Returns:

Name Type Description
POI POI

A POI object containing the calculated centroid coordinates.

Raises:

Type Description
AssertionError

If the extend_to object has a different orientation, location, or zoom than the input mask.

Notes
  • The function calculates the centroid coordinates of each region in the mask image.
  • The centroid coordinates are rounded to the specified number of decimal places.
  • The fixed dimensions for the centroids can be specified using vert_id and subreg_id.
  • If extend_to is provided, the calculated centroids will be added to the existing object and the updated object will be returned.
  • The region label is assumed to be an integer.
  • NaN values in the binary mask are ignored.
Source code in TPTBox/core/poi.py
def calc_centroids(
    msk: Image_Reference,
    decimals=3,
    first_stage: int | Abstract_lvl = -1,
    second_stage: int | Abstract_lvl = 50,
    extend_to: POI | None = None,
    inplace: bool = False,
    bar=False,
    _crop=True,
) -> POI:
    """Calculates the centroid coordinates of each region in the given mask image.

    Args:
        msk (Image_Reference): An `Image_Reference` object representing the input mask image.
        decimals (int, optional): An optional integer specifying the number of decimal places to round the centroid coordinates to (default is 3).
        vert_id (int, optional): An optional integer specifying the fixed vertical dimension for the centroids (default is -1).
        subreg_id (int, optional): An optional integer specifying the fixed subregion dimension for the centroids (default is 50).
        extend_to (POI, optional): An optional `POI` object to add the calculated centroids to (default is None).

    Returns:
        POI: A `POI` object containing the calculated centroid coordinates.

    Raises:
        AssertionError: If the `extend_to` object has a different orientation, location, or zoom than the input mask.

    Notes:
        - The function calculates the centroid coordinates of each region in the mask image.
        - The centroid coordinates are rounded to the specified number of decimal places.
        - The fixed dimensions for the centroids can be specified using `vert_id` and `subreg_id`.
        - If `extend_to` is provided, the calculated centroids will be added to the existing object and the updated object will be returned.
        - The region label is assumed to be an integer.
        - NaN values in the binary mask are ignored.
    """
    args = {}
    if isinstance(second_stage, Abstract_lvl):
        second_stage = second_stage.value
        args["level_two_info"] = type(second_stage)
    if isinstance(first_stage, Abstract_lvl):
        first_stage = first_stage.value
        args["level_one_info"] = type(first_stage)
    assert first_stage == -1 or second_stage == -1, "first or second dimension must be fixed."
    msk_nii = to_nii(msk, seg=True)
    msk_data = msk_nii.get_seg_array()
    if extend_to is None:
        ctd_list = POI_Descriptor()
    else:
        if not inplace:
            extend_to = extend_to.copy()
        ctd_list = extend_to.centroids
        extend_to.assert_affine(msk_nii, shape_tolerance=1, origin_tolerance=1)
    u = msk_nii.unique()
    if bar:
        from tqdm import tqdm

        u = tqdm(u)
    for i in u:
        if _crop:
            # TODO test implementation and remove old
            m = msk_nii.extract_label(i)
            crop = m.compute_crop()
            m2: NII = m[crop]
            ctr_mass: Sequence[float] = center_of_mass(m2.get_seg_array())  # type: ignore
            out_coord = tuple(round(x + crop.start, decimals) for x, crop in zip(ctr_mass, crop))
        else:
            # OLD
            msk_temp = np.zeros(msk_data.shape, dtype=bool)
            msk_temp[msk_data == i] = True
            ctr_mass: Sequence[float] = center_of_mass(msk_temp)  # type: ignore
            out_coord = tuple(round(x, decimals) for x in ctr_mass)

        if second_stage == -1:
            ctd_list[first_stage, int(i)] = out_coord
        else:
            ctd_list[int(i), second_stage] = out_coord
    return POI(ctd_list, **msk_nii._extract_affine(), **args)

calc_poi_from_subreg_vert

calc_poi_from_subreg_vert(vert: Image_Reference, subreg: Image_Reference, *, buffer_file: str | Path | None = None, save_buffer_file=False, decimals=2, subreg_id: int | Abstract_lvl | Sequence[int | Abstract_lvl] | Sequence[Abstract_lvl] | Sequence[int] = 50, verbose: logging = False, extend_to: POI | None = None, _vert_ids: list[int] | None = None, _print_phases=False, _orientation_version=0) -> POI

Calculates the POIs of a subregion within a vertebral mask. This function is spine opinionated, the general implementation is "calc_poi_from_two_masks".

Parameters:

Name Type Description Default
vert_msk Image_Reference

A vertebral mask image reference.

required
subreg Image_Reference

An image reference for the subregion of interest.

required
decimals int

Number of decimal places to round the output coordinates to. Defaults to 1.

2
subreg_id int | Location | list[int | Location]

The ID(s) of the subregion(s) to calculate POIs for. Defaults to 50.

50
axcodes_to Ax_Codes | None

A tuple of axis codes indicating the target orientation of the images. Defaults to None.

required
verbose bool

Whether to print progress messages. Defaults to False.

False
fixed_offset int

A fixed offset value to add to the calculated POI coordinates. Defaults to 0.

required
extend_to POI | None

An existing POI object to extend with the new POI values. Defaults to None.

None

Returns:

Name Type Description
POI POI

A POI object containing the calculated POI coordinates.

Source code in TPTBox/core/poi.py
@_buffer_it
def calc_poi_from_subreg_vert(
    vert: Image_Reference,
    subreg: Image_Reference,
    *,
    buffer_file: str | Path | None = None,  # used by wrapper  # noqa: ARG001
    save_buffer_file=False,  # used by wrapper  # noqa: ARG001
    decimals=2,
    subreg_id: int | Abstract_lvl | Sequence[int | Abstract_lvl] | Sequence[Abstract_lvl] | Sequence[int] = 50,
    verbose: logging = False,
    extend_to: POI | None = None,
    # use_vertebra_special_action=True,
    _vert_ids: list[int] | None = None,
    _print_phases=False,
    _orientation_version=0,
) -> POI:
    """Calculates the POIs of a subregion within a vertebral mask. This function is spine opinionated, the general implementation is "calc_poi_from_two_masks".

    Args:
        vert_msk (Image_Reference): A vertebral mask image reference.
        subreg (Image_Reference): An image reference for the subregion of interest.
        decimals (int, optional): Number of decimal places to round the output coordinates to. Defaults to 1.
        subreg_id (int | Location | list[int | Location], optional): The ID(s) of the subregion(s) to calculate POIs for. Defaults to 50.
        axcodes_to (Ax_Codes | None, optional): A tuple of axis codes indicating the target orientation of the images. Defaults to None.
        verbose (bool, optional): Whether to print progress messages. Defaults to False.
        fixed_offset (int, optional): A fixed offset value to add to the calculated POI coordinates. Defaults to 0.
        extend_to (POI | None, optional): An existing POI object to extend with the new POI values. Defaults to None.

    Returns:
        POI: A POI object containing the calculated POI coordinates.
    """
    vert_msk = to_nii(vert, seg=True)
    subreg_msk = to_nii(subreg, seg=True)
    org_shape = subreg_msk.shape
    try:
        crop = vert_msk.compute_crop()
        crop = subreg_msk.compute_crop(maximum_size=crop)
    # crop = (slice(0, subreg_msk.shape[0]), slice(0, subreg_msk.shape[1]), slice(0, subreg_msk.shape[2]))
    except ValueError:
        return POI({}, **vert_msk._extract_affine(), format=save_load.FORMAT_POI) if extend_to is None else extend_to.copy()
    vert_msk.assert_affine(subreg_msk)
    vert_msk = vert_msk.apply_crop(crop)
    subreg_msk = subreg_msk.apply_crop(crop)
    extend_to = (
        POI(
            {},
            **vert_msk._extract_affine(),
            format=save_load.FORMAT_POI,
            level_one_info=Vertebra_Instance,
            level_two_info=Location,
        )
        if extend_to is None
        else extend_to.apply_crop(crop, inplace=True)
    )

    if _vert_ids is None:
        _vert_ids = vert_msk.unique()

    from TPTBox.core.poi_fun.vertebra_pois_non_centroids import (  # noqa: PLC0415
        add_prerequisites,
        compute_non_centroid_pois,
    )

    subreg_id = add_prerequisites(_int2loc(subreg_id if isinstance(subreg_id, Sequence) else [subreg_id]))  # type: ignore

    log.print("Calc centroids from subregion id", subreg_id, vert_msk.shape, verbose=verbose)
    subreg_id_int = set(_loc2int_list(subreg_id))
    subreg_id_int_phase_1 = tuple(
        filter(
            lambda i: i < 53 and i not in [Location.Vertebra_Full.value, Location.Dens_axis.value],
            subreg_id_int,
        )
    )
    # Step 1 get all required locations, crop vert/subreg
    # Step 2 calc centroids

    print("step 2", subreg_id_int) if _print_phases else None
    if len(subreg_id_int_phase_1) != 0:
        arr = vert_msk.get_array()
        arr[arr >= 100] = 0
        vert_only_bone = vert_msk.set_array(arr)
        arr = subreg_msk.get_array()
        # if use_vertebra_special_action:
        arr[arr == 49] = Location.Vertebra_Corpus.value
        subreg_msk = subreg_msk.set_array(arr)
        extend_to = calc_centroids_from_two_masks(
            vert_only_bone,
            subreg_msk,
            decimals=decimals,
            limit_ids_of_lvl_2=subreg_id_int_phase_1,
            verbose=verbose if isinstance(verbose, bool) else True,
            extend_to=extend_to,
        )
        [subreg_id_int.remove(i) for i in subreg_id_int_phase_1]
    # Step 3 Vertebra_Full
    print("step 3", subreg_id_int) if _print_phases else None
    if Location.Vertebra_Full.value in subreg_id_int:
        log.print("Calc centroid from subregion id", "Vertebra_Full", verbose=verbose)
        full = Location.Vertebra_Full.value
        vert_arr = vert_msk.get_seg_array()
        if _is_not_yet_computed((full,), extend_to, full):
            arr = vert_arr.copy()
            arr[arr >= v_name2idx["Cocc"]] = 0
            # arr[arr >= Location.Vertebra_Corpus.value] = 0
            # arr[arr != 0] = full
            extend_to = calc_centroids(vert_msk.set_array(arr), decimals=decimals, second_stage=full, extend_to=extend_to, inplace=True)
        subreg_id_int.remove(full)
    # Step 4 IVD / Endplates Superior / Endplate Inferior
    print("step 4", subreg_id_int) if _print_phases else None
    mapping_vert = {
        Location.Vertebra_Disc.value: 100,
        Location.Vertebral_Body_Endplate_Superior.value: 200,
        Location.Vertebral_Body_Endplate_Inferior.value: 300,
    }
    for loc, v in mapping_vert.items():
        if loc in subreg_id_int:
            log.print("Calc centroid from subregion id", Location(loc).name, verbose=verbose)
            vert_arr = vert_msk.get_seg_array()
            subreg_arr = subreg_msk.get_seg_array()
            # IVD / Endplates Superior / Endplate Inferior
            vert_arr[subreg_arr != loc] = 0
            # remove a off set of 100/200/300 and remove other that are not of interest
            vert_arr[vert_arr >= v + 100] = 0
            vert_arr[vert_arr < v] = 0
            vert_arr[vert_arr != 0] -= v
            extend_to = calc_centroids(
                vert_msk.set_array(vert_arr),
                decimals=decimals,
                second_stage=v,
                extend_to=extend_to,
                inplace=True,
            )
            subreg_id_int.remove(loc)
    # Step 5 call non_centroid_pois
    # Prepare mask to binary mask
    print("step 5", subreg_id_int) if _print_phases else None
    vert_arr = vert_msk.get_seg_array()
    subreg_arr = subreg_msk.get_seg_array()
    assert subreg_msk.shape == vert_arr.shape, "Shape miss-match" + str(subreg_msk.shape) + str(vert_arr.shape)
    vert_arr[subreg_arr >= 100] = 0
    subreg_arr[subreg_arr >= 100] = 0

    if extend_to is None:
        extend_to = POI({}, **vert_msk._extract_affine(), format=save_load.FORMAT_POI)
    if len(subreg_id_int) != 0:
        # print("step 6", subreg_id_int)
        compute_non_centroid_pois(
            extend_to,
            _int2loc(list(subreg_id_int)),  # type: ignore
            vert_msk,
            subreg_msk,
            _vert_ids=_vert_ids,
            log=log,
            verbose=verbose,
            _orientation_version=_orientation_version,
        )
    extend_to.apply_crop_reverse(crop, org_shape, inplace=True)
    return extend_to

calc_poi_from_two_segs

calc_poi_from_two_segs(msk_reference: Image_Reference, subreg_reference: Image_Reference | None, out_path: Path | str, subreg_id: int | Abstract_lvl | Sequence[int | Abstract_lvl] | None = None, verbose=True, override=False, decimals=3, check_every_point=True) -> POI

Compute centroids of a mask within each subregion and optionally save/load from file.

If out_path is None and msk_reference is a :class:~TPTBox.BIDS_FILE, a path is generated automatically from its label attribute and subreg_id.

If subreg_reference is None, the function computes the centroids using only msk_reference.

If subreg_reference is not None, the function computes the centroids with respect to the given subreg_id in the subregion defined by subreg_reference.

Parameters:

Name Type Description Default
msk_reference Image_Reference

The mask to compute the centroids from.

required
subreg_reference Image_Reference | None

The subregion mask to compute the centroids relative to.

required
out_path Path | None

The path to save the computed centroids to.

required
subreg_id int | Location | list[int | Location]

The ID of the subregion to compute centroids in.

None
verbose bool

Whether to print verbose output during the computation.

True
override bool

Whether to overwrite any existing centroids file at out_path.

False
decimals int

The number of decimal places to round the computed centroid coordinates to.

3
additional_folder bool

Whether to add a /ctd/ folder to the path generated for the output file.

required

Returns:

Name Type Description
Centroids POI

The computed centroids, as a Centroids object.

Source code in TPTBox/core/poi.py
def calc_poi_from_two_segs(
    msk_reference: Image_Reference,
    subreg_reference: Image_Reference | None,
    out_path: Path | str,
    subreg_id: int | Abstract_lvl | Sequence[int | Abstract_lvl] | None = None,
    verbose=True,
    override=False,
    decimals=3,
    # additional_folder=False,
    check_every_point=True,
    # use_vertebra_special_action=True,
) -> POI:
    """Compute centroids of a mask within each subregion and optionally save/load from file.

    If ``out_path`` is ``None`` and ``msk_reference`` is a :class:`~TPTBox.BIDS_FILE`, a path is
    generated automatically from its label attribute and ``subreg_id``.

    If `subreg_reference` is None, the function computes the centroids using only `msk_reference`.

    If `subreg_reference` is not None, the function computes the centroids with respect to the given `subreg_id` in the
    subregion defined by `subreg_reference`.

    Args:
        msk_reference (Image_Reference): The mask to compute the centroids from.
        subreg_reference (Image_Reference | None, optional): The subregion mask to compute the centroids relative to.
        out_path (Path | None, optional): The path to save the computed centroids to.
        subreg_id (int | Location | list[int | Location], optional): The ID of the subregion to compute centroids in.
        verbose (bool, optional): Whether to print verbose output during the computation.
        override (bool, optional): Whether to overwrite any existing centroids file at `out_path`.
        decimals (int, optional): The number of decimal places to round the computed centroid coordinates to.
        additional_folder (bool, optional): Whether to add a `/ctd/` folder to the path generated for the output file.

    Returns:
        Centroids: The computed centroids, as a `Centroids` object.
    """
    assert out_path is not None, "Automatic path generation is deprecated"
    out_path = Path(out_path)
    # assert out_path is not None or isinstance(
    #    msk_reference, BIDS.bids_files.BIDS_FILE
    # ), "Automatic path generation is only possible with a BIDS_FILE"
    # if out_path is None and isinstance(msk_reference, BIDS.bids_files.BIDS_FILE):
    #    if not isinstance(subreg_id, list) and subreg_id != -1:
    #        name = subreg_idx2name[loc2int(subreg_id)]
    #    elif subreg_reference is None:
    #        name = msk_reference.get("label", default="full")
    #    else:
    #        name = "multi"
    #    assert name is not None
    #    out_path = msk_reference.get_changed_path(
    #        file_type="json",
    #        format="ctd",
    #        info={"label": name.replace("_", "-")},
    #        parent="derivatives" if msk_reference.get_parent() == "rawdata" else msk_reference.get_parent(),
    #        additional_folder="ctd" if additional_folder else None,
    #    )
    assert out_path is not None
    if override:
        out_path.unlink()
    log.print(f"[*] Generate ctd json towards {out_path}", verbose=verbose)

    msk_nii = to_nii(msk_reference, True)
    sub_nii = to_nii_optional(subreg_reference, True)
    if (sub_nii is None or not check_every_point) and out_path.exists():
        return POI.load(out_path)
    if subreg_id is None:
        assert sub_nii is not None
        subreg_id = sub_nii.unique()
    if sub_nii is not None:
        ctd = calc_poi_from_subreg_vert(
            msk_nii,
            sub_nii,
            buffer_file=out_path,
            decimals=decimals,
            subreg_id=subreg_id,
            verbose=verbose,
        )
    else:
        assert not isinstance(subreg_id, Sequence), "Missing instance+semantic map for multiple Values"
        ctd = calc_centroids(msk_nii, second_stage=_loc2int(subreg_id), decimals=decimals)

    ctd.save(out_path, verbose=verbose)
    return ctd