Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Further graphic data checks #276

Merged
merged 4 commits into from
Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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