Skip to content

Commit

Permalink
Merge pull request #276 from ImagingDataCommons/sr_polygon_check
Browse files Browse the repository at this point in the history
Further graphic data checks
  • Loading branch information
CPBridge authored Mar 28, 2024
2 parents 1672988 + 25661f6 commit 445a259
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 0 deletions.
42 changes: 42 additions & 0 deletions src/highdicom/spatial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions src/highdicom/sr/value_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions tests/test_sr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 445a259

Please sign in to comment.