Repository: ImagingDataCommons/highdicom

Issue:
Add SCOORD to a Measurement in SR (TID 320)
Hi, is it possible to add spatial coordinates to a Measurement in SR?

I'm looking at [TID 320 Image or Spatial Coordinates](https://dicom.nema.org/medical/dicom/current/output/chtml/part16/chapter_A.html#sect_TID_320), specifically row 3 and 4.

If it's not currently possible, I could work on a PR to add it.

But I could use some pointers on how to best implement it.
As I understand, the `referenced_images` parameter in `sr.Measurement` implements the first row of TID 320 (inferred from image).
What's the best solution to add the other options? Should a new parameter in `sr.Measurement` be added for each one? Or the `referenced_images` type could be changed to `Optional[Sequence[SourceImageForMeasurement | SourceScoordForMeasurement]]`?

Discussion:

Patch:
diff --git a/docs/installation.rst b/docs/installation.rst
index 37c9424..7eaecc0 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -16,7 +16,7 @@ Requirements
 Installation
 ------------
 
-Pre-built package available at PyPi:
+Pre-build package available at PyPi:
 
 .. code-block:: none
 
diff --git a/src/highdicom/sr/__init__.py b/src/highdicom/sr/__init__.py
index e116f31..4d1c31a 100644
--- a/src/highdicom/sr/__init__.py
+++ b/src/highdicom/sr/__init__.py
@@ -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,
@@ -104,6 +106,8 @@ __all__ = [
     'ContentItem',
     'ContentSequence',
     'ContentSequence',
+    'CoordinatesForMeasurement',
+    'CoordinatesForMeasurement3D',
     'DateContentItem',
     'DateTimeContentItem',
     'DeviceObserverIdentifyingAttributes',
diff --git a/src/highdicom/sr/content.py b/src/highdicom/sr/content.py
index fc6a85b..f6c7b75 100644
--- a/src/highdicom/sr/content.py
+++ b/src/highdicom/sr/content.py
@@ -443,11 +443,7 @@ class SourceImageForRegion(ImageContentItem):
                     '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,
@@ -707,6 +703,137 @@ class SourceSeriesForSegmentation(UIDRefContentItem):
         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,
+            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
diff --git a/src/highdicom/sr/templates.py b/src/highdicom/sr/templates.py
index 4424d4d..42b5e6d 100644
--- a/src/highdicom/sr/templates.py
+++ b/src/highdicom/sr/templates.py
@@ -10,6 +10,8 @@ from pydicom.sr.codedict import codes
 
 from highdicom.sr.coding import CodedConcept
 from highdicom.sr.content import (
+    CoordinatesForMeasurement,
+    CoordinatesForMeasurement3D,
     FindingSite,
     LongitudinalTemporalOffsetFromEvent,
     ImageRegion,
@@ -43,6 +45,7 @@ from highdicom.sr.value_types import (
     ImageContentItem,
     NumContentItem,
     PnameContentItem,
+    Scoord3DContentItem,
     TextContentItem,
     UIDRefContentItem,
 )
@@ -448,7 +451,7 @@ def _contains_uidref_items(
 
 def _contains_image_items(
     parent_item: ContentItem,
-    name: Union[Code, CodedConcept, None],
+    name: Union[Code, CodedConcept],
     referenced_sop_class_uid: Union[str, None] = None,
     referenced_sop_instance_uid: Union[str, None] = None,
     relationship_type: Optional[RelationshipTypeValues] = None
@@ -459,7 +462,7 @@ def _contains_image_items(
     ----------
     parent_item: highdicom.sr.ContentItem
         Parent SR Content Item
-    name: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None]
+    name: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code]
         Name of the child SR Content Item
     referenced_sop_class_uid: Union[str, None], optional
         SOP Class UID referenced by the content item
@@ -2095,10 +2098,8 @@ class SubjectContextDevice(Template):
             ('manufacturer_name', codes.DCM.DeviceSubjectManufacturer),
             ('model_name', codes.DCM.DeviceSubjectModelName),
             ('serial_number', codes.DCM.DeviceSubjectSerialNumber),
-            (
-                'physical_location',
-                codes.DCM.DeviceSubjectPhysicalLocationDuringObservation
-            ),
+            ('physical_location',
+             codes.DCM.DeviceSubjectPhysicalLocationDuringObservation),
         ]
         kwargs = {}
         for dataset in sequence:
@@ -2389,6 +2390,7 @@ class Measurement(Template):
         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
     ):
         """
@@ -2433,6 +2435,11 @@ class Measurement(Template):
             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
 
@@ -2503,6 +2510,14 @@ class Measurement(Template):
                         '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):
@@ -2622,6 +2637,24 @@ class Measurement(Template):
         )
         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"""
@@ -3287,11 +3320,17 @@ class _ROIMeasurementsAndQualitativeEvaluations(
                     '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(
-    _ROIMeasurementsAndQualitativeEvaluations
-):
+        _ROIMeasurementsAndQualitativeEvaluations):
 
     """:dcm:`TID 1410 <part16/chapter_A.html#sect_TID_1410>`
      Planar ROI Measurements and Qualitative Evaluations"""
@@ -3305,7 +3344,9 @@ class PlanarROIMeasurementsAndQualitativeEvaluations(
     def __init__(
         self,
         tracking_identifier: TrackingIdentifier,
-        referenced_region: Union[ImageRegion, ImageRegion3D, None],
+        referenced_region: Optional[
+            Union[ImageRegion, ImageRegion3D]
+        ] = None,
         referenced_segment: Optional[ReferencedSegmentationFrame] = None,
         referenced_real_world_value_map: Optional[RealWorldValueMap] = None,
         time_point_context: Optional[TimePointContext] = None,
@@ -3571,8 +3612,7 @@ class PlanarROIMeasurementsAndQualitativeEvaluations(
 
 
 class VolumetricROIMeasurementsAndQualitativeEvaluations(
-    _ROIMeasurementsAndQualitativeEvaluations
-):
+        _ROIMeasurementsAndQualitativeEvaluations):
 
     """:dcm:`TID 1411 <part16/chapter_A.html#sect_TID_1411>`
      Volumetric ROI Measurements and Qualitative Evaluations"""
@@ -3587,7 +3627,9 @@ class VolumetricROIMeasurementsAndQualitativeEvaluations(
     def __init__(
         self,
         tracking_identifier: TrackingIdentifier,
-        referenced_regions: Optional[Sequence[ImageRegion]] = None,
+        referenced_regions: Optional[
+            Union[Sequence[ImageRegion]]
+        ] = None,
         referenced_volume_surface: Optional[VolumeSurface] = None,
         referenced_segment: Optional[ReferencedSegment] = None,
         referenced_real_world_value_map: Optional[RealWorldValueMap] = None,


Test patch:
diff --git a/tests/test_sr.py b/tests/test_sr.py
index e774d4a..4f8bc79 100644
--- a/tests/test_sr.py
+++ b/tests/test_sr.py
@@ -16,15 +16,16 @@ from pydicom.uid import generate_uid
 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,
@@ -75,6 +76,7 @@ from highdicom.sr import (
     VolumetricROIMeasurementsAndQualitativeEvaluations,
     srread,
 )
+from highdicom.sr.content import CoordinatesForMeasurement
 from highdicom.sr.utils import find_content_items
 from highdicom import UID
 
@@ -2141,6 +2143,31 @@ class TestSourceImageForMeasurement(unittest.TestCase):
             )
 
 
+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):
@@ -2807,6 +2834,30 @@ class TestMeasurement(unittest.TestCase):
             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
+        assert isinstance(measurement.referenced_coordinates[0], CoordinatesForMeasurement)
+        assert isinstance(measurement.referenced_coordinates[1], CoordinatesForMeasurement3D)
+
 
 class TestQualitativeEvaluation(unittest.TestCase):
 
@@ -3068,6 +3119,24 @@ class TestPlanarROIMeasurementsAndQualitativeEvaluations(unittest.TestCase):
                 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],
+            )
+
 
 class TestVolumetricROIMeasurementsAndQualitativeEvaluations(unittest.TestCase):