diff --git a/src/highdicom/sr/__init__.py b/src/highdicom/sr/__init__.py index 4d1c31a9..039813e8 100644 --- a/src/highdicom/sr/__init__.py +++ b/src/highdicom/sr/__init__.py @@ -33,10 +33,13 @@ ) from highdicom.sr.templates import ( AlgorithmIdentification, + CircumpapillaryRNFLKeyMeasurements, DeviceObserverIdentifyingAttributes, ImageLibrary, ImageLibraryEntryDescriptors, LanguageOfContentItemAndDescendants, + MacularMeasurementsGroup, + MacularThicknessKeyMeasurements, Measurement, MeasurementProperties, MeasurementReport, @@ -45,9 +48,12 @@ NormalRangeProperties, ObserverContext, ObservationContext, + OphthalmologyMeasurementsGroup, PersonObserverIdentifyingAttributes, PlanarROIMeasurementsAndQualitativeEvaluations, QualitativeEvaluation, + RNFLClockfaceMeasurementsGroup, + RNFLSectorMeasurementsGroup, SubjectContext, SubjectContextDevice, SubjectContextFetus, @@ -97,6 +103,7 @@ __all__ = [ 'AlgorithmIdentification', + 'CircumpapillaryRNFLKeyMeasurements', 'CodeContentItem', 'CodedConcept', 'CompositeContentItem', @@ -122,6 +129,8 @@ 'ImageRegion3D', 'LanguageOfContentItemAndDescendants', 'LongitudinalTemporalOffsetFromEvent', + 'MacularMeasurementsGroup', + 'MacularThicknessKeyMeasurements', 'Measurement', 'MeasurementProperties', 'MeasurementReport', @@ -131,11 +140,14 @@ 'NumContentItem', 'ObservationContext', 'ObserverContext', + 'OphthalmologyMeasurementsGroup', 'PersonObserverIdentifyingAttributes', 'PixelOriginInterpretationValues', 'PlanarROIMeasurementsAndQualitativeEvaluations', 'PnameContentItem', 'QualitativeEvaluation', + 'RNFLClockfaceMeasurementsGroup', + 'RNFLSectorMeasurementsGroup', 'RealWorldValueMap', 'ReferencedSegment', 'ReferencedSegmentationFrame', diff --git a/src/highdicom/sr/templates/__init__.py b/src/highdicom/sr/templates/__init__.py new file mode 100644 index 00000000..42911ee8 --- /dev/null +++ b/src/highdicom/sr/templates/__init__.py @@ -0,0 +1,87 @@ +"""DICOM structured reporting templates. + +This package contains implementations of DICOM SR templates defined in +DICOM PS3.16. Each sub-module covers one top-level template family: + +- :mod:`tid1500` — TID 1500 Measurement Report (general imaging measurements) +- :mod:`tid2120` — Eyecare Measurement Templates (TID 2120, 2123, 2124), + ratified in DICOM PS3.16 2025b (formerly circulated as Supplement 247 + draft TIDs 6001, 6004, 6005) +""" + +# Re-export everything from tid1500 for backwards compatibility. +# All names that were previously importable from highdicom.sr.templates +# remain importable without change. +from highdicom.sr.templates.tid1500 import ( + AlgorithmIdentification, + DeviceObserverIdentifyingAttributes, + ImageLibrary, + ImageLibraryEntry, + ImageLibraryEntryDescriptors, + LanguageOfContentItemAndDescendants, + Measurement, + MeasurementProperties, + MeasurementReport, + MeasurementsAndQualitativeEvaluations, + MeasurementStatisticalProperties, + NormalRangeProperties, + ObserverContext, + ObservationContext, + PersonObserverIdentifyingAttributes, + PlanarROIMeasurementsAndQualitativeEvaluations, + QualitativeEvaluation, + SubjectContext, + SubjectContextDevice, + SubjectContextFetus, + SubjectContextSpecimen, + Template, + TimePointContext, + TrackingIdentifier, + VolumetricROIMeasurementsAndQualitativeEvaluations, +) + +# Eyecare Measurement Templates (TID 2120, 2123, 2124) +from highdicom.sr.templates.tid2120 import ( + CircumpapillaryRNFLKeyMeasurements, + MacularMeasurementsGroup, + MacularThicknessKeyMeasurements, + OphthalmologyMeasurementsGroup, + RNFLClockfaceMeasurementsGroup, + RNFLSectorMeasurementsGroup, +) + +__all__ = [ + # TID 1500 family + "AlgorithmIdentification", + "DeviceObserverIdentifyingAttributes", + "ImageLibrary", + "ImageLibraryEntry", + "ImageLibraryEntryDescriptors", + "LanguageOfContentItemAndDescendants", + "Measurement", + "MeasurementProperties", + "MeasurementReport", + "MeasurementsAndQualitativeEvaluations", + "MeasurementStatisticalProperties", + "NormalRangeProperties", + "ObserverContext", + "ObservationContext", + "PersonObserverIdentifyingAttributes", + "PlanarROIMeasurementsAndQualitativeEvaluations", + "QualitativeEvaluation", + "SubjectContext", + "SubjectContextDevice", + "SubjectContextFetus", + "SubjectContextSpecimen", + "Template", + "TimePointContext", + "TrackingIdentifier", + "VolumetricROIMeasurementsAndQualitativeEvaluations", + # Eyecare Measurement Templates family + "OphthalmologyMeasurementsGroup", + "RNFLSectorMeasurementsGroup", + "RNFLClockfaceMeasurementsGroup", + "MacularMeasurementsGroup", + "CircumpapillaryRNFLKeyMeasurements", + "MacularThicknessKeyMeasurements", +] diff --git a/src/highdicom/sr/templates.py b/src/highdicom/sr/templates/tid1500.py similarity index 100% rename from src/highdicom/sr/templates.py rename to src/highdicom/sr/templates/tid1500.py diff --git a/src/highdicom/sr/templates/tid2120.py b/src/highdicom/sr/templates/tid2120.py new file mode 100644 index 00000000..0ed8df12 --- /dev/null +++ b/src/highdicom/sr/templates/tid2120.py @@ -0,0 +1,1045 @@ +"""DICOM SR templates for Eyecare Measurement Templates. + +Implements TIDs 2120, 2123, and 2124 as ratified in DICOM PS3.16 2025b +(formerly circulated in Supplement 247 draft as TIDs 6001, 6004, and 6005; +Supplement 247 has since been incorporated into the standard and its +templates renumbered). + +Reference: :dcm:`TID 2120 `, +:dcm:`TID 2123 `, +:dcm:`TID 2124 ` + +Conformance enforcement +------------------------ +Per the ratified standard, TID 2123 *Circumpapillary Retinal Nerve Fiber +Layer Key Measurements* invokes TID 2120 in two structurally distinct +roles: a "sector" group (:dcm:`CID 4282 ` sector +methods, e.g. Garway-Heath or quadrant sectors, with a mandatory Retinal ROI +width measurement plus optional :dcm:`CID 4283 ` +sector values) and a separate "clockface" group (fixed RNFL Clockface +Method, :dcm:`CID 4284 ` clockface-position +values), plus a bilateral RNFL-symmetry measurement that becomes mandatory +once both eyes are represented. + +Rather than accept an arbitrary list of measurements and hope the caller +assembled the right combination for whichever role and CID applies, +:class:`RNFLSectorMeasurementsGroup`, :class:`RNFLClockfaceMeasurementsGroup`, +and :class:`MacularMeasurementsGroup` accept the individual measurement +values as named parameters and build the correctly-coded +:class:`~highdicom.sr.Measurement` instances internally, so that a +successful construction guarantees a conformant object. +:class:`OphthalmologyMeasurementsGroup` remains available directly for +other TID 2120 invocations not covered by these specializations. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from pydicom.sr.codedict import codes +from pydicom.sr.coding import Code + +from highdicom.sr.coding import CodedConcept +from highdicom.sr.content import ( + FindingSite, + RealWorldValueMap, + SourceImageForMeasurementGroup, +) +from highdicom.sr.value_types import ( + ContentSequence, + ContainerContentItem, +) +from highdicom.sr.templates.tid1500 import ( + AlgorithmIdentification, + DEFAULT_LANGUAGE, + LanguageOfContentItemAndDescendants, + Measurement, + MeasurementsAndQualitativeEvaluations, + QualitativeEvaluation, + Template, + TimePointContext, + TrackingIdentifier, +) + +# --------------------------------------------------------------------------- +# Supplementary UCUM unit not yet in pydicom.sr.codedict +# --------------------------------------------------------------------------- +#: UCUM microliter — used for macular total volume (``LN 57118-2``). +#: Not exposed as a convenience attribute in pydicom 3.x; defined here until +#: pydicom adds it to ``codes.UCUM``. +UCUM_MICROLITER: Code = Code('uL', 'UCUM', 'microliter') + +# --------------------------------------------------------------------------- +# Container concept names for the root templates TID 2123 and TID 2124. +# Final DCM codes per the ratified standard (formerly Sup247 draft +# placeholders nnn102/nnn103 under a private coding scheme). +# --------------------------------------------------------------------------- +_CONCEPT_CIRCUMPAPILLARY_RNFL_KEY = CodedConcept( + value='131242', scheme_designator='DCM', + meaning='Circumpapillary RNFL Key Measurements' +) +_CONCEPT_MACULAR_THICKNESS_KEY = CodedConcept( + value='131243', scheme_designator='DCM', + meaning='Macular Thickness Key Measurements' +) + +# --------------------------------------------------------------------------- +# CID 4282 — Retinal Sector Methods. Used as the ``method`` value for +# RNFLSectorMeasurementsGroup. +# --------------------------------------------------------------------------- + +#: Semicircular sectors. DCM 131301. +SemicircularSectors: CodedConcept = CodedConcept( + value='131301', scheme_designator='DCM', meaning='Semicircular sectors' +) +#: Quadrant sectors. DCM 131302. +QuadrantSectors: CodedConcept = CodedConcept( + value='131302', scheme_designator='DCM', meaning='Quadrant sectors' +) +#: SNIT (Superior-Nasal-Inferior-Temporal) rectangular sectors. DCM 131303. +SNITRectangularSectors: CodedConcept = CodedConcept( + value='131303', scheme_designator='DCM', + meaning='SNIT rectangular sectors' +) +#: Garway-Heath sectors. DCM 131305. +GarwayHeathSectors: CodedConcept = CodedConcept( + value='131305', scheme_designator='DCM', meaning='Garway-Heath sectors' +) +#: Quadrant-octant sectors. DCM 131306. +QuadrantOctantSectors: CodedConcept = CodedConcept( + value='131306', scheme_designator='DCM', + meaning='Quadrant-octant sectors' +) + +_SECTOR_METHODS = ( + SemicircularSectors, + QuadrantSectors, + SNITRectangularSectors, + GarwayHeathSectors, + QuadrantOctantSectors, +) + +#: RNFL Clockface Method — the fixed method value for +#: RNFLClockfaceMeasurementsGroup. DCM 131308. +RNFLClockfaceMethod: CodedConcept = CodedConcept( + value='131308', scheme_designator='DCM', + meaning='RNFL Clockface Method' +) + +# --------------------------------------------------------------------------- +# CID 4283 — RNFL Sector Measurements. All values in µm. +# Final DCM codes per the ratified standard (formerly Sup247 draft +# placeholders nnn400-nnn404 under a private coding scheme). +# --------------------------------------------------------------------------- + +#: Average circumpapillary RNFL thickness. DCM 131264. +RNFLAverageThickness: CodedConcept = CodedConcept( + value='131264', scheme_designator='DCM', + meaning='RNFL average thickness' +) + +#: Inferior sector RNFL thickness. DCM 131265. +RNFLInferiorThickness: CodedConcept = CodedConcept( + value='131265', scheme_designator='DCM', + meaning='RNFL inferior sector thickness' +) + +#: Superior sector RNFL thickness. DCM 131266. +RNFLSuperiorThickness: CodedConcept = CodedConcept( + value='131266', scheme_designator='DCM', + meaning='RNFL superior sector thickness' +) + +#: Temporal sector RNFL thickness. DCM 131267. +RNFLTemporalThickness: CodedConcept = CodedConcept( + value='131267', scheme_designator='DCM', + meaning='RNFL temporal sector thickness' +) + +#: Nasal sector RNFL thickness. DCM 131268. +RNFLNasalThickness: CodedConcept = CodedConcept( + value='131268', scheme_designator='DCM', + meaning='RNFL nasal sector thickness' +) + +#: Nasal-superior sector RNFL thickness. DCM 131269. +RNFLNasalSuperiorThickness: CodedConcept = CodedConcept( + value='131269', scheme_designator='DCM', + meaning='RNFL nasal-superior sector thickness' +) + +#: Nasal-inferior sector RNFL thickness. DCM 131270. +RNFLNasalInferiorThickness: CodedConcept = CodedConcept( + value='131270', scheme_designator='DCM', + meaning='RNFL nasal-inferior sector thickness' +) + +#: Temporal-inferior sector RNFL thickness. DCM 131271. +RNFLTemporalInferiorThickness: CodedConcept = CodedConcept( + value='131271', scheme_designator='DCM', + meaning='RNFL temporal-inferior sector thickness' +) + +#: Temporal-superior sector RNFL thickness. DCM 131272. +RNFLTemporalSuperiorThickness: CodedConcept = CodedConcept( + value='131272', scheme_designator='DCM', + meaning='RNFL temporal-superior sector thickness' +) + +#: Symmetry between right and left eye RNFL measurements, in percent. +#: Mandatory (TID 2123 row 7) whenever RNFL measurements are provided for +#: both eyes. DCM 131273. +RNFLSymmetry: CodedConcept = CodedConcept( + value='131273', scheme_designator='DCM', meaning='RNFL symmetry' +) + +# --------------------------------------------------------------------------- +# CID 4284 — RNFL Clockface Measurements. +# --------------------------------------------------------------------------- + +#: Width of the circumpapillary circle scan region, in mm. Shared between +#: the sector (CID 4283) and clockface (CID 4284) roles. DCM 131274. +RetinalROIWidth: CodedConcept = CodedConcept( + value='131274', scheme_designator='DCM', meaning='Retinal ROI width' +) + +#: Mapping of clockface position (1-12) to its CodedConcept, DCM +#: 131276-131287, each an RNFL thickness measurement in µm at that +#: clockface position. +RNFL_CLOCKFACE_POSITION_CODES: dict[int, CodedConcept] = { + position: CodedConcept( + value=str(131275 + position), scheme_designator='DCM', + meaning=f'RNFL clockface position {position} thickness' + ) + for position in range(1, 13) +} + +# --------------------------------------------------------------------------- +# CID 4285 — Macular Thickness Key Measurements. +# +# The nine ETDRS grid subfields and total volume use LOINC codes; average +# macular thickness uses a final DCM code (formerly Sup247 draft placeholder +# nnn250 under a private coding scheme). +# --------------------------------------------------------------------------- + +#: Center point thickness (single foveal pixel/A-scan). LOINC 57108-3. +MacularCenterPointThickness: CodedConcept = CodedConcept( + value='57108-3', scheme_designator='LN', + meaning='Macular grid.center point thickness by OCT' +) + +#: Central subfield (1 mm diameter circle; CMT). LOINC 57109-1. +MacularCenterSubfieldThickness: CodedConcept = CodedConcept( + value='57109-1', scheme_designator='LN', + meaning='Macular grid.center subfield thickness by OCT' +) + +#: Inner superior subfield (1–3 mm annulus, superior sector). LOINC 57110-9. +MacularInnerSuperiorThickness: CodedConcept = CodedConcept( + value='57110-9', scheme_designator='LN', + meaning='Macular grid.inner superior subfield thickness by OCT' +) + +#: Inner nasal subfield. LOINC 57111-7. +MacularInnerNasalThickness: CodedConcept = CodedConcept( + value='57111-7', scheme_designator='LN', + meaning='Macular grid.inner nasal subfield thickness by OCT' +) + +#: Inner inferior subfield. LOINC 57112-5. +MacularInnerInferiorThickness: CodedConcept = CodedConcept( + value='57112-5', scheme_designator='LN', + meaning='Macular grid.inner inferior subfield thickness by OCT' +) + +#: Inner temporal subfield. LOINC 57113-3. +MacularInnerTemporalThickness: CodedConcept = CodedConcept( + value='57113-3', scheme_designator='LN', + meaning='Macular grid.inner temporal subfield thickness by OCT' +) + +#: Outer superior subfield (3–6 mm annulus, superior sector). LOINC 57114-1. +MacularOuterSuperiorThickness: CodedConcept = CodedConcept( + value='57114-1', scheme_designator='LN', + meaning='Macular grid.outer superior subfield thickness by OCT' +) + +#: Outer nasal subfield. LOINC 57115-8. +MacularOuterNasalThickness: CodedConcept = CodedConcept( + value='57115-8', scheme_designator='LN', + meaning='Macular grid.outer nasal subfield thickness by OCT' +) + +#: Outer inferior subfield. LOINC 57116-6. +MacularOuterInferiorThickness: CodedConcept = CodedConcept( + value='57116-6', scheme_designator='LN', + meaning='Macular grid.outer inferior subfield thickness by OCT' +) + +#: Outer temporal subfield. LOINC 57117-4. +MacularOuterTemporalThickness: CodedConcept = CodedConcept( + value='57117-4', scheme_designator='LN', + meaning='Macular grid.outer temporal subfield thickness by OCT' +) + +#: Total macular volume within the 6 mm ETDRS circle, in µL. LOINC 57118-2. +MacularTotalVolume: CodedConcept = CodedConcept( + value='57118-2', scheme_designator='LN', + meaning='Macular grid.total volume by OCT' +) + +#: Average macular thickness over the full ETDRS grid, in µm. DCM 131255. +AverageMacularThickness: CodedConcept = CodedConcept( + value='131255', scheme_designator='DCM', + meaning='Average macular thickness' +) + + +# --------------------------------------------------------------------------- +# TID 2120 — Ophthalmology Measurements Group +# --------------------------------------------------------------------------- + +class OphthalmologyMeasurementsGroup(MeasurementsAndQualitativeEvaluations): + """:dcm:`TID 2120 ` + Ophthalmology Measurements Group + + Type: Extensible · Order: Non-Significant · Root: No + + Sub-template that wraps a set of ophthalmic measurements for a single + eye. Per the standard, TID 2120 is a proper subset of + :dcm:`TID 1501 ` + *Measurement and Qualitative Evaluation Group* with the finding site + constrained to ``EV (81745001, SCT, "Eye")`` and laterality mandatory, + so this class subclasses + :class:`~highdicom.sr.MeasurementsAndQualitativeEvaluations` directly + rather than reimplementing shared TID 1501 behavior (tracking identifier, + source images, etc.). + + This template is invoked by the root templates + :class:`CircumpapillaryRNFLKeyMeasurements` (TID 2123, via + :class:`RNFLSectorMeasurementsGroup` and + :class:`RNFLClockfaceMeasurementsGroup`) and + :class:`MacularThicknessKeyMeasurements` (TID 2124, via + :class:`MacularMeasurementsGroup`). It remains directly usable for other + TID 2120 invocations not covered by those specializations. + """ + + def __init__( + self, + tracking_identifier: TrackingIdentifier, + laterality: CodedConcept | Code, + measurements: Sequence[Measurement] | None = None, + finding_site: CodedConcept | Code | None = None, + topographical_modifier: CodedConcept | Code | None = None, + referenced_real_world_value_map: RealWorldValueMap | None = None, + time_point_context: TimePointContext | None = None, + finding_type: CodedConcept | Code | None = None, + method: CodedConcept | Code | None = None, + algorithm_id: AlgorithmIdentification | None = None, + session: str | None = None, + qualitative_evaluations: Sequence[QualitativeEvaluation] | None = None, + finding_category: CodedConcept | Code | None = None, + source_images: Sequence[SourceImageForMeasurementGroup] | None = None, + ) -> None: + """ + Parameters + ---------- + tracking_identifier: highdicom.sr.TrackingIdentifier + Identifier for tracking measurements. + laterality: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code] + Laterality of the eye measured. See + :dcm:`CID 247 ` "Laterality" for + options; typically ``codes.cid247.Right`` or + ``codes.cid247.Left``. Encoded as a modifier of the Finding + Site item. + measurements: Union[Sequence[highdicom.sr.Measurement], None], optional + One or more :class:`~highdicom.sr.Measurement` instances + (TID 300), each encoding a single numeric ophthalmic finding + (e.g. average RNFL thickness, or a macular subfield thickness). + finding_site: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Specific anatomic site within the eye. See + :dcm:`CID 4209 ` + "Ophthalmic Anatomic Location" for options. Defaults to + ``codes.cid4209.Eye`` (``EV (81745001, SCT, "Eye")``), which is + the value-set constraint imposed by the standard. + topographical_modifier: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Coded modifier of the finding site. + referenced_real_world_value_map: Union[highdicom.sr.RealWorldValueMap, None], optional + Referenced real world value map for the eye. + time_point_context: Union[highdicom.sr.TimePointContext, None], optional + Description of the time point context. + finding_type: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Type of observed finding. + method: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Coded measurement method. + algorithm_id: Union[highdicom.sr.AlgorithmIdentification, None], optional + Identification of algorithm used for making measurements. + session: Union[str, None], optional + Description of the session. + qualitative_evaluations: Union[Sequence[highdicom.sr.QualitativeEvaluation], None], optional + Coded name-value pairs that describe qualitative evaluations. + finding_category: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Category of observed finding. + source_images: Union[Sequence[highdicom.sr.SourceImageForMeasurementGroup], None], optional + Images that were the source of the measurements. If not + provided, all images listed in the document tree of the + containing SR document are assumed to be source images. + + """ # noqa: E501 + if finding_site is None: + finding_site = codes.cid4209.Eye + + finding_sites = [ + FindingSite( + anatomic_location=finding_site, + laterality=laterality, + topographical_modifier=topographical_modifier, + ) + ] + + super().__init__( + tracking_identifier=tracking_identifier, + referenced_real_world_value_map=referenced_real_world_value_map, + time_point_context=time_point_context, + finding_type=finding_type, + method=method, + algorithm_id=algorithm_id, + finding_sites=finding_sites, + session=session, + measurements=measurements, + qualitative_evaluations=qualitative_evaluations, + finding_category=finding_category, + source_images=source_images, + ) + # The base class hardcodes template_id='1501' since it implements + # TID 1501. This container instead invokes TID 2120, which imposes + # additional constraints (mandatory laterality, eye-only finding + # site) beyond what TID 1501 requires, so the Content Template + # Sequence must identify it as such. + self[0].ContentTemplateSequence[0].TemplateIdentifier = '2120' + + +def _laterality_of(group: OphthalmologyMeasurementsGroup) -> CodedConcept | None: + sites = group.finding_sites + if not sites: + return None + return sites[0].laterality + + +# --------------------------------------------------------------------------- +# TID 2120, sector role — invoked by TID 2123 Row 5 +# --------------------------------------------------------------------------- + +class RNFLSectorMeasurementsGroup(OphthalmologyMeasurementsGroup): + """TID 2120 Ophthalmology Measurements Group, invoked in the "sector" + role of :dcm:`TID 2123 ` Row 5: a retinal + sector method (:dcm:`CID 4282 `) with the + mandatory Retinal ROI width measurement plus optional + :dcm:`CID 4283 ` sector thickness values. + """ + + def __init__( + self, + tracking_identifier: TrackingIdentifier, + laterality: CodedConcept | Code, + sector_method: CodedConcept | Code, + retinal_roi_width: float, + average: float | None = None, + inferior: float | None = None, + superior: float | None = None, + temporal: float | None = None, + nasal: float | None = None, + nasal_superior: float | None = None, + nasal_inferior: float | None = None, + temporal_inferior: float | None = None, + temporal_superior: float | None = None, + finding_site: CodedConcept | Code | None = None, + topographical_modifier: CodedConcept | Code | None = None, + source_images: Sequence[SourceImageForMeasurementGroup] | None = None, + ) -> None: + """ + Parameters + ---------- + tracking_identifier: highdicom.sr.TrackingIdentifier + Identifier for tracking measurements. + laterality: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code] + Laterality of the eye measured (:dcm:`CID 247 `). + sector_method: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code] + Retinal sector method used, one of the + :dcm:`CID 4282 ` codes exposed as + module-level constants: :data:`SemicircularSectors`, + :data:`QuadrantSectors`, :data:`SNITRectangularSectors`, + :data:`GarwayHeathSectors`, :data:`QuadrantOctantSectors`. + retinal_roi_width: float + Width of the circumpapillary circle scan region, in mm. + Mandatory per Row 5. + average: Union[float, None], optional + Average circumpapillary RNFL thickness, in µm. + inferior: Union[float, None], optional + Inferior sector RNFL thickness, in µm. + superior: Union[float, None], optional + Superior sector RNFL thickness, in µm. + temporal: Union[float, None], optional + Temporal sector RNFL thickness, in µm. + nasal: Union[float, None], optional + Nasal sector RNFL thickness, in µm. + nasal_superior: Union[float, None], optional + Nasal-superior sector RNFL thickness, in µm. + nasal_inferior: Union[float, None], optional + Nasal-inferior sector RNFL thickness, in µm. + temporal_inferior: Union[float, None], optional + Temporal-inferior sector RNFL thickness, in µm. + temporal_superior: Union[float, None], optional + Temporal-superior sector RNFL thickness, in µm. + finding_site: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Specific anatomic site within the eye. Defaults to + ``codes.cid4209.Eye``. + topographical_modifier: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Coded modifier of the finding site. + source_images: Union[Sequence[highdicom.sr.SourceImageForMeasurementGroup], None], optional + Images that were the source of the measurements. + + Raises + ------ + ValueError + If ``sector_method`` is not one of the CID 4282 codes. + + """ # noqa: E501 + sector_method = CodedConcept.from_code(sector_method) + if sector_method not in _SECTOR_METHODS: + raise ValueError( + 'Argument "sector_method" must be one of the CID 4282 ' + 'Retinal Sector Methods codes (e.g. GarwayHeathSectors, ' + 'QuadrantSectors).' + ) + + measurements = [ + Measurement( + name=RetinalROIWidth, + value=retinal_roi_width, + unit=codes.UCUM.Millimeter, + ), + ] + optional_measurements = { + RNFLAverageThickness: average, + RNFLInferiorThickness: inferior, + RNFLSuperiorThickness: superior, + RNFLTemporalThickness: temporal, + RNFLNasalThickness: nasal, + RNFLNasalSuperiorThickness: nasal_superior, + RNFLNasalInferiorThickness: nasal_inferior, + RNFLTemporalInferiorThickness: temporal_inferior, + RNFLTemporalSuperiorThickness: temporal_superior, + } + for name, value in optional_measurements.items(): + if value is not None: + measurements.append( + Measurement( + name=name, value=value, unit=codes.UCUM.Micrometer + ) + ) + + super().__init__( + tracking_identifier=tracking_identifier, + laterality=laterality, + measurements=measurements, + finding_site=finding_site, + topographical_modifier=topographical_modifier, + method=sector_method, + source_images=source_images, + ) + + +# --------------------------------------------------------------------------- +# TID 2120, clockface role — invoked by TID 2123 Row 6 +# --------------------------------------------------------------------------- + +class RNFLClockfaceMeasurementsGroup(OphthalmologyMeasurementsGroup): + """TID 2120 Ophthalmology Measurements Group, invoked in the + "clockface" role of :dcm:`TID 2123 ` Row 6: + the fixed :data:`RNFLClockfaceMethod` with + :dcm:`CID 4284 ` clockface-position + thickness values. + """ + + def __init__( + self, + tracking_identifier: TrackingIdentifier, + laterality: CodedConcept | Code, + clockface_measurements: Mapping[int, float], + retinal_roi_width: float | None = None, + finding_site: CodedConcept | Code | None = None, + topographical_modifier: CodedConcept | Code | None = None, + source_images: Sequence[SourceImageForMeasurementGroup] | None = None, + ) -> None: + """ + Parameters + ---------- + tracking_identifier: highdicom.sr.TrackingIdentifier + Identifier for tracking measurements. + laterality: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code] + Laterality of the eye measured (:dcm:`CID 247 `). + clockface_measurements: Mapping[int, float] + Mapping of clockface position (1-12, per CID 4284) to the RNFL + thickness in µm measured at that position. Positions not + measured may be omitted; at least one must be provided. + retinal_roi_width: Union[float, None], optional + Width of the circumpapillary circle scan region, in mm. + finding_site: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Specific anatomic site within the eye. Defaults to + ``codes.cid4209.Eye``. + topographical_modifier: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Coded modifier of the finding site. + source_images: Union[Sequence[highdicom.sr.SourceImageForMeasurementGroup], None], optional + Images that were the source of the measurements. + + Raises + ------ + ValueError + If ``clockface_measurements`` is empty or contains a key + outside 1-12. + + """ # noqa: E501 + if not clockface_measurements: + raise ValueError( + 'Argument "clockface_measurements" must contain at least ' + 'one clockface position.' + ) + for position in clockface_measurements: + if position not in RNFL_CLOCKFACE_POSITION_CODES: + raise ValueError( + f'Clockface position {position!r} is invalid; keys of ' + '"clockface_measurements" must be integers from 1 to ' + '12 (CID 4284).' + ) + + measurements = [] + if retinal_roi_width is not None: + measurements.append( + Measurement( + name=RetinalROIWidth, + value=retinal_roi_width, + unit=codes.UCUM.Millimeter, + ) + ) + for position, value in clockface_measurements.items(): + measurements.append( + Measurement( + name=RNFL_CLOCKFACE_POSITION_CODES[position], + value=value, + unit=codes.UCUM.Micrometer, + ) + ) + + super().__init__( + tracking_identifier=tracking_identifier, + laterality=laterality, + measurements=measurements, + finding_site=finding_site, + topographical_modifier=topographical_modifier, + method=RNFLClockfaceMethod, + source_images=source_images, + ) + + +# --------------------------------------------------------------------------- +# TID 2123 — Circumpapillary Retinal Nerve Fiber Layer Key Measurements +# --------------------------------------------------------------------------- + +class CircumpapillaryRNFLKeyMeasurements(Template): + """:dcm:`TID 2123 ` + Circumpapillary Retinal Nerve Fiber Layer Key Measurements + + Type: Extensible · Order: Non-Significant · Root: Yes + + Root template for circumpapillary retinal nerve fiber layer (RNFL) + thickness measurements obtained by ophthalmic tomography (OCT). Wraps + one or two :class:`RNFLSectorMeasurementsGroup` instances (Row 5), one + or two :class:`RNFLClockfaceMeasurementsGroup` instances (Row 6), or + both, plus an RNFL-symmetry measurement (Row 7) that is required + whenever both eyes are represented across the two group lists combined. + + Usage example:: + + from highdicom.sr.templates import AlgorithmIdentification, TrackingIdentifier + from highdicom.sr.templates.tid2120 import ( + CircumpapillaryRNFLKeyMeasurements, + GarwayHeathSectors, + RNFLSectorMeasurementsGroup, + ) + from pydicom.sr.codedict import codes + + algo = AlgorithmIdentification(name='Revo FC130', version='1.0') + group = RNFLSectorMeasurementsGroup( + tracking_identifier=TrackingIdentifier(identifier='RNFL-OD'), + laterality=codes.cid247.Right, + sector_method=GarwayHeathSectors, + retinal_roi_width=3.4, + average=121.0, + ) + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=algo, + sector_measurement_groups=[group], + ) + """ + + def __init__( + self, + algorithm_id: AlgorithmIdentification, + sector_measurement_groups: ( + Sequence[RNFLSectorMeasurementsGroup] | None + ) = None, + clockface_measurement_groups: ( + Sequence[RNFLClockfaceMeasurementsGroup] | None + ) = None, + rnfl_symmetry: float | None = None, + language_of_content_item_and_descendants: ( + LanguageOfContentItemAndDescendants | None + ) = None, + ) -> None: + """ + Parameters + ---------- + algorithm_id: highdicom.sr.AlgorithmIdentification + Identification of the algorithm used to produce the RNFL + measurements (TID 4019). Mandatory. + sector_measurement_groups: Union[Sequence[highdicom.sr.RNFLSectorMeasurementsGroup], None], optional + One or two :class:`RNFLSectorMeasurementsGroup` instances, one + per eye (Row 5). + clockface_measurement_groups: Union[Sequence[highdicom.sr.RNFLClockfaceMeasurementsGroup], None], optional + One or two :class:`RNFLClockfaceMeasurementsGroup` instances, + one per eye (Row 6). + rnfl_symmetry: Union[float, None], optional + Symmetry between right and left eye RNFL measurements, as a + percentage (Row 7). Required when ``sector_measurement_groups`` + and/or ``clockface_measurement_groups`` together represent both + eyes; must not be provided otherwise. + language_of_content_item_and_descendants: Union[highdicom.sr.LanguageOfContentItemAndDescendants, None], optional + Language specification for all SR content items. Defaults to + English (``en-US``, RFC 5646) when not provided. + + Raises + ------ + TypeError + If ``algorithm_id`` is not an + :class:`~highdicom.sr.AlgorithmIdentification`, or if any group + does not have the expected type. + ValueError + If neither group list is provided, if either contains more than + two items, or if ``rnfl_symmetry`` is inconsistent with whether + both eyes are represented. + + """ # noqa: E501 + sector_measurement_groups = list(sector_measurement_groups or []) + clockface_measurement_groups = list(clockface_measurement_groups or []) + + if not sector_measurement_groups and not clockface_measurement_groups: + raise ValueError( + 'At least one of "sector_measurement_groups" or ' + '"clockface_measurement_groups" must be provided.' + ) + if len(sector_measurement_groups) > 2: + raise ValueError( + 'Argument "sector_measurement_groups" must contain at most ' + 'two items (one per eye).' + ) + if len(clockface_measurement_groups) > 2: + raise ValueError( + 'Argument "clockface_measurement_groups" must contain at ' + 'most two items (one per eye).' + ) + for g in sector_measurement_groups: + if not isinstance(g, RNFLSectorMeasurementsGroup): + raise TypeError( + 'Each item of "sector_measurement_groups" must have ' + 'type RNFLSectorMeasurementsGroup.' + ) + for g in clockface_measurement_groups: + if not isinstance(g, RNFLClockfaceMeasurementsGroup): + raise TypeError( + 'Each item of "clockface_measurement_groups" must have ' + 'type RNFLClockfaceMeasurementsGroup.' + ) + if not isinstance(algorithm_id, AlgorithmIdentification): + raise TypeError( + 'Argument "algorithm_id" must have type ' + 'AlgorithmIdentification.' + ) + + all_groups = [*sector_measurement_groups, *clockface_measurement_groups] + lateralities = [_laterality_of(g) for g in all_groups] + is_bilateral = ( + any(lat == codes.cid247.Right for lat in lateralities) and + any(lat == codes.cid247.Left for lat in lateralities) + ) + if is_bilateral and rnfl_symmetry is None: + raise ValueError( + 'Argument "rnfl_symmetry" is required (TID 2123 Row 7) ' + 'when RNFL measurements are provided for both eyes.' + ) + if not is_bilateral and rnfl_symmetry is not None: + raise ValueError( + 'Argument "rnfl_symmetry" is only applicable (TID 2123 ' + 'Row 7) when RNFL measurements are provided for both eyes.' + ) + + item = ContainerContentItem( + name=_CONCEPT_CIRCUMPAPILLARY_RNFL_KEY, + template_id='2123', + ) + item.ContentSequence = ContentSequence() + + if language_of_content_item_and_descendants is None: + language_of_content_item_and_descendants = \ + LanguageOfContentItemAndDescendants(DEFAULT_LANGUAGE) + item.ContentSequence.extend(language_of_content_item_and_descendants) + + item.ContentSequence.extend(algorithm_id) + + for group in sector_measurement_groups: + item.ContentSequence.extend(group) + for group in clockface_measurement_groups: + item.ContentSequence.extend(group) + + if rnfl_symmetry is not None: + item.ContentSequence.extend( + Measurement( + name=RNFLSymmetry, + value=rnfl_symmetry, + unit=codes.UCUM.Percent, + ) + ) + + super().__init__([item], is_root=True) + + +# --------------------------------------------------------------------------- +# TID 2120, macular role — invoked by TID 2124 Row 5 +# --------------------------------------------------------------------------- + +class MacularMeasurementsGroup(OphthalmologyMeasurementsGroup): + """TID 2120 Ophthalmology Measurements Group, invoked by + :dcm:`TID 2124 ` Row 5: + :dcm:`CID 4285 ` Macular Thickness Key + Measurements (ETDRS grid subfields, total volume, average thickness). + """ + + def __init__( + self, + tracking_identifier: TrackingIdentifier, + laterality: CodedConcept | Code, + center_point: float | None = None, + center_subfield: float | None = None, + inner_superior: float | None = None, + inner_nasal: float | None = None, + inner_inferior: float | None = None, + inner_temporal: float | None = None, + outer_superior: float | None = None, + outer_nasal: float | None = None, + outer_inferior: float | None = None, + outer_temporal: float | None = None, + total_volume: float | None = None, + average_thickness: float | None = None, + finding_site: CodedConcept | Code | None = None, + topographical_modifier: CodedConcept | Code | None = None, + source_images: Sequence[SourceImageForMeasurementGroup] | None = None, + ) -> None: + """ + Parameters + ---------- + tracking_identifier: highdicom.sr.TrackingIdentifier + Identifier for tracking measurements. + laterality: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code] + Laterality of the eye measured (:dcm:`CID 247 `). + center_point: Union[float, None], optional + Center point thickness (single foveal pixel/A-scan), in µm. + center_subfield: Union[float, None], optional + Central subfield thickness (CMT; 1 mm diameter circle), in µm. + inner_superior: Union[float, None], optional + Inner superior subfield thickness, in µm. + inner_nasal: Union[float, None], optional + Inner nasal subfield thickness, in µm. + inner_inferior: Union[float, None], optional + Inner inferior subfield thickness, in µm. + inner_temporal: Union[float, None], optional + Inner temporal subfield thickness, in µm. + outer_superior: Union[float, None], optional + Outer superior subfield thickness, in µm. + outer_nasal: Union[float, None], optional + Outer nasal subfield thickness, in µm. + outer_inferior: Union[float, None], optional + Outer inferior subfield thickness, in µm. + outer_temporal: Union[float, None], optional + Outer temporal subfield thickness, in µm. + total_volume: Union[float, None], optional + Total macular volume within the 6 mm ETDRS circle, in µL. + average_thickness: Union[float, None], optional + Average macular thickness over the full ETDRS grid, in µm. + finding_site: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Specific anatomic site within the eye. Defaults to + ``codes.cid4209.Eye``. + topographical_modifier: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code, None], optional + Coded modifier of the finding site. + source_images: Union[Sequence[highdicom.sr.SourceImageForMeasurementGroup], None], optional + Images that were the source of the measurements. + + Raises + ------ + ValueError + If no measurement value is provided. + + """ # noqa: E501 + micrometer_measurements = { + MacularCenterPointThickness: center_point, + MacularCenterSubfieldThickness: center_subfield, + MacularInnerSuperiorThickness: inner_superior, + MacularInnerNasalThickness: inner_nasal, + MacularInnerInferiorThickness: inner_inferior, + MacularInnerTemporalThickness: inner_temporal, + MacularOuterSuperiorThickness: outer_superior, + MacularOuterNasalThickness: outer_nasal, + MacularOuterInferiorThickness: outer_inferior, + MacularOuterTemporalThickness: outer_temporal, + AverageMacularThickness: average_thickness, + } + if ( + all(v is None for v in micrometer_measurements.values()) and + total_volume is None + ): + raise ValueError( + 'At least one macular thickness measurement must be ' + 'provided.' + ) + + measurements = [ + Measurement(name=name, value=value, unit=codes.UCUM.Micrometer) + for name, value in micrometer_measurements.items() + if value is not None + ] + if total_volume is not None: + measurements.append( + Measurement( + name=MacularTotalVolume, + value=total_volume, + unit=UCUM_MICROLITER, + ) + ) + + super().__init__( + tracking_identifier=tracking_identifier, + laterality=laterality, + measurements=measurements, + finding_site=finding_site, + topographical_modifier=topographical_modifier, + source_images=source_images, + ) + + +# --------------------------------------------------------------------------- +# TID 2124 — Macular Thickness Key Measurements +# --------------------------------------------------------------------------- + +class MacularThicknessKeyMeasurements(Template): + """:dcm:`TID 2124 ` + Macular Thickness Key Measurements + + Type: Extensible · Order: Non-Significant · Root: Yes + + Root template for macular thickness measurements obtained by ophthalmic + tomography (OCT) using the Early Treatment of Diabetic Retinopathy Study + (ETDRS) grid. Wraps one or two :class:`MacularMeasurementsGroup` + instances, one per eye. + + Usage example:: + + from highdicom.sr.templates import AlgorithmIdentification, TrackingIdentifier + from highdicom.sr.templates.tid2120 import ( + MacularMeasurementsGroup, + MacularThicknessKeyMeasurements, + ) + from pydicom.sr.codedict import codes + + algo = AlgorithmIdentification(name='Cirrus HD-OCT', version='11.0') + group = MacularMeasurementsGroup( + tracking_identifier=TrackingIdentifier(identifier='Macula-OD'), + laterality=codes.cid247.Right, + center_subfield=288.49, + ) + report = MacularThicknessKeyMeasurements( + algorithm_id=algo, + measurement_groups=[group], + ) + """ + + def __init__( + self, + algorithm_id: AlgorithmIdentification, + measurement_groups: Sequence[MacularMeasurementsGroup], + language_of_content_item_and_descendants: ( + LanguageOfContentItemAndDescendants | None + ) = None, + ) -> None: + """ + Parameters + ---------- + algorithm_id: highdicom.sr.AlgorithmIdentification + Identification of the algorithm used to produce the macular + thickness measurements (TID 4019). Mandatory. + measurement_groups: Sequence[highdicom.sr.MacularMeasurementsGroup] + One or two :class:`MacularMeasurementsGroup` instances + (TID 2120), one per eye. Mandatory. + language_of_content_item_and_descendants: Union[highdicom.sr.LanguageOfContentItemAndDescendants, None], optional + Language specification for all SR content items. Defaults to + English (``en-US``) when not provided. + + Raises + ------ + TypeError + If ``algorithm_id`` is not an + :class:`~highdicom.sr.AlgorithmIdentification`, or if any + element of ``measurement_groups`` is not a + :class:`MacularMeasurementsGroup`. + ValueError + If ``measurement_groups`` is empty or contains more than two + items. + + """ # noqa: E501 + + if not measurement_groups: + raise ValueError( + "Argument 'measurement_groups' must contain at least one item." + ) + if len(measurement_groups) > 2: + raise ValueError( + "Argument 'measurement_groups' must contain at most two items " + "(one per eye)." + ) + for g in measurement_groups: + if not isinstance(g, MacularMeasurementsGroup): + raise TypeError( + 'Each item of "measurement_groups" must have type ' + 'MacularMeasurementsGroup.' + ) + if not isinstance(algorithm_id, AlgorithmIdentification): + raise TypeError( + 'Argument "algorithm_id" must have type ' + 'AlgorithmIdentification.' + ) + + item = ContainerContentItem( + name=_CONCEPT_MACULAR_THICKNESS_KEY, + template_id='2124', + ) + item.ContentSequence = ContentSequence() + + if language_of_content_item_and_descendants is None: + language_of_content_item_and_descendants = \ + LanguageOfContentItemAndDescendants(DEFAULT_LANGUAGE) + item.ContentSequence.extend(language_of_content_item_and_descendants) + + item.ContentSequence.extend(algorithm_id) + + for group in measurement_groups: + item.ContentSequence.extend(group) + + super().__init__([item], is_root=True) diff --git a/tests/test_sr_ophthalmic.py b/tests/test_sr_ophthalmic.py new file mode 100644 index 00000000..bd0327e4 --- /dev/null +++ b/tests/test_sr_ophthalmic.py @@ -0,0 +1,817 @@ +"""Tests for Eyecare Measurement SR templates (TID 2120/2123/2124), +ratified in DICOM PS3.16 2025b (formerly circulated as Supplement 247 draft +TIDs 6001/6004/6005). + +Tests cover: +- OphthalmologyMeasurementsGroup (TID 2120, generic) +- RNFLSectorMeasurementsGroup / RNFLClockfaceMeasurementsGroup (TID 2120, + specialized for TID 2123 rows 5/6) +- MacularMeasurementsGroup (TID 2120, specialized for TID 2124 row 5) +- CircumpapillaryRNFLKeyMeasurements (TID 2123), including the bilateral + RNFL-symmetry conformance rule +- MacularThicknessKeyMeasurements (TID 2124) +- Two ComprehensiveSR roundtrips (serialise → dcmread → verify values survive) +""" + +from io import BytesIO +from pathlib import Path + +import pytest +from pydicom import dcmread +from pydicom.sr.codedict import codes +from pydicom.uid import generate_uid + +from highdicom.sr.sop import ComprehensiveSR +from highdicom.sr.templates import ( + AlgorithmIdentification, + Measurement, + TrackingIdentifier, +) +from highdicom.sr.templates.tid2120 import ( + AverageMacularThickness, + CircumpapillaryRNFLKeyMeasurements, + GarwayHeathSectors, + MacularCenterSubfieldThickness, + MacularInnerSuperiorThickness, + MacularInnerNasalThickness, + MacularInnerInferiorThickness, + MacularInnerTemporalThickness, + MacularMeasurementsGroup, + MacularOuterSuperiorThickness, + MacularOuterNasalThickness, + MacularOuterInferiorThickness, + MacularOuterTemporalThickness, + MacularThicknessKeyMeasurements, + MacularTotalVolume, + OphthalmologyMeasurementsGroup, + QuadrantSectors, + RNFLAverageThickness, + RNFLClockfaceMeasurementsGroup, + RNFLClockfaceMethod, + RNFLInferiorThickness, + RNFLSectorMeasurementsGroup, + RNFLSuperiorThickness, + RNFLSymmetry, + RNFLTemporalThickness, + RNFLNasalThickness, + RetinalROIWidth, + UCUM_MICROLITER, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_DATA_DIR = Path(__file__).parent.parent / 'data' / 'test_files' + + +def _make_tracking_id(identifier: str = 'ophthalmic-group') -> TrackingIdentifier: + # The inherited MeasurementsAndQualitativeEvaluations base class requires + # both a human-readable identifier and a UID to be present (not just a + # UID), so a non-None identifier must always be supplied. + return TrackingIdentifier(identifier=identifier) + + +def _make_rnfl_measurement(value: float = 121.0) -> Measurement: + return Measurement( + name=RNFLAverageThickness, + value=value, + unit=codes.UCUM.Micrometer, + ) + + +def _make_macular_measurement(value: float = 281.4) -> Measurement: + return Measurement( + name=MacularCenterSubfieldThickness, + value=value, + unit=codes.UCUM.Micrometer, + ) + + +def _make_algo(name: str = 'Revo FC130', version: str = '1.0') -> AlgorithmIdentification: + return AlgorithmIdentification(name=name, version=version) + + +def _make_sector_group( + laterality=None, identifier: str = 'RNFL-sector', **kwargs +) -> RNFLSectorMeasurementsGroup: + kwargs.setdefault('average', 121.0) + return RNFLSectorMeasurementsGroup( + tracking_identifier=_make_tracking_id(identifier), + laterality=laterality or codes.cid247.Right, + sector_method=GarwayHeathSectors, + retinal_roi_width=3.4, + **kwargs, + ) + + +def _make_clockface_group( + laterality=None, identifier: str = 'RNFL-clockface', **kwargs +) -> RNFLClockfaceMeasurementsGroup: + kwargs.setdefault('clockface_measurements', {1: 100.0, 6: 95.0}) + return RNFLClockfaceMeasurementsGroup( + tracking_identifier=_make_tracking_id(identifier), + laterality=laterality or codes.cid247.Right, + **kwargs, + ) + + +def _make_macular_group( + value: float = 281.4, laterality=None +) -> MacularMeasurementsGroup: + return MacularMeasurementsGroup( + tracking_identifier=_make_tracking_id('Macula'), + laterality=laterality or codes.cid247.Right, + center_subfield=value, + ) + + +# --------------------------------------------------------------------------- +# OphthalmologyMeasurementsGroup (TID 2120, generic) +# --------------------------------------------------------------------------- + + +class TestOphthalmologyMeasurementsGroup: + def test_basic_construction(self): + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement()], + ) + assert len(group) == 1 + + def test_container_name_is_measurement_group(self): + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement()], + ) + container = group[0] + assert container.ConceptNameCodeSequence[0].CodeValue == '125007' + + def test_template_id(self): + """TID 2120 is invoked here, not the TID 1501 base class it reuses.""" + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement()], + ) + container = group[0] + assert container.ContentTemplateSequence[0].TemplateIdentifier == '2120' + + def test_finding_site_default_is_eye(self): + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement()], + ) + site = group.finding_sites[0] + assert site.value == codes.cid4209.Eye + + def test_finding_site_custom(self): + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Left, + measurements=[_make_rnfl_measurement()], + finding_site=codes.cid4209.OpticNerveHead, + ) + site = group.finding_sites[0] + assert site.value == codes.cid4209.OpticNerveHead + + def test_laterality_nested_inside_finding_site(self): + """Laterality is a modifier of Finding Site, not of the container.""" + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement()], + ) + site = group.finding_sites[0] + assert hasattr(site, 'ContentSequence') + assert site.ContentSequence[0].ConceptNameCodeSequence[0].CodeValue == \ + '272741003' + + def test_laterality_right(self): + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement()], + ) + site = group.finding_sites[0] + # Right eye: EV (24028007, SCT, "Right") + assert site.laterality.value == '24028007' + + def test_laterality_left(self): + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Left, + measurements=[_make_rnfl_measurement()], + ) + site = group.finding_sites[0] + # Left eye: EV (7771000, SCT, "Left") + assert site.laterality.value == '7771000' + + def test_topographical_modifier(self): + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement()], + topographical_modifier=codes.cid4209.OpticNerveHead, + ) + site = group.finding_sites[0] + assert site.topographical_modifier == codes.cid4209.OpticNerveHead + + def test_measurement_present(self): + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement(121.0)], + ) + measurements = group.get_measurements() + assert len(measurements) == 1 + assert measurements[0].value == 121.0 + + def test_measurement_unit_micrometer(self): + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement()], + ) + assert group.get_measurements()[0].unit == codes.UCUM.Micrometer + + def test_tracking_identifier(self): + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id('RNFL-OD-001'), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement()], + ) + assert group.tracking_identifier == 'RNFL-OD-001' + assert group.tracking_uid is not None + + def test_tracking_identifier_requires_readable_text(self): + """The inherited base class requires both a human-readable + identifier and a UID; a bare TrackingIdentifier() (UID only) is + rejected even though TID 2120 itself marks tracking identifier as + optional overall.""" + with pytest.raises(ValueError, match="tracking"): + OphthalmologyMeasurementsGroup( + tracking_identifier=TrackingIdentifier(), + laterality=codes.cid247.Right, + measurements=[_make_rnfl_measurement()], + ) + + def test_multiple_measurements(self): + meas_list = [ + Measurement(name=RNFLAverageThickness, value=121.0, unit=codes.UCUM.Micrometer), + Measurement(name=RNFLInferiorThickness, value=145.0, unit=codes.UCUM.Micrometer), + Measurement(name=RNFLSuperiorThickness, value=138.0, unit=codes.UCUM.Micrometer), + ] + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=meas_list, + ) + vals = [m.value for m in group.get_measurements()] + assert vals == [121.0, 145.0, 138.0] + + def test_no_measurements_allowed(self): + """TID 2120's Measurement row is conditional on the invoking root + template's requirements, not mandatory at this level, so an empty/ + absent list is accepted here.""" + group = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=[], + ) + assert group.get_measurements() == [] + + def test_wrong_measurement_type_raises(self): + with pytest.raises(TypeError, match="type Measurement"): + OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + measurements=["not a measurement"], + ) + + +# --------------------------------------------------------------------------- +# RNFLSectorMeasurementsGroup (TID 2120, sector role, TID 2123 Row 5) +# --------------------------------------------------------------------------- + + +class TestRNFLSectorMeasurementsGroup: + def test_basic_construction(self): + group = _make_sector_group() + assert len(group) == 1 + + def test_roi_width_and_average_present(self): + group = _make_sector_group(average=121.0) + measurements = {m.name: m.value for m in group.get_measurements()} + assert measurements[RetinalROIWidth] == 3.4 + assert measurements[RNFLAverageThickness] == 121.0 + + def test_roi_width_unit_is_mm(self): + group = _make_sector_group() + roi = next( + m for m in group.get_measurements() if m.name == RetinalROIWidth + ) + assert roi.unit == codes.UCUM.Millimeter + + def test_all_optional_quadrants(self): + group = _make_sector_group( + average=121.0, + inferior=145.0, + superior=138.0, + temporal=80.0, + nasal=95.0, + nasal_superior=90.0, + nasal_inferior=92.0, + temporal_inferior=78.0, + temporal_superior=82.0, + ) + # ROI width + 9 optional measurements + assert len(group.get_measurements()) == 10 + + def test_method_is_sector_method(self): + group = _make_sector_group() + assert group.method == GarwayHeathSectors + + def test_invalid_sector_method_raises(self): + with pytest.raises(ValueError, match="CID 4282"): + RNFLSectorMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + sector_method=codes.SCT.Eye, + retinal_roi_width=3.4, + ) + + def test_accepts_any_cid4282_method(self): + group = RNFLSectorMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + sector_method=QuadrantSectors, + retinal_roi_width=3.0, + ) + assert group.method == QuadrantSectors + + def test_template_id_is_2120(self): + group = _make_sector_group() + assert group[0].ContentTemplateSequence[0].TemplateIdentifier == '2120' + + +# --------------------------------------------------------------------------- +# RNFLClockfaceMeasurementsGroup (TID 2120, clockface role, TID 2123 Row 6) +# --------------------------------------------------------------------------- + + +class TestRNFLClockfaceMeasurementsGroup: + def test_basic_construction(self): + group = _make_clockface_group() + assert len(group) == 1 + + def test_method_is_fixed_clockface_method(self): + group = _make_clockface_group() + assert group.method == RNFLClockfaceMethod + + def test_positions_encode_correctly(self): + group = _make_clockface_group( + clockface_measurements={1: 100.0, 2: 105.0, 12: 98.0} + ) + measurements = {m.name.value: m.value for m in group.get_measurements()} + assert measurements['131276'] == 100.0 # position 1 + assert measurements['131277'] == 105.0 # position 2 + assert measurements['131287'] == 98.0 # position 12 + + def test_roi_width_optional_and_encodes(self): + group = _make_clockface_group(retinal_roi_width=3.6) + roi = next( + m for m in group.get_measurements() if m.name == RetinalROIWidth + ) + assert roi.value == 3.6 + assert roi.unit == codes.UCUM.Millimeter + + def test_empty_measurements_raises(self): + with pytest.raises(ValueError, match="at least one"): + RNFLClockfaceMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + clockface_measurements={}, + ) + + def test_invalid_position_raises(self): + with pytest.raises(ValueError, match="1 to 12"): + RNFLClockfaceMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + clockface_measurements={0: 100.0}, + ) + with pytest.raises(ValueError, match="1 to 12"): + RNFLClockfaceMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + clockface_measurements={13: 100.0}, + ) + + +# --------------------------------------------------------------------------- +# CircumpapillaryRNFLKeyMeasurements (TID 2123) +# --------------------------------------------------------------------------- + + +class TestCircumpapillaryRNFLKeyMeasurements: + def test_basic_construction_sector_only(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=[_make_sector_group()], + ) + assert len(report) == 1 + + def test_basic_construction_clockface_only(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + clockface_measurement_groups=[_make_clockface_group()], + ) + assert len(report) == 1 + + def test_sector_and_clockface_combined(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=[_make_sector_group()], + clockface_measurement_groups=[_make_clockface_group()], + ) + root = report[0] + # [0]=Language, [1][2]=AlgoId, [3]=sector group, [4]=clockface group + assert len(root.ContentSequence) == 5 + + def test_root_container_code(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=[_make_sector_group()], + ) + root = report[0] + assert root.ConceptNameCodeSequence[0].CodeValue == '131242' + assert root.ConceptNameCodeSequence[0].CodingSchemeDesignator == 'DCM' + + def test_template_id(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=[_make_sector_group()], + ) + assert report[0].ContentTemplateSequence[0].TemplateIdentifier == '2123' + + def test_algo_id_present(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo('MyOCT', '2.3'), + sector_measurement_groups=[_make_sector_group()], + ) + root = report[0] + algo_name_item = root.ContentSequence[1] + assert algo_name_item.TextValue == 'MyOCT' + + def test_bilateral_sector_requires_symmetry(self): + od = _make_sector_group(codes.cid247.Right, identifier='OD') + os_ = _make_sector_group(codes.cid247.Left, identifier='OS') + with pytest.raises(ValueError, match="rnfl_symmetry"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=[od, os_], + ) + + def test_bilateral_sector_with_symmetry_succeeds(self): + od = _make_sector_group(codes.cid247.Right, identifier='OD') + os_ = _make_sector_group(codes.cid247.Left, identifier='OS') + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=[od, os_], + rnfl_symmetry=97.3, + ) + root = report[0] + symmetry_items = [ + item for item in root.ContentSequence + if getattr(item, 'ValueType', None) == 'NUM' and + item.ConceptNameCodeSequence[0].CodeValue == '131273' + ] + assert len(symmetry_items) == 1 + assert float(symmetry_items[0].MeasuredValueSequence[0].NumericValue) == 97.3 + + def test_bilateral_across_sector_and_clockface_requires_symmetry(self): + """Bilaterality is judged across both group lists combined.""" + od = _make_sector_group(codes.cid247.Right, identifier='OD') + os_ = _make_clockface_group(codes.cid247.Left, identifier='OS') + with pytest.raises(ValueError, match="rnfl_symmetry"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=[od], + clockface_measurement_groups=[os_], + ) + + def test_unilateral_rejects_symmetry(self): + with pytest.raises(ValueError, match="only applicable"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=[_make_sector_group()], + rnfl_symmetry=97.3, + ) + + def test_neither_group_list_raises(self): + with pytest.raises(ValueError, match="At least one"): + CircumpapillaryRNFLKeyMeasurements(algorithm_id=_make_algo()) + + def test_more_than_two_sector_groups_raises(self): + with pytest.raises(ValueError, match="at most two"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=[ + _make_sector_group(identifier='1'), + _make_sector_group(identifier='2'), + _make_sector_group(identifier='3'), + ], + ) + + def test_more_than_two_clockface_groups_raises(self): + with pytest.raises(ValueError, match="at most two"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + clockface_measurement_groups=[ + _make_clockface_group(identifier='1'), + _make_clockface_group(identifier='2'), + _make_clockface_group(identifier='3'), + ], + ) + + def test_wrong_algo_type_raises(self): + with pytest.raises(TypeError, match="AlgorithmIdentification"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id="not an algo", + sector_measurement_groups=[_make_sector_group()], + ) + + def test_wrong_sector_group_type_raises(self): + with pytest.raises(TypeError, match="RNFLSectorMeasurementsGroup"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=["not a group"], + ) + + def test_wrong_clockface_group_type_raises(self): + with pytest.raises(TypeError, match="RNFLClockfaceMeasurementsGroup"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + clockface_measurement_groups=["not a group"], + ) + + def test_rnfl_codes_are_final_dcm_codes(self): + """RNFL measurement concept names use final, ratified DCM codes.""" + expected = { + RNFLAverageThickness: '131264', + RNFLInferiorThickness: '131265', + RNFLSuperiorThickness: '131266', + RNFLTemporalThickness: '131267', + RNFLNasalThickness: '131268', + RetinalROIWidth: '131274', + RNFLSymmetry: '131273', + } + for concept, value in expected.items(): + assert concept.scheme_designator == 'DCM' + assert concept.value == value + + +# --------------------------------------------------------------------------- +# MacularMeasurementsGroup (TID 2120, macular role, TID 2124 Row 5) +# --------------------------------------------------------------------------- + + +class TestMacularMeasurementsGroup: + def test_basic_construction(self): + group = _make_macular_group() + assert len(group) == 1 + + def test_center_subfield_value(self): + group = _make_macular_group(281.4) + measurements = {m.name: m.value for m in group.get_measurements()} + assert measurements[MacularCenterSubfieldThickness] == 281.4 + + def test_total_volume_unit_is_ul(self): + group = MacularMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + total_volume=8.42, + ) + vol = next( + m for m in group.get_measurements() if m.name == MacularTotalVolume + ) + assert vol.unit == UCUM_MICROLITER + + def test_full_etdrs_grid(self): + group = MacularMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + center_point=270.0, + center_subfield=281.4, + inner_superior=338.0, + inner_nasal=350.0, + inner_inferior=335.0, + inner_temporal=320.0, + outer_superior=290.0, + outer_nasal=305.0, + outer_inferior=285.0, + outer_temporal=265.0, + total_volume=8.42, + average_thickness=300.1, + ) + # 11 micrometer-unit measurements + total volume + assert len(group.get_measurements()) == 12 + + def test_no_measurements_raises(self): + with pytest.raises(ValueError, match="At least one"): + MacularMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, + ) + + def test_template_id_is_2120(self): + group = _make_macular_group() + assert group[0].ContentTemplateSequence[0].TemplateIdentifier == '2120' + + +# --------------------------------------------------------------------------- +# MacularThicknessKeyMeasurements (TID 2124) +# --------------------------------------------------------------------------- + + +class TestMacularThicknessKeyMeasurements: + def test_basic_construction(self): + report = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[_make_macular_group()], + ) + assert len(report) == 1 + + def test_root_container_code(self): + report = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[_make_macular_group()], + ) + root = report[0] + assert root.ConceptNameCodeSequence[0].CodeValue == '131243' + assert root.ConceptNameCodeSequence[0].CodingSchemeDesignator == 'DCM' + + def test_template_id(self): + report = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[_make_macular_group()], + ) + assert report[0].ContentTemplateSequence[0].TemplateIdentifier == '2124' + + def test_content_sequence_structure(self): + report = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[_make_macular_group()], + ) + root = report[0] + # [0]=Language, [1],[2]=AlgoId (2 items), [3]=Measurement Group + assert len(root.ContentSequence) == 4 + + def test_etdrs_codes_are_loinc(self): + """ETDRS subfield measurements use LOINC (LN) scheme.""" + loinc_concepts = [ + MacularCenterSubfieldThickness, + MacularInnerSuperiorThickness, + MacularInnerNasalThickness, + MacularInnerInferiorThickness, + MacularInnerTemporalThickness, + MacularOuterSuperiorThickness, + MacularOuterNasalThickness, + MacularOuterInferiorThickness, + MacularOuterTemporalThickness, + MacularTotalVolume, + ] + for concept in loinc_concepts: + assert concept.scheme_designator == 'LN', ( + f"{concept.meaning} should use LN, got {concept.scheme_designator}" + ) + + def test_average_macular_thickness_is_final_dcm_code(self): + assert AverageMacularThickness.scheme_designator == 'DCM' + assert AverageMacularThickness.value == '131255' + + def test_ucum_microliter_constant(self): + """UCUM_MICROLITER is 'uL' from scheme UCUM.""" + assert UCUM_MICROLITER.value == 'uL' + assert UCUM_MICROLITER.scheme_designator == 'UCUM' + + def test_bilateral_macular(self): + od = _make_macular_group(281.4, codes.cid247.Right) + os_ = _make_macular_group(275.0, codes.cid247.Left) + report = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[od, os_], + ) + root = report[0] + # [0]=Language, [1],[2]=AlgoId, [3]=OD, [4]=OS + assert len(root.ContentSequence) == 5 + + assert od.get_measurements()[0].value == 281.4 + assert os_.get_measurements()[0].value == 275.0 + + def test_more_than_two_groups_raises(self): + with pytest.raises(ValueError, match="at most two"): + MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[ + _make_macular_group(), + _make_macular_group(), + _make_macular_group(), + ], + ) + + def test_wrong_group_type_raises(self): + with pytest.raises(TypeError, match="MacularMeasurementsGroup"): + MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=["not a group"], + ) + + +# --------------------------------------------------------------------------- +# Roundtrip tests — template → ComprehensiveSR → dcmwrite → dcmread +# --------------------------------------------------------------------------- + + +class TestRoundtrip: + """Verify that SR content survives serialisation to DICOM bytes.""" + + @pytest.fixture + def ref_ds(self): + return dcmread(str(_DATA_DIR / 'ct_image.dcm')) + + def test_rnfl_roundtrip(self, ref_ds): + """TID 2123: numeric value 121.0 µm and code '131264' survive dcmread.""" + group = _make_sector_group(average=121.0) + template = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + sector_measurement_groups=[group], + ) + + sr = ComprehensiveSR( + evidence=[ref_ds], + content=template, + series_instance_uid=generate_uid(), + series_number=1, + sop_instance_uid=generate_uid(), + instance_number=1, + manufacturer='Revo', + ) + with BytesIO() as buf: + sr.save_as(buf) + buf.seek(0) + sr_back = dcmread(buf) + + # After dcmread, sr_back.ContentSequence lists the ROOT container's + # children directly: [0]=Language, [1][2]=AlgoId, [3]=MeasGroup + mg = sr_back.ContentSequence[3] + assert mg.ValueType == 'CONTAINER' + meas_items = { + item.ConceptNameCodeSequence[0].CodeValue: item + for item in mg.ContentSequence if item.ValueType == 'NUM' + } + avg_item = meas_items['131264'] + assert float(avg_item.MeasuredValueSequence[0].NumericValue) == 121.0 + assert avg_item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0].CodeValue == 'um' + # Retinal ROI width (mandatory) also survives + roi_item = meas_items['131274'] + assert float(roi_item.MeasuredValueSequence[0].NumericValue) == 3.4 + + def test_macular_roundtrip_loinc_code_survives(self, ref_ds): + """TID 2124: LOINC code '57109-1' (CMT) survives serialisation.""" + group = _make_macular_group(281.4) + template = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[group], + ) + + sr = ComprehensiveSR( + evidence=[ref_ds], + content=template, + series_instance_uid=generate_uid(), + series_number=1, + sop_instance_uid=generate_uid(), + instance_number=1, + manufacturer='Cirrus', + ) + with BytesIO() as buf: + sr.save_as(buf) + buf.seek(0) + sr_back = dcmread(buf) + + # After dcmread, sr_back.ContentSequence lists the ROOT container's + # children directly: [0]=Language, [1][2]=AlgoId, [3]=MeasGroup + mg = sr_back.ContentSequence[3] + assert mg.ValueType == 'CONTAINER' + meas_items = [ + item for item in mg.ContentSequence if item.ValueType == 'NUM' + ] + assert len(meas_items) == 1 + meas_item = meas_items[0] + assert float(meas_item.MeasuredValueSequence[0].NumericValue) == 281.4 + assert meas_item.ConceptNameCodeSequence[0].CodeValue == '57109-1' + assert meas_item.ConceptNameCodeSequence[0].CodingSchemeDesignator == 'LN'