diff --git a/src/highdicom/spatial.py b/src/highdicom/spatial.py index 54a64925..d5fab854 100644 --- a/src/highdicom/spatial.py +++ b/src/highdicom/spatial.py @@ -917,3 +917,45 @@ def map_coordinate_into_pixel_matrix( round(pixel_matrix_coordinates[1]), round(pixel_matrix_coordinates[2]), ) + + +def are_points_coplanar( + points: np.ndarray, + tol: float = 1e-5, +) -> bool: + """Check whether a set of 3D points are coplanar (to within a tolerance). + + Parameters + ---------- + points: np.ndarray + Numpy array of shape (n x 3) containing 3D points. + tol: float + Tolerance on the distance of the furthest point from the plane of best + fit. + + Returns + ------- + bool: + True if the points are coplanar within a tolerance tol, False + otherwise. Note that if n < 4, points are always coplanar. + + """ + if points.ndim != 2 or points.shape[1] != 3: + raise ValueError("Array should have shape (n x 3).") + + n = points.shape[0] + if n < 4: + # Any set of three or fewer points is coplanar + return True + + # Center points by subtracting mean + c = np.mean(points, axis=0, keepdims=True) + points_centered = points - c + + # Use a SVD to determine the normal of the plane of best fit, then + # find maximum deviation from it + u, _, _ = np.linalg.svd(points_centered.T) + normal = u[:, -1] + deviations = normal.T @ points_centered.T + max_dev = np.abs(deviations).max() + return max_dev <= tol diff --git a/src/highdicom/sr/value_types.py b/src/highdicom/sr/value_types.py index 5969ffe4..25ea70a5 100644 --- a/src/highdicom/sr/value_types.py +++ b/src/highdicom/sr/value_types.py @@ -23,6 +23,7 @@ from pydicom.sr.coding import Code from pydicom.valuerep import DA, DS, TM, DT, PersonName +from highdicom.spatial import are_points_coplanar from highdicom.sr.coding import CodedConcept from highdicom.sr.enum import ( GraphicTypeValues, @@ -1763,6 +1764,25 @@ def __init__( '(x, y, z) triplets in three-dimensional patient or ' 'slide coordinate space.' ) + if graphic_type == GraphicTypeValues3D.POLYGON: + if not np.array_equal(graphic_data[0], graphic_data[-1]): + raise ValueError( + 'Graphic data of a 3D scoord of graphic type "POLYGON" ' + 'must be closed, i.e. the first and last points must ' + 'be equal.' + ) + + # Check for coplanarity, if required by the graphic type + if graphic_type in ( + GraphicTypeValues3D.POLYGON, + GraphicTypeValues3D.ELLIPSE, + ): + if not are_points_coplanar(graphic_data): + raise ValueError( + 'Graphic data of a 3D scoord of type ' + f'"{graphic_type.value}" must contain co-planar points.' + ) + # Flatten list of coordinate triplets self.GraphicData = graphic_data.flatten().tolist() self.ReferencedFrameOfReferenceUID = frame_of_reference_uid diff --git a/tests/test_sr.py b/tests/test_sr.py index d4b94aaf..280bf323 100644 --- a/tests/test_sr.py +++ b/tests/test_sr.py @@ -1168,6 +1168,38 @@ def test_scoord3d_item_construction_polygon(self): with pytest.raises(AttributeError): i.FiducialUID + def test_scoord3d_item_construction_non_closed_polygon(self): + name = codes.DCM.ImageRegion + graphic_type = GraphicTypeValues3D.POLYGON + graphic_data = np.array([ + [1.0, 1.0, 1.0], [2.0, 2.0, 1.0], [1.0, 2.0, 1.0] # non-closed + ]) + frame_of_reference_uid = '1.2.3' + with pytest.raises(ValueError): + Scoord3DContentItem( + name=name, + graphic_type=graphic_type, + graphic_data=graphic_data, + frame_of_reference_uid=frame_of_reference_uid, + relationship_type=RelationshipTypeValues.INFERRED_FROM + ) + + def test_scoord3d_item_construction_non_coplanar_polygon(self): + name = codes.DCM.ImageRegion + graphic_type = GraphicTypeValues3D.POLYGON + graphic_data = np.array([ + [1.0, 1.0, 1.0], [2.0, 2.0, 1.0], [1.0, 1.0, 3.0] # non-coplanr + ]) + frame_of_reference_uid = '1.2.3' + with pytest.raises(ValueError): + Scoord3DContentItem( + name=name, + graphic_type=graphic_type, + graphic_data=graphic_data, + frame_of_reference_uid=frame_of_reference_uid, + relationship_type=RelationshipTypeValues.INFERRED_FROM + ) + def test_container_item_from_dataset(self): code_name_ds = _build_coded_concept_dataset(codes.DCM.Finding) code_value_ds = _build_coded_concept_dataset(codes.SCT.Neoplasm)