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

Add coordinate for Measurement in SR #307

Merged
4 changes: 4 additions & 0 deletions src/highdicom/sr/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Package for creation of Structured Report (SR) instances."""
from highdicom.sr.coding import CodedConcept
from highdicom.sr.content import (
CoordinatesForMeasurement,
CoordinatesForMeasurement3D,
FindingSite,
ImageRegion,
ImageRegion3D,
Expand Down Expand Up @@ -104,6 +106,8 @@
'ContentItem',
'ContentSequence',
'ContentSequence',
'CoordinatesForMeasurement',
'CoordinatesForMeasurement3D',
'DateContentItem',
'DateTimeContentItem',
'DeviceObserverIdentifyingAttributes',
Expand Down
137 changes: 132 additions & 5 deletions src/highdicom/sr/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,11 +443,7 @@ def __init__(
'1-based.'
)
super().__init__(
name=CodedConcept(
value='111040',
meaning='Original Source',
scheme_designator='DCM'
),
name=codes.SCT.Source,
referenced_sop_class_uid=referenced_sop_class_uid,
referenced_sop_instance_uid=referenced_sop_instance_uid,
referenced_frame_numbers=referenced_frame_numbers,
Expand Down Expand Up @@ -707,6 +703,137 @@ def from_dataset(
return cast(SourceSeriesForSegmentation, item)


class CoordinatesForMeasurement(ScoordContentItem):

"""Content item representing spatial coordinates of a measurement"""

def __init__(
self,
graphic_type: Union[GraphicTypeValues, str],
graphic_data: np.ndarray,
source_image: SourceImageForRegion,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has nothing to do with ImageRegion, but I reused SourceImageForRegion here as it needs to be an image with SELECTED FROM relationship. Not sure if there's a better option.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting question. SourceImageForRegion pretty much matches the requirements of the ImageContentItem that would be used here, but the concept name used may pose a problem.

The concept name is blank in table TID 320, but the highdicom content item class does not allow "unnamed" content items (this issue arises in a few places and we made this design decision fairly early on). SourceImageForRegion uses the concept name (DCM, 111040, Original Source), whereas it states at the bottom of TID 320 that

(260753009, SCT, "Source") may be be used as a generic Concept Name when there is a desire to avoid having an anonymous (unnamed) Content Item

so we should follow that here, which implies that SourceImageForRegion as it is currently written would not be appropriate.

However, looking at the other places that SourceImageForRegion is used (basically row six of TID 1410 and TID 1411), I notice that the same suggestion to use SCT 260753009 is given there. I am not sure why SourceImageForRegion uses DCM 111040, it appears that this goes all the way back to the beginning of the library. Perhaps @hackermd can shed some light

Given this, I favour changing SourceImageForRegion to use SCT 260753009 and then using SourceImageForRegion as you propose for TID 320.

purpose: Union[CodedConcept, Code] = codes.SCT.Source,
) -> None:
"""
Parameters
----------
purpose: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code]
Concept name
graphic_type: Union[highdicom.sr.GraphicTypeValues, str]
Name of the graphic type
graphic_data: numpy.ndarray
Array of ordered spatial coordinates, where each row of the array
represents a (Column,Row) pair
source_image: highdicom.sr.SourceImageForRegion
Source image to which `graphic_data` relates
"""
graphic_type = GraphicTypeValues(graphic_type)
super().__init__(
name=purpose,
graphic_type=graphic_type,
graphic_data=graphic_data,
relationship_type=RelationshipTypeValues.INFERRED_FROM,
)
self.ContentSequence = [source_image]

@classmethod
def from_dataset(
cls,
dataset: Dataset,
copy: bool = True,
) -> 'CoordinatesForMeasurement':
"""Construct object from an existing dataset.

Parameters
----------
dataset: pydicom.dataset.Dataset
Dataset representing an SR Content Item with value type SCOORD
copy: bool
If True, the underlying dataset is deep-copied such that the
original dataset remains intact. If False, this operation will
alter the original dataset in place.

Returns
-------
highdicom.sr.CoordinatesForMeasurement
Constructed object

"""
if copy:
dataset_copy = deepcopy(dataset)
else:
dataset_copy = dataset
item = super()._from_dataset_base(dataset_copy)
return cast(cls, item)


class CoordinatesForMeasurement3D(Scoord3DContentItem):
"""Content item representing spatial 3D coordinates of a measurement"""

def __init__(
self,
graphic_type: Union[GraphicTypeValues3D, str],
graphic_data: np.ndarray,
frame_of_reference_uid: Union[str, UID],
fiducial_uid: Optional[Union[str, UID]] = None,
purpose: Union[CodedConcept, Code] = codes.SCT.Source,
):
"""
Parameters
----------
purpose: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code]
Concept name
graphic_type: Union[highdicom.sr.GraphicTypeValues3D, str]
Name of the graphic type
graphic_data: numpy.ndarray[numpy.float]
Array of spatial coordinates, where each row of the array
represents a (x, y, z) coordinate triplet
frame_of_reference_uid: Union[highdicom.UID, str]
Unique identifier of the frame of reference within which the
coordinates are defined
fiducial_uid: Union[str, None], optional
Unique identifier for the content item
"""
super().__init__(
name=purpose,
relationship_type=RelationshipTypeValues.INFERRED_FROM,
graphic_type=graphic_type,
graphic_data=graphic_data,
frame_of_reference_uid=frame_of_reference_uid,
fiducial_uid=fiducial_uid,
)

@classmethod
def from_dataset(
cls,
dataset: Dataset,
copy: bool = True,
) -> 'CoordinatesForMeasurement3D':
"""Construct object from an existing dataset.

Parameters
----------
dataset: pydicom.dataset.Dataset
Dataset representing an SR Content Item with value type SCOORD3D
copy: bool
If True, the underlying dataset is deep-copied such that the
original dataset remains intact. If False, this operation will
alter the original dataset in place.

Returns
-------
highdicom.sr.CoordinatesForMeasurement3D
Constructed object

"""
if copy:
dataset_copy = deepcopy(dataset)
else:
dataset_copy = dataset
item = super()._from_dataset_base(dataset_copy)
return cast(cls, item)


class ImageRegion(ScoordContentItem):

"""Content item representing an image region of interest in the
Expand Down
42 changes: 42 additions & 0 deletions src/highdicom/sr/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

from highdicom.sr.coding import CodedConcept
from highdicom.sr.content import (
CoordinatesForMeasurement,
CPBridge marked this conversation as resolved.
Show resolved Hide resolved
CoordinatesForMeasurement3D,
FindingSite,
LongitudinalTemporalOffsetFromEvent,
ImageRegion,
Expand Down Expand Up @@ -43,6 +45,7 @@
ImageContentItem,
NumContentItem,
PnameContentItem,
Scoord3DContentItem,
TextContentItem,
UIDRefContentItem,
)
Expand Down Expand Up @@ -2387,6 +2390,7 @@ def __init__(
method: Optional[Union[CodedConcept, Code]] = None,
properties: Optional[MeasurementProperties] = None,
referenced_images: Optional[Sequence[SourceImageForMeasurement]] = None,
referenced_coordinates: Optional[Sequence[Union[CoordinatesForMeasurement, CoordinatesForMeasurement3D]]] = None,
referenced_real_world_value_map: Optional[RealWorldValueMap] = None
):
"""
Expand Down Expand Up @@ -2431,6 +2435,11 @@ def __init__(
and an indication of its selection from a set of measurements
referenced_images: Union[Sequence[highdicom.sr.SourceImageForMeasurement], None], optional
Referenced images which were used as sources for the measurement
referenced_coordinates: Union[Sequence[Union[highdicom.sr.CoordinatesForMeasurement, highdicom.sr.CoordinatesForMeasurement3D]], None], optional
Referenced coordinates for the measurement.
Measurements with referenced coordinates are not valid to be used with
`PlanarROIMeasurementsAndQualitativeEvaluations` or
`VolumetricROIMeasurementsAndQualitativeEvaluations`
referenced_real_world_value_map: Union[highdicom.sr.RealWorldValueMap, None], optional
Referenced real world value map for referenced source images

Expand Down Expand Up @@ -2501,6 +2510,14 @@ def __init__(
'SourceImageForMeasurement.'
)
content.append(image)
if referenced_coordinates is not None:
for scoord in referenced_coordinates:
if not isinstance(scoord, (CoordinatesForMeasurement, Scoord3DContentItem)):
raise TypeError(
'Arguments "referenced_coordinates" must have type '
'CoordinatesForMeasurement or Scoord3DContentItem.'
)
content.append(scoord)
if referenced_real_world_value_map is not None:
if not isinstance(referenced_real_world_value_map,
RealWorldValueMap):
Expand Down Expand Up @@ -2620,6 +2637,24 @@ def referenced_images(self) -> List[SourceImageForMeasurement]:
)
return [SourceImageForMeasurement.from_dataset(m) for m in matches]

@property
def referenced_coordinates(self) -> List[Union[CoordinatesForMeasurement, CoordinatesForMeasurement3D]]:
"""List[Union[highdicom.sr.CoordinatesForMeasurement, highdicom.sr.CoordinatesForMeasurement3D]]:
referenced coordinates"""
if not hasattr(self[0], 'ContentSequence'):
return []
scoord_matches = find_content_items(
self[0],
value_type=ValueTypeValues.SCOORD
)
coord = [CoordinatesForMeasurement.from_dataset(m) for m in scoord_matches]
scoord3d_matches = find_content_items(
self[0],
value_type=ValueTypeValues.SCOORD3D
)
coord.extend([CoordinatesForMeasurement3D.from_dataset(m) for m in scoord3d_matches])
return coord

@property
def finding_sites(self) -> List[FindingSite]:
"""List[highdicom.sr.FindingSite]: finding sites"""
Expand Down Expand Up @@ -3285,6 +3320,13 @@ def __init__(
'ReferencedSegmentationFrame.'
)
group_item.ContentSequence.extend(referenced_segment)
if measurements is not None:
for measurement in measurements:
if measurement.referenced_coordinates:
raise ValueError(
'Referenced coordinates in measurements are not allowed in '
f'{self.__class__.__name__}.'
)


class PlanarROIMeasurementsAndQualitativeEvaluations(
Expand Down
71 changes: 70 additions & 1 deletion tests/test_sr.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@
from pydicom.valuerep import DA, DS, DT, TM, PersonName
from pydicom.uid import SegmentationStorage

from highdicom.sr import CodedConcept
from highdicom.sr import (
AlgorithmIdentification,
CodeContentItem,
CodedConcept,
CompositeContentItem,
Comprehensive3DSR,
ComprehensiveSR,
ContainerContentItem,
ContentSequence,
CoordinatesForMeasurement3D,
DateContentItem,
DateTimeContentItem,
DeviceObserverIdentifyingAttributes,
Expand Down Expand Up @@ -75,6 +76,7 @@
VolumetricROIMeasurementsAndQualitativeEvaluations,
srread,
)
from highdicom.sr.content import CoordinatesForMeasurement
from highdicom.sr.utils import find_content_items
from highdicom import UID

Expand Down Expand Up @@ -2141,6 +2143,31 @@ def test_from_invalid_source_image_seg(self):
)


class TestCoordinatesForMeasurement(unittest.TestCase):

def setUp(self):
super().setUp()
self.source_image = SourceImageForRegion(
referenced_sop_class_uid='1.2.840.10008.5.1.4.1.1.2',
referenced_sop_instance_uid='1.2.3.4.5.6.7.8.9.10'
)

def test_construction(self):
item = CoordinatesForMeasurement(
graphic_type=GraphicTypeValues.MULTIPOINT,
graphic_data=np.array([[10, 20], [20, 30]]),
source_image=self.source_image
)

assert item.graphic_type == GraphicTypeValues.MULTIPOINT
assert item.GraphicData == [10, 20, 20, 30]
assert item.relationship_type == RelationshipTypeValues.INFERRED_FROM

assert len(item.ContentSequence) == 1
assert item.ContentSequence[0] == self.source_image
assert item.ContentSequence[0].relationship_type == RelationshipTypeValues.SELECTED_FROM


class TestReferencedSegment(unittest.TestCase):

def setUp(self):
Expand Down Expand Up @@ -2807,6 +2834,30 @@ def test_construction_with_optional_parameters(self):
assert isinstance(retrieved, SourceImageForMeasurement)
assert retrieved == original

def test_referenced_coordinates(self):
scoord = CoordinatesForMeasurement(
graphic_type="POINT",
graphic_data=np.array([[50.0, 50.0]]),
source_image=self._image
)
scoord_3d = CoordinatesForMeasurement3D(
graphic_type="POINT",
graphic_data=np.array([[50.0, 50.0, 30.0]]),
frame_of_reference_uid="1.2.3.4.5.6.7.8.9"
)
measurement = Measurement(
name=self._name,
value=self._value,
unit=self._unit,
referenced_coordinates=[scoord, scoord_3d],
)

assert len(measurement.referenced_coordinates) == 2
assert scoord in measurement.referenced_coordinates
assert scoord_3d in measurement.referenced_coordinates
CPBridge marked this conversation as resolved.
Show resolved Hide resolved
assert isinstance(measurement.referenced_coordinates[0], CoordinatesForMeasurement)
assert isinstance(measurement.referenced_coordinates[1], CoordinatesForMeasurement3D)


class TestQualitativeEvaluation(unittest.TestCase):

Expand Down Expand Up @@ -3068,6 +3119,24 @@ def test_constructed_with_multiple_references(self):
referenced_segment=self._segment
)

def test_constructed_with_measurements_coordinates(self):
measurement = Measurement(
name=codes.SCT.Diameter,
value=5,
unit=codes.UCUM.Millimeter,
referenced_coordinates=[CoordinatesForMeasurement(
graphic_type=GraphicTypeValues.POLYLINE,
graphic_data=np.array([[1, 1], [2, 2]]),
source_image=self._image_for_region
)]
)
with pytest.raises(ValueError) as exc:
PlanarROIMeasurementsAndQualitativeEvaluations(
tracking_identifier=self._tracking_identifier,
referenced_region=self._region,
measurements=[measurement],
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test somewhere that the new referenced_coordinates property works as expected, and returns the right number of items and correct type?



class TestVolumetricROIMeasurementsAndQualitativeEvaluations(unittest.TestCase):

Expand Down