From ef2a6b0c5e792ea6459660c6e712b867affffbad Mon Sep 17 00:00:00 2001 From: kyinhub Date: Sat, 25 Jul 2026 11:08:04 -0700 Subject: [PATCH] fix: reject non-invertible volume affines Signed-off-by: kyinhub --- src/highdicom/volume.py | 28 +++++++++++++++++++--------- tests/test_volume.py | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index ac8f294f..a999137c 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -272,9 +272,10 @@ def __init__( affine: numpy.ndarray 4 x 4 affine matrix representing the transformation from voxel indices to the frame-of-reference coordinate system. The top left 3 - x 3 matrix should be a scaled orthogonal matrix representing the - rotation and scaling. The top right 3 x 1 vector represents the - translation component. The last row should have value [0, 0, 0, 1]. + x 3 matrix must be an invertible, scaled orthogonal matrix + representing the rotation and scaling. The top right 3 x 1 vector + represents the translation component. The last row should have + value [0, 0, 0, 1]. coordinate_system: highdicom.CoordinateSystemNames | str Coordinate system (``"PATIENT"`` or ``"SLIDE"``) in which the volume is defined. @@ -330,6 +331,13 @@ def __init__( to_reference_convention=LPS_PATIENT_REFERENCE_CONVENTION, ) + try: + np.linalg.inv(affine) + except np.linalg.LinAlgError as error: + raise ValueError( + "Argument 'affine' must be invertible." + ) from error + self._affine = affine self._frame_of_reference_uid = frame_of_reference_uid @@ -1990,9 +1998,10 @@ def __init__( 4 x 4 affine matrix representing the transformation from pixel indices (slice index, row index, column index) to the frame-of-reference coordinate system. The top left 3 x 3 matrix - should be a scaled orthogonal matrix representing the rotation and - scaling. The top right 3 x 1 vector represents the translation - component. The last row should have value [0, 0, 0, 1]. + must be an invertible, scaled orthogonal matrix representing the + rotation and scaling. The top right 3 x 1 vector represents the + translation component. The last row should have value + [0, 0, 0, 1]. spatial_shape: Sequence[int] Number of voxels in the (implied) volume along the three spatial dimensions. @@ -2477,9 +2486,10 @@ def __init__( 4 x 4 affine matrix representing the transformation from pixel indices (slice index, row index, column index) to the frame-of-reference coordinate system. The top left 3 x 3 matrix - should be a scaled orthogonal matrix representing the rotation and - scaling. The top right 3 x 1 vector represents the translation - component. The last row should have value [0, 0, 0, 1]. + must be an invertible, scaled orthogonal matrix representing the + rotation and scaling. The top right 3 x 1 vector represents the + translation component. The last row should have value + [0, 0, 0, 1]. coordinate_system: highdicom.CoordinateSystemNames | str Coordinate system (``"PATIENT"`` or ``"SLIDE"``) in which the volume is defined. diff --git a/tests/test_volume.py b/tests/test_volume.py index 04648ae6..02a86f2c 100644 --- a/tests/test_volume.py +++ b/tests/test_volume.py @@ -52,6 +52,26 @@ def read_ct_series_volume(): return get_volume_from_series(ct_series), ct_series +@pytest.mark.parametrize('volume_type', [Volume, VolumeGeometry]) +def test_reject_singular_affine(volume_type): + affine = np.eye(4) + # Keep the dependent column non-zero so that the approximate + # orthogonality check alone does not reject the affine. + affine[:3, 2] = affine[:3, 0] * 1e-6 + + kwargs = { + 'affine': affine, + 'coordinate_system': "PATIENT", + } + if volume_type is Volume: + kwargs['array'] = np.zeros((2, 2, 2)) + else: + kwargs['spatial_shape'] = (2, 2, 2) + + with pytest.raises(ValueError, match="affine.*invertible"): + volume_type(**kwargs) + + def test_transforms(): array = np.zeros((25, 50, 50)) orientation = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]