From 4fa928893c1bb481219bdf4f812b837302dde685 Mon Sep 17 00:00:00 2001 From: Noel Jaurrieta Date: Fri, 10 Apr 2026 19:47:46 -0600 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20restructure=20sr/templates=20?= =?UTF-8?q?=E2=86=92=20package;=20add=20Supplement=20247=20TIDs=206001/600?= =?UTF-8?q?4/6005?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure ----------- - src/highdicom/sr/templates.py → src/highdicom/sr/templates/tid1500.py - New src/highdicom/sr/templates/__init__.py re-exports every name that was previously importable from highdicom.sr.templates (backwards-compat). - All 294 existing SR tests pass unchanged. Supplement 247 — Eyecare Measurement Templates ----------------------------------------------- New module src/highdicom/sr/templates/tid6000.py implements: TID 6001 OphthalmologyMeasurementsGroup Sub-template (analogous to TID 1501). Wraps one eye's measurements with mandatory Finding Site and Laterality (CID 4209 / CID 244). Container concept: (125007, DCM, "Measurement Group"). TID 6004 CircumpapillaryRNFLKeyMeasurements Root template. Wraps 1–2 TID 6001 groups with mandatory AlgorithmIdentification (TID 4019). CID 42x3 RNFL concept codes (nnn400–nnn404) are provisional (Sup247 draft) and encoded under scheme 99OPHTHALMO pending the parallel pydicom PR that will add the final CID entries. TID 6005 MacularThicknessKeyMeasurements Root template. Same structure as TID 6004. CID 42x4 macular measurements use finalised LOINC codes (57108-3 through 57118-2); average macular thickness (nnn250) is provisional. All three classes are re-exported from highdicom.sr (public API). Validated with real OCT data (Siltspook: CMT=288.49 µm, RNFL avg=121 µm). Reference: DICOM PS3.16 Supplement 247 (incorporated 2025c) https://dicom.nema.org/Dicom/News/January2025/docs/sups/sup247.pdf Closes: ImagingDataCommons/highdicom#406 (draft) --- src/highdicom/sr/__init__.py | 3 + src/highdicom/sr/templates/__init__.py | 79 +++ .../sr/{templates.py => templates/tid1500.py} | 0 src/highdicom/sr/templates/tid6000.py | 506 ++++++++++++++++++ 4 files changed, 588 insertions(+) create mode 100644 src/highdicom/sr/templates/__init__.py rename src/highdicom/sr/{templates.py => templates/tid1500.py} (100%) create mode 100644 src/highdicom/sr/templates/tid6000.py diff --git a/src/highdicom/sr/__init__.py b/src/highdicom/sr/__init__.py index 4d1c31a9b..8bd434768 100644 --- a/src/highdicom/sr/__init__.py +++ b/src/highdicom/sr/__init__.py @@ -33,10 +33,12 @@ ) from highdicom.sr.templates import ( AlgorithmIdentification, + CircumpapillaryRNFLKeyMeasurements, DeviceObserverIdentifyingAttributes, ImageLibrary, ImageLibraryEntryDescriptors, LanguageOfContentItemAndDescendants, + MacularThicknessKeyMeasurements, Measurement, MeasurementProperties, MeasurementReport, @@ -45,6 +47,7 @@ NormalRangeProperties, ObserverContext, ObservationContext, + OphthalmologyMeasurementsGroup, PersonObserverIdentifyingAttributes, PlanarROIMeasurementsAndQualitativeEvaluations, QualitativeEvaluation, diff --git a/src/highdicom/sr/templates/__init__.py b/src/highdicom/sr/templates/__init__.py new file mode 100644 index 000000000..2ac6e0ce0 --- /dev/null +++ b/src/highdicom/sr/templates/__init__.py @@ -0,0 +1,79 @@ +"""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:`tid6000` — Supplement 247 Eyecare Measurement Templates (TID 6001–6009) +""" + +# 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, +) + +# Supplement 247 — Eyecare Measurement Templates +from highdicom.sr.templates.tid6000 import ( + CircumpapillaryRNFLKeyMeasurements, + MacularThicknessKeyMeasurements, + OphthalmologyMeasurementsGroup, +) + +__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", + # Supplement 247 family + "OphthalmologyMeasurementsGroup", + "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/tid6000.py b/src/highdicom/sr/templates/tid6000.py new file mode 100644 index 000000000..46e940633 --- /dev/null +++ b/src/highdicom/sr/templates/tid6000.py @@ -0,0 +1,506 @@ +"""DICOM SR templates for Supplement 247 — Eyecare Measurement Templates. + +Implements TIDs 6001–6005 as defined in DICOM Supplement 247 +"Eyecare Measurement Templates", incorporated into DICOM PS3.16 2025c. + +Reference: DICOM PS3.16 Supplement 247 + https://dicom.nema.org/Dicom/News/January2025/docs/sups/sup247.pdf + +Note on provisional concept codes +---------------------------------- +Supplement 247 was balloted as a draft at the time of this implementation. +Several DCM concept codes appear in the draft as ``nnnXXX`` placeholders +(i.e. final code values had not yet been assigned by DICOM WG-09). +Those placeholders are marked with ``# Sup247 draft — nnnXXX`` comments +and defined as private ``CodedConcept`` objects using the scheme designator +``99OPHTHALMO`` (≤ 16 chars, required for DICOM VR SH). + +A companion PR to pydicom will replace these with the final CID entries +once the official code assignments are published. All code objects that +already have stable identifiers (LOINC, SNOMED-CT, or established DCM +values) use the canonical pydicom ``codes.*`` accessors. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from pydicom.sr.codedict import codes +from pydicom.sr.coding import Code + +from highdicom.sr.coding import CodedConcept +from highdicom.sr.enum import RelationshipTypeValues +from highdicom.sr.value_types import ( + CodeContentItem, + ContainerContentItem, + ContentSequence, + NumContentItem, +) +from highdicom.sr.templates.tid1500 import ( + AlgorithmIdentification, + LanguageOfContentItemAndDescendants, + Measurement, + Template, +) + +# --------------------------------------------------------------------------- +# Provisional concept codes — Supplement 247 draft (nnnXXX placeholders). +# Scheme 99HIGHDICOM-EYECARE is a private designator that will be replaced +# by official DCM codes once DICOM WG-09 assigns them. +# --------------------------------------------------------------------------- +_SCHEME = '99OPHTHALMO' + +# Container concept names for the root templates (TID 60x3–60x9) +# Sup247 draft table rows: EV (nnn101–nnn107, DCM, "...") +_CONCEPT_OPTIC_DISC_KEY = CodedConcept( + value='nnn101', scheme_designator=_SCHEME, + meaning='Optic Disc Key Measurements' +) +_CONCEPT_RNFL_KEY = CodedConcept( + value='nnn102', scheme_designator=_SCHEME, + meaning='RNFL Key Measurements' +) +_CONCEPT_MACULAR_THICKNESS_KEY = CodedConcept( + value='nnn103', scheme_designator=_SCHEME, + meaning='Macular Thickness Key Measurements' +) + +# --------------------------------------------------------------------------- +# CID 42x3 — RNFL Key Measurements (all µm, UCUM "um") +# Sup247 draft Table CID 42x3: nnn400–nnn404, nnn406, nnn411–nnn422 +# --------------------------------------------------------------------------- +RNFLAverageThickness = CodedConcept( + value='nnn400', scheme_designator=_SCHEME, + meaning='Retinal nerve fiber layer average thickness' +) +RNFLInferiorThickness = CodedConcept( + value='nnn401', scheme_designator=_SCHEME, + meaning='Retinal nerve fiber layer inferior thickness' +) +RNFLSuperiorThickness = CodedConcept( + value='nnn402', scheme_designator=_SCHEME, + meaning='Retinal nerve fiber layer superior thickness' +) +RNFLTemporalThickness = CodedConcept( + value='nnn403', scheme_designator=_SCHEME, + meaning='Retinal nerve fiber layer temporal thickness' +) +RNFLNasalThickness = CodedConcept( + value='nnn404', scheme_designator=_SCHEME, + meaning='Retinal nerve fiber layer nasal thickness' +) +RetinalROIRadius = CodedConcept( + value='nnn406', scheme_designator=_SCHEME, + meaning='Retinal ROI radius' +) + +# --------------------------------------------------------------------------- +# CID 42x4 — Macular Thickness Key Measurements +# LOINC codes are already finalised in DICOM 2025c for the ETDRS grid. +# nnn250 (Average macular thickness) remains a Sup247 placeholder. +# --------------------------------------------------------------------------- +# LOINC ETDRS grid codes — stable, no pydicom shortcut yet +MacularCenterPointThickness = CodedConcept( + value='57108-3', scheme_designator='LN', + meaning='Macular grid.center point thickness by OCT' +) +MacularCenterSubfieldThickness = CodedConcept( + value='57109-1', scheme_designator='LN', + meaning='Macular grid.center subfield thickness by OCT' +) +MacularInnerSuperiorThickness = CodedConcept( + value='57110-9', scheme_designator='LN', + meaning='Macular grid.inner superior subfield thickness by OCT' +) +MacularInnerNasalThickness = CodedConcept( + value='57111-7', scheme_designator='LN', + meaning='Macular grid.inner nasal subfield thickness by OCT' +) +MacularInnerInferiorThickness = CodedConcept( + value='57112-5', scheme_designator='LN', + meaning='Macular grid.inner inferior subfield thickness by OCT' +) +MacularInnerTemporalThickness = CodedConcept( + value='57113-3', scheme_designator='LN', + meaning='Macular grid.inner temporal subfield thickness by OCT' +) +MacularOuterSuperiorThickness = CodedConcept( + value='57114-1', scheme_designator='LN', + meaning='Macular grid.outer superior subfield thickness by OCT' +) +MacularOuterNasalThickness = CodedConcept( + value='57115-8', scheme_designator='LN', + meaning='Macular grid.outer nasal subfield thickness by OCT' +) +MacularOuterInferiorThickness = CodedConcept( + value='57116-6', scheme_designator='LN', + meaning='Macular grid.outer inferior subfield thickness by OCT' +) +MacularOuterTemporalThickness = CodedConcept( + value='57117-4', scheme_designator='LN', + meaning='Macular grid.outer temporal subfield thickness by OCT' +) +MacularTotalVolume = CodedConcept( + value='57118-2', scheme_designator='LN', + meaning='Macular grid.total volume by OCT' +) +# Sup247 draft nnn250 — provisional +AverageMacularThickness = CodedConcept( + value='nnn250', scheme_designator=_SCHEME, + meaning='Average macular thickness' +) + + +# --------------------------------------------------------------------------- +# TID 6001 (Sup247 TID 60x1) — Ophthalmology Measurements Group +# --------------------------------------------------------------------------- + +class OphthalmologyMeasurementsGroup(Template): + """:dcm:`TID 6001 ` + Ophthalmology Measurements Group + + A sub-template specialised for ophthalmic measurements, structurally + equivalent to TID 1501 *Measurement and Qualitative Evaluation Group* + but restricted to ophthalmic anatomy (finding site "Eye") and with + mandatory laterality. + + This template is invoked by the root eyecare templates (TID 6003–6009) + once per eye measured. It wraps the actual measurement + :class:`~highdicom.sr.Measurement` items together with the anatomic + context (finding site and laterality) required by Supplement 247. + + Reference: DICOM PS3.16 Supplement 247, Table TID 60x1 + """ + + def __init__( + self, + laterality: CodedConcept | Code, + measurements: Sequence[Measurement], + finding_site: CodedConcept | Code | None = None, + tracking_identifier: str | None = None, + ) -> None: + """ + Parameters + ---------- + laterality: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code] + Laterality of the eye measured (see + :dcm:`CID 244 ` "Laterality" for + options; typically ``codes.cid244.Right`` or + ``codes.cid244.Left``). + measurements: Sequence[highdicom.sr.Measurement] + One or more measurements belonging to this group. Each + :class:`~highdicom.sr.Measurement` (TID 300) encodes a single + numeric finding (e.g. average RNFL 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`` when not provided. + tracking_identifier: str, optional + Free-text tracking identifier for this measurement group (row 6 + of TID 60x1, concept ``(112039, DCM, "Tracking Identifier")``). + + """ # noqa: E501 + super().__init__() + + if finding_site is None: + finding_site = codes.cid4209.Eye + + item = ContainerContentItem( + name=codes.DCM.MeasurementGroup, + template_id='6001', + relationship_type=RelationshipTypeValues.CONTAINS, + ) + item.ContentSequence = ContentSequence() + + # Row 2 — Finding Site (mandatory) + site_item = CodeContentItem( + name=CodedConcept( + value='363698007', + scheme_designator='SCT', + meaning='Finding Site', + ), + value=finding_site, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD, + ) + item.ContentSequence.append(site_item) + + # Row 3 — Laterality (mandatory) + lat_item = CodeContentItem( + name=CodedConcept( + value='272741003', + scheme_designator='SCT', + meaning='Laterality', + ), + value=laterality, + relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD, + ) + item.ContentSequence.append(lat_item) + + # Row 6 — Tracking Identifier (optional) + if tracking_identifier is not None: + from highdicom.sr.value_types import TextContentItem + tracking_item = TextContentItem( + name=CodedConcept( + value='112039', + scheme_designator='DCM', + meaning='Tracking Identifier', + ), + value=tracking_identifier, + relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT, + ) + item.ContentSequence.append(tracking_item) + + # Rows 8/10 — Measurements (mandatory, 1-n) + if not measurements: + raise ValueError( + "Argument 'measurements' must contain at least one item." + ) + for meas in measurements: + if not isinstance(meas, Measurement): + raise TypeError( + 'Each item of "measurements" must have type Measurement.' + ) + item.ContentSequence.extend(meas) + + self.append(item) + + +# --------------------------------------------------------------------------- +# TID 6004 (Sup247 TID 60x4) — Retinal Nerve Fiber Layer Key Measurements +# --------------------------------------------------------------------------- + +class CircumpapillaryRNFLKeyMeasurements(Template): + """:dcm:`TID 6004 ` + Retinal Nerve Fiber Layer Key Measurements + + Root template for circumpapillary RNFL thickness measurements obtained + by ophthalmic tomography (OCT). Encodes the key quadrant and average + RNFL values as defined by CID 42x3 (RNFL Key Measurements) of + Supplement 247. + + Usage:: + + from highdicom.sr.templates import ( + AlgorithmIdentification, + CircumpapillaryRNFLKeyMeasurements, + Measurement, + OphthalmologyMeasurementsGroup, + ) + from highdicom.sr.templates.tid6000 import ( + RNFLAverageThickness, + RNFLSuperiorThickness, + RNFLInferiorThickness, + ) + from pydicom.sr.codedict import codes + from pydicom.sr.coding import Code + + algo = AlgorithmIdentification(name='Revo FC', version='1.0') + meas = [ + Measurement( + name=RNFLAverageThickness, + value=121.0, + unit=codes.UCUM.Micrometer, + ), + Measurement( + name=RNFLSuperiorThickness, + value=135.0, + unit=codes.UCUM.Micrometer, + ), + ] + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=meas, + ) + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=algo, + measurement_groups=[group], + ) + + Reference: DICOM PS3.16 Supplement 247, Table TID 60x4 + """ + + def __init__( + self, + algorithm_id: AlgorithmIdentification, + measurement_groups: Sequence[OphthalmologyMeasurementsGroup], + 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 per Supplement 247 row 4). + measurement_groups: Sequence[highdicom.sr.OphthalmologyMeasurementsGroup] + One or two measurement groups (TID 6001), one per eye. Each + group carries the laterality and the individual RNFL thickness + measurements from CID 42x3. + language_of_content_item_and_descendants: Union[highdicom.sr.LanguageOfContentItemAndDescendants, None], optional + Language specification for all content items (defaults to English + when omitted). + + """ # noqa: E501 + super().__init__() + + 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, OphthalmologyMeasurementsGroup): + raise TypeError( + 'Each item of "measurement_groups" must have type ' + 'OphthalmologyMeasurementsGroup.' + ) + + item = ContainerContentItem( + name=_CONCEPT_RNFL_KEY, + template_id='6004', + ) + item.ContentSequence = ContentSequence() + + # Row 2 — Language (optional) + if language_of_content_item_and_descendants is None: + from highdicom.sr.templates.tid1500 import DEFAULT_LANGUAGE + language_of_content_item_and_descendants = \ + LanguageOfContentItemAndDescendants(DEFAULT_LANGUAGE) + item.ContentSequence.extend(language_of_content_item_and_descendants) + + # Row 4 — Algorithm Identification (mandatory) + if not isinstance(algorithm_id, AlgorithmIdentification): + raise TypeError( + 'Argument "algorithm_id" must have type ' + 'AlgorithmIdentification.' + ) + item.ContentSequence.extend(algorithm_id) + + # Row 5 — Measurement Groups (1-2, mandatory) + for group in measurement_groups: + item.ContentSequence.extend(group) + + super().__init__([item], is_root=True) + + +# --------------------------------------------------------------------------- +# TID 6005 (Sup247 TID 60x5) — Macular Thickness Key Measurements +# --------------------------------------------------------------------------- + +class MacularThicknessKeyMeasurements(Template): + """:dcm:`TID 6005 ` + Macular Thickness Key Measurements + + Root template for ETDRS-grid macular thickness measurements obtained + by ophthalmic tomography (OCT). Encodes the central subfield (CMT) + and the inner/outer ring sector values defined by CID 42x4 (Macular + Thickness Key Measurements) of Supplement 247. + + The ETDRS grid concept names are encoded using their LOINC codes + (``57108-3`` through ``57118-2``), which are finalised in the standard. + + Usage:: + + from highdicom.sr.templates import ( + AlgorithmIdentification, + MacularThicknessKeyMeasurements, + Measurement, + OphthalmologyMeasurementsGroup, + ) + from highdicom.sr.templates.tid6000 import MacularCenterSubfieldThickness + from pydicom.sr.codedict import codes + + algo = AlgorithmIdentification(name='Cirrus HD-OCT', version='11.0') + meas = [ + Measurement( + name=MacularCenterSubfieldThickness, + value=288.49, + unit=codes.UCUM.Micrometer, + ), + ] + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=meas, + ) + report = MacularThicknessKeyMeasurements( + algorithm_id=algo, + measurement_groups=[group], + ) + + Reference: DICOM PS3.16 Supplement 247, Table TID 60x5 + """ + + def __init__( + self, + algorithm_id: AlgorithmIdentification, + measurement_groups: Sequence[OphthalmologyMeasurementsGroup], + 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 per Supplement 247 + row 4). + measurement_groups: Sequence[highdicom.sr.OphthalmologyMeasurementsGroup] + One or two measurement groups (TID 6001), one per eye. Each + group carries the laterality and the individual macular thickness + measurements from CID 42x4. + language_of_content_item_and_descendants: Union[highdicom.sr.LanguageOfContentItemAndDescendants, None], optional + Language specification for all content items (defaults to English + when omitted). + + """ # noqa: E501 + super().__init__() + + 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, OphthalmologyMeasurementsGroup): + raise TypeError( + 'Each item of "measurement_groups" must have type ' + 'OphthalmologyMeasurementsGroup.' + ) + + item = ContainerContentItem( + name=_CONCEPT_MACULAR_THICKNESS_KEY, + template_id='6005', + ) + item.ContentSequence = ContentSequence() + + # Row 2 — Language (optional) + if language_of_content_item_and_descendants is None: + from highdicom.sr.templates.tid1500 import DEFAULT_LANGUAGE + language_of_content_item_and_descendants = \ + LanguageOfContentItemAndDescendants(DEFAULT_LANGUAGE) + item.ContentSequence.extend(language_of_content_item_and_descendants) + + # Row 4 — Algorithm Identification (mandatory) + if not isinstance(algorithm_id, AlgorithmIdentification): + raise TypeError( + 'Argument "algorithm_id" must have type ' + 'AlgorithmIdentification.' + ) + item.ContentSequence.extend(algorithm_id) + + # Row 5 — Measurement Groups (1-2, mandatory) + for group in measurement_groups: + item.ContentSequence.extend(group) + + super().__init__([item], is_root=True) From 5c08dbebc17488fbdb47324f78d157c637f7c190 Mon Sep 17 00:00:00 2001 From: Noel Jaurrieta Date: Fri, 10 Apr 2026 22:50:15 -0600 Subject: [PATCH 2/6] fix: correct TID 6001 laterality nesting per Supplement 247 table (Row 3 >>) --- src/highdicom/sr/templates/tid6000.py | 417 +++++++++++++++++--------- 1 file changed, 274 insertions(+), 143 deletions(-) diff --git a/src/highdicom/sr/templates/tid6000.py b/src/highdicom/sr/templates/tid6000.py index 46e940633..ee1f3de63 100644 --- a/src/highdicom/sr/templates/tid6000.py +++ b/src/highdicom/sr/templates/tid6000.py @@ -1,6 +1,6 @@ """DICOM SR templates for Supplement 247 — Eyecare Measurement Templates. -Implements TIDs 6001–6005 as defined in DICOM Supplement 247 +Implements TIDs 6001, 6004, and 6005 as defined in DICOM Supplement 247 "Eyecare Measurement Templates", incorporated into DICOM PS3.16 2025c. Reference: DICOM PS3.16 Supplement 247 @@ -8,17 +8,31 @@ Note on provisional concept codes ---------------------------------- -Supplement 247 was balloted as a draft at the time of this implementation. -Several DCM concept codes appear in the draft as ``nnnXXX`` placeholders -(i.e. final code values had not yet been assigned by DICOM WG-09). -Those placeholders are marked with ``# Sup247 draft — nnnXXX`` comments -and defined as private ``CodedConcept`` objects using the scheme designator -``99OPHTHALMO`` (≤ 16 chars, required for DICOM VR SH). - -A companion PR to pydicom will replace these with the final CID entries -once the official code assignments are published. All code objects that -already have stable identifiers (LOINC, SNOMED-CT, or established DCM -values) use the canonical pydicom ``codes.*`` accessors. +Supplement 247 was balloted with several DCM concept codes shown as +``nnnXXX`` placeholders — the final numeric values had not yet been +assigned by DICOM WG-09 at time of publication. Those codes are defined +here as private ``CodedConcept`` objects under scheme ``99OPHTHALMO`` +(≤ 16 chars, required for DICOM VR SH) and are marked with +``# Sup247 draft — nnnXXX`` inline comments. + +Codes pending a companion PR to pydicom (``pydicom.sr.codedict``): + +Container concept names (99OPHTHALMO, draft placeholders): + - ``nnn102`` — "RNFL Key Measurements" + - ``nnn103`` — "Macular Thickness Key Measurements" + +CID 42x3 RNFL Key Measurements (99OPHTHALMO): + - ``nnn400`` – average thickness + - ``nnn401–nnn404`` – inferior / superior / temporal / nasal quadrants + - ``nnn406`` – retinal ROI radius + +CID 42x4 Macular Thickness Key Measurements: + - ``nnn250`` – average macular thickness (99OPHTHALMO) + - ``LN 57108-3`` through ``LN 57118-2`` – ETDRS grid (LOINC, stable, + not yet exposed as convenience attributes in ``pydicom.sr.codedict``) + +No ``coding_ophthalmology.py`` is created in this PR; per CPBridge guidance +in ImagingDataCommons/highdicom#406, new CIDs belong in a parallel pydicom PR. """ from __future__ import annotations @@ -34,28 +48,32 @@ CodeContentItem, ContainerContentItem, ContentSequence, - NumContentItem, + TextContentItem, + UIDRefContentItem, ) from highdicom.sr.templates.tid1500 import ( AlgorithmIdentification, + DEFAULT_LANGUAGE, LanguageOfContentItemAndDescendants, Measurement, Template, ) +# --------------------------------------------------------------------------- +# 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') + # --------------------------------------------------------------------------- # Provisional concept codes — Supplement 247 draft (nnnXXX placeholders). -# Scheme 99HIGHDICOM-EYECARE is a private designator that will be replaced -# by official DCM codes once DICOM WG-09 assigns them. # --------------------------------------------------------------------------- _SCHEME = '99OPHTHALMO' -# Container concept names for the root templates (TID 60x3–60x9) -# Sup247 draft table rows: EV (nnn101–nnn107, DCM, "...") -_CONCEPT_OPTIC_DISC_KEY = CodedConcept( - value='nnn101', scheme_designator=_SCHEME, - meaning='Optic Disc Key Measurements' -) +# Container concept names for root templates TID 6004 and 6005. +# Sup247 draft: EV (nnn102, DCM, "RNFL Key Measurements") etc. _CONCEPT_RNFL_KEY = CodedConcept( value='nnn102', scheme_designator=_SCHEME, meaning='RNFL Key Measurements' @@ -66,86 +84,125 @@ ) # --------------------------------------------------------------------------- -# CID 42x3 — RNFL Key Measurements (all µm, UCUM "um") -# Sup247 draft Table CID 42x3: nnn400–nnn404, nnn406, nnn411–nnn422 +# CID 42x3 — RNFL Key Measurements (Supplement 247 Table CID 42x3) +# All measurements in µm; unit: (um, UCUM, "um"). +# Codes are Sup247 draft placeholders under 99OPHTHALMO. # --------------------------------------------------------------------------- -RNFLAverageThickness = CodedConcept( + +#: Average circumpapillary RNFL thickness. Sup247 draft nnn400. +RNFLAverageThickness: CodedConcept = CodedConcept( value='nnn400', scheme_designator=_SCHEME, meaning='Retinal nerve fiber layer average thickness' ) -RNFLInferiorThickness = CodedConcept( + +#: Inferior quadrant RNFL thickness. Sup247 draft nnn401. +RNFLInferiorThickness: CodedConcept = CodedConcept( value='nnn401', scheme_designator=_SCHEME, meaning='Retinal nerve fiber layer inferior thickness' ) -RNFLSuperiorThickness = CodedConcept( + +#: Superior quadrant RNFL thickness. Sup247 draft nnn402. +RNFLSuperiorThickness: CodedConcept = CodedConcept( value='nnn402', scheme_designator=_SCHEME, meaning='Retinal nerve fiber layer superior thickness' ) -RNFLTemporalThickness = CodedConcept( + +#: Temporal quadrant RNFL thickness. Sup247 draft nnn403. +RNFLTemporalThickness: CodedConcept = CodedConcept( value='nnn403', scheme_designator=_SCHEME, meaning='Retinal nerve fiber layer temporal thickness' ) -RNFLNasalThickness = CodedConcept( + +#: Nasal quadrant RNFL thickness. Sup247 draft nnn404. +RNFLNasalThickness: CodedConcept = CodedConcept( value='nnn404', scheme_designator=_SCHEME, meaning='Retinal nerve fiber layer nasal thickness' ) -RetinalROIRadius = CodedConcept( + +#: Radius of the circumpapillary circle scan used for RNFL measurement, in mm. +#: Sup247 draft nnn406. +RetinalROIRadius: CodedConcept = CodedConcept( value='nnn406', scheme_designator=_SCHEME, meaning='Retinal ROI radius' ) # --------------------------------------------------------------------------- -# CID 42x4 — Macular Thickness Key Measurements -# LOINC codes are already finalised in DICOM 2025c for the ETDRS grid. -# nnn250 (Average macular thickness) remains a Sup247 placeholder. +# CID 42x4 — Macular Thickness Key Measurements (Supplement 247 Table CID 42x4) +# +# The nine ETDRS grid subfields and total volume use LOINC codes that are +# finalised in DICOM 2025c. The "average macular thickness" concept (nnn250) +# remains a Sup247 draft placeholder. # --------------------------------------------------------------------------- -# LOINC ETDRS grid codes — stable, no pydicom shortcut yet -MacularCenterPointThickness = CodedConcept( + +#: 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' ) -MacularCenterSubfieldThickness = CodedConcept( + +#: 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' ) -MacularInnerSuperiorThickness = CodedConcept( + +#: 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' ) -MacularInnerNasalThickness = CodedConcept( + +#: Inner nasal subfield. LOINC 57111-7. +MacularInnerNasalThickness: CodedConcept = CodedConcept( value='57111-7', scheme_designator='LN', meaning='Macular grid.inner nasal subfield thickness by OCT' ) -MacularInnerInferiorThickness = CodedConcept( + +#: Inner inferior subfield. LOINC 57112-5. +MacularInnerInferiorThickness: CodedConcept = CodedConcept( value='57112-5', scheme_designator='LN', meaning='Macular grid.inner inferior subfield thickness by OCT' ) -MacularInnerTemporalThickness = CodedConcept( + +#: Inner temporal subfield. LOINC 57113-3. +MacularInnerTemporalThickness: CodedConcept = CodedConcept( value='57113-3', scheme_designator='LN', meaning='Macular grid.inner temporal subfield thickness by OCT' ) -MacularOuterSuperiorThickness = CodedConcept( + +#: 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' ) -MacularOuterNasalThickness = CodedConcept( + +#: Outer nasal subfield. LOINC 57115-8. +MacularOuterNasalThickness: CodedConcept = CodedConcept( value='57115-8', scheme_designator='LN', meaning='Macular grid.outer nasal subfield thickness by OCT' ) -MacularOuterInferiorThickness = CodedConcept( + +#: Outer inferior subfield. LOINC 57116-6. +MacularOuterInferiorThickness: CodedConcept = CodedConcept( value='57116-6', scheme_designator='LN', meaning='Macular grid.outer inferior subfield thickness by OCT' ) -MacularOuterTemporalThickness = CodedConcept( + +#: Outer temporal subfield. LOINC 57117-4. +MacularOuterTemporalThickness: CodedConcept = CodedConcept( value='57117-4', scheme_designator='LN', meaning='Macular grid.outer temporal subfield thickness by OCT' ) -MacularTotalVolume = CodedConcept( + +#: 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' ) -# Sup247 draft nnn250 — provisional -AverageMacularThickness = CodedConcept( + +#: Average macular thickness over the full ETDRS grid, in µm. +#: Sup247 draft nnn250. +AverageMacularThickness: CodedConcept = CodedConcept( value='nnn250', scheme_designator=_SCHEME, meaning='Average macular thickness' ) @@ -159,17 +216,30 @@ class OphthalmologyMeasurementsGroup(Template): """:dcm:`TID 6001 ` Ophthalmology Measurements Group - A sub-template specialised for ophthalmic measurements, structurally - equivalent to TID 1501 *Measurement and Qualitative Evaluation Group* - but restricted to ophthalmic anatomy (finding site "Eye") and with - mandatory laterality. + Type: Extensible · Order: Non-Significant · Root: No + + Sub-template that wraps a set of ophthalmic measurements for a single + eye. Structurally equivalent to TID 1501 + *Measurement and Qualitative Evaluation Group* but specialised for + ophthalmology: the finding site is constrained to + ``EV (81745001, SCT, "Eye")`` and laterality is mandatory. + + Per Supplement 247 Table TID 60x1, the content item hierarchy is:: - This template is invoked by the root eyecare templates (TID 6003–6009) - once per eye measured. It wraps the actual measurement - :class:`~highdicom.sr.Measurement` items together with the anatomic - context (finding site and laterality) required by Supplement 247. + Row 1 CONTAINER (125007, DCM, "Measurement Group") [M] + Row 2 > Finding Site (363698007, SCT) → Eye (81745001) [M] + Row 3 >> Laterality (272741003, SCT) → CID 244 [M] + Row 6 > Tracking Identifier (112039, DCM) [U] + Row 8 > Measurement (TID 300) × 1-n [MC] - Reference: DICOM PS3.16 Supplement 247, Table TID 60x1 + Note on HTML anchor: the URL fragment ``#sect_TID_6001`` will resolve + once DICOM PS3.16 is updated to incorporate Supplement 247. Until then + refer to the supplement PDF directly: + https://dicom.nema.org/Dicom/News/January2025/docs/sups/sup247.pdf + + This template is invoked 1–2 times (once per eye) by the root templates + :class:`CircumpapillaryRNFLKeyMeasurements` (TID 6004) and + :class:`MacularThicknessKeyMeasurements` (TID 6005). """ def __init__( @@ -183,22 +253,29 @@ def __init__( Parameters ---------- laterality: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code] - Laterality of the eye measured (see - :dcm:`CID 244 ` "Laterality" for - options; typically ``codes.cid244.Right`` or - ``codes.cid244.Left``). + Laterality of the eye measured. See + :dcm:`CID 244 ` "Laterality + Left-Right Only" for options; typically ``codes.cid244.Right`` + or ``codes.cid244.Left``. Encoded as a HAS_CONCEPT_MOD of + the Finding Site item (Table TID 60x1, row 3, double-indent). measurements: Sequence[highdicom.sr.Measurement] - One or more measurements belonging to this group. Each - :class:`~highdicom.sr.Measurement` (TID 300) encodes a single - numeric finding (e.g. average RNFL thickness). + One or more :class:`~highdicom.sr.Measurement` instances + (TID 300), each encoding a single numeric ophthalmic finding + (e.g. average RNFL thickness from CID 42x3, or a macular + subfield thickness from CID 42x4). Corresponds to row 8 of + TID 60x1 — mandatory when the invoking template supplies a + non-empty ``$Measurement`` parameter. 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`` when not provided. + 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 Supplement 247 row 2. tracking_identifier: str, optional - Free-text tracking identifier for this measurement group (row 6 - of TID 60x1, concept ``(112039, DCM, "Tracking Identifier")``). + Free-text identifier for tracking this measurement group across + studies or analyses. Encoded as + ``(112039, DCM, "Tracking Identifier")`` with relationship type + HAS_OBS_CONTEXT (row 6 of TID 60x1). """ # noqa: E501 super().__init__() @@ -206,6 +283,16 @@ def __init__( if finding_site is None: finding_site = codes.cid4209.Eye + if not measurements: + raise ValueError( + "Argument 'measurements' must contain at least one item." + ) + for meas in measurements: + if not isinstance(meas, Measurement): + raise TypeError( + 'Each item of "measurements" must have type Measurement.' + ) + item = ContainerContentItem( name=codes.DCM.MeasurementGroup, template_id='6001', @@ -213,7 +300,8 @@ def __init__( ) item.ContentSequence = ContentSequence() - # Row 2 — Finding Site (mandatory) + # Row 2 — Finding Site (M, HAS_CONCEPT_MOD of container). + # Value set constraint: EV (81745001, SCT, "Eye"). site_item = CodeContentItem( name=CodedConcept( value='363698007', @@ -223,9 +311,10 @@ def __init__( value=finding_site, relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD, ) - item.ContentSequence.append(site_item) - - # Row 3 — Laterality (mandatory) + # Row 3 (>>) — Laterality is HAS_CONCEPT_MOD of the Finding Site + # item, NOT of the container. The double-indent (>>) in Table TID + # 60x1 encodes this nested modifier relationship. + site_item.ContentSequence = ContentSequence() lat_item = CodeContentItem( name=CodedConcept( value='272741003', @@ -235,11 +324,11 @@ def __init__( value=laterality, relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD, ) - item.ContentSequence.append(lat_item) + site_item.ContentSequence.append(lat_item) + item.ContentSequence.append(site_item) - # Row 6 — Tracking Identifier (optional) + # Row 6 — Tracking Identifier (U, HAS_OBS_CONTEXT). if tracking_identifier is not None: - from highdicom.sr.value_types import TextContentItem tracking_item = TextContentItem( name=CodedConcept( value='112039', @@ -251,16 +340,8 @@ def __init__( ) item.ContentSequence.append(tracking_item) - # Rows 8/10 — Measurements (mandatory, 1-n) - if not measurements: - raise ValueError( - "Argument 'measurements' must contain at least one item." - ) + # Row 8 — Measurements (MC, 1-n, CONTAINS via TID 300). for meas in measurements: - if not isinstance(meas, Measurement): - raise TypeError( - 'Each item of "measurements" must have type Measurement.' - ) item.ContentSequence.extend(meas) self.append(item) @@ -274,12 +355,28 @@ class CircumpapillaryRNFLKeyMeasurements(Template): """:dcm:`TID 6004 ` Retinal Nerve Fiber Layer Key Measurements - Root template for circumpapillary RNFL thickness measurements obtained - by ophthalmic tomography (OCT). Encodes the key quadrant and average - RNFL values as defined by CID 42x3 (RNFL Key Measurements) of - Supplement 247. + Type: Extensible · Order: Non-Significant · Root: Yes + + Root template for circumpapillary retinal nerve fiber layer (RNFL) + thickness measurements obtained by ophthalmic tomography (OCT). + Encodes the key quadrant and average RNFL values as defined by + CID 42x3 *RNFL Key Measurements* of Supplement 247. - Usage:: + Per Supplement 247 Table TID 60x4, the content item hierarchy is:: + + Row 1 CONTAINER (nnn102, 99OPHTHALMO, "RNFL Key Measurements") + Row 2 > Language of Content Item (TID 1204) [U] + Row 4 > Algorithm Identification (TID 4019) [M] + Row 5 > Ophthalmology Measurements Group (TID 6001) × 1-2 [M] + + (Row 3, Observer Context, is not implemented in this PR. + Row 6, bilateral symmetry measurement, is an optional extension.) + + Note: the HTML anchor ``#sect_TID_6004`` will resolve once DICOM PS3.16 + is updated to incorporate Supplement 247. Until then refer to: + https://dicom.nema.org/Dicom/News/January2025/docs/sups/sup247.pdf + + Usage example:: from highdicom.sr.templates import ( AlgorithmIdentification, @@ -287,26 +384,16 @@ class CircumpapillaryRNFLKeyMeasurements(Template): Measurement, OphthalmologyMeasurementsGroup, ) - from highdicom.sr.templates.tid6000 import ( - RNFLAverageThickness, - RNFLSuperiorThickness, - RNFLInferiorThickness, - ) + from highdicom.sr.templates.tid6000 import RNFLAverageThickness from pydicom.sr.codedict import codes - from pydicom.sr.coding import Code - algo = AlgorithmIdentification(name='Revo FC', version='1.0') + algo = AlgorithmIdentification(name='Revo FC130', version='1.0') meas = [ Measurement( name=RNFLAverageThickness, value=121.0, unit=codes.UCUM.Micrometer, ), - Measurement( - name=RNFLSuperiorThickness, - value=135.0, - unit=codes.UCUM.Micrometer, - ), ] group = OphthalmologyMeasurementsGroup( laterality=codes.cid244.Right, @@ -316,8 +403,6 @@ class CircumpapillaryRNFLKeyMeasurements(Template): algorithm_id=algo, measurement_groups=[group], ) - - Reference: DICOM PS3.16 Supplement 247, Table TID 60x4 """ def __init__( @@ -333,14 +418,29 @@ def __init__( ---------- algorithm_id: highdicom.sr.AlgorithmIdentification Identification of the algorithm used to produce the RNFL - measurements (TID 4019, mandatory per Supplement 247 row 4). + measurements (TID 4019). Mandatory per Supplement 247 + Table TID 60x4 row 4 (HAS_OBS_CONTEXT). measurement_groups: Sequence[highdicom.sr.OphthalmologyMeasurementsGroup] - One or two measurement groups (TID 6001), one per eye. Each - group carries the laterality and the individual RNFL thickness - measurements from CID 42x3. + One or two :class:`OphthalmologyMeasurementsGroup` instances + (TID 6001), one per eye. Each group carries the laterality + and the individual RNFL thickness measurements from CID 42x3. + Two groups are used for bilateral studies (one per eye). + Mandatory per Supplement 247 Table TID 60x4 row 5. language_of_content_item_and_descendants: Union[highdicom.sr.LanguageOfContentItemAndDescendants, None], optional - Language specification for all content items (defaults to English - when omitted). + Language specification for all SR content items (row 2 of + TID 60x4, HAS_CONCEPT_MOD). 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 + element of ``measurement_groups`` is not an + :class:`OphthalmologyMeasurementsGroup`. + ValueError + If ``measurement_groups`` is empty or contains more than two + items. """ # noqa: E501 super().__init__() @@ -360,6 +460,11 @@ def __init__( 'Each item of "measurement_groups" must have type ' 'OphthalmologyMeasurementsGroup.' ) + if not isinstance(algorithm_id, AlgorithmIdentification): + raise TypeError( + 'Argument "algorithm_id" must have type ' + 'AlgorithmIdentification.' + ) item = ContainerContentItem( name=_CONCEPT_RNFL_KEY, @@ -367,22 +472,16 @@ def __init__( ) item.ContentSequence = ContentSequence() - # Row 2 — Language (optional) + # Row 2 — Language of Content Item and Descendants (U). if language_of_content_item_and_descendants is None: - from highdicom.sr.templates.tid1500 import DEFAULT_LANGUAGE language_of_content_item_and_descendants = \ LanguageOfContentItemAndDescendants(DEFAULT_LANGUAGE) item.ContentSequence.extend(language_of_content_item_and_descendants) - # Row 4 — Algorithm Identification (mandatory) - if not isinstance(algorithm_id, AlgorithmIdentification): - raise TypeError( - 'Argument "algorithm_id" must have type ' - 'AlgorithmIdentification.' - ) + # Row 4 — Algorithm Identification (M, TID 4019, HAS_OBS_CONTEXT). item.ContentSequence.extend(algorithm_id) - # Row 5 — Measurement Groups (1-2, mandatory) + # Row 5 — Ophthalmology Measurements Group (M, 1-2, CONTAINS). for group in measurement_groups: item.ContentSequence.extend(group) @@ -397,15 +496,34 @@ class MacularThicknessKeyMeasurements(Template): """:dcm:`TID 6005 ` Macular Thickness Key Measurements - Root template for ETDRS-grid macular thickness measurements obtained - by ophthalmic tomography (OCT). Encodes the central subfield (CMT) - and the inner/outer ring sector values defined by CID 42x4 (Macular - Thickness Key Measurements) of Supplement 247. + 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. Encodes the central subfield thickness (CMT) and the + inner/outer ring sector values defined by CID 42x4 *Macular Thickness + Key Measurements* of Supplement 247. - The ETDRS grid concept names are encoded using their LOINC codes - (``57108-3`` through ``57118-2``), which are finalised in the standard. + The nine ETDRS subfield concept names use their LOINC codes + (``57108-3`` through ``57117-4``), which are finalised in the standard. + Total macular volume uses ``LN 57118-2``; the unit must be + ``UCUM_MICROLITER`` (``uL``). Average macular thickness (``nnn250``) + remains a Supplement 247 draft placeholder under scheme ``99OPHTHALMO``. - Usage:: + Per Supplement 247 Table TID 60x5, the content item hierarchy is:: + + Row 1 CONTAINER (nnn103, 99OPHTHALMO, "Macular Thickness Key Measurements") + Row 2 > Language of Content Item (TID 1204) [U] + Row 4 > Algorithm Identification (TID 4019) [M] + Row 5 > Ophthalmology Measurements Group (TID 6001) × 1-2 [M] + + (Row 3, Observer Context, is not implemented in this PR.) + + Note: the HTML anchor ``#sect_TID_6005`` will resolve once DICOM PS3.16 + is updated to incorporate Supplement 247. Until then refer to: + https://dicom.nema.org/Dicom/News/January2025/docs/sups/sup247.pdf + + Usage example:: from highdicom.sr.templates import ( AlgorithmIdentification, @@ -432,8 +550,6 @@ class MacularThicknessKeyMeasurements(Template): algorithm_id=algo, measurement_groups=[group], ) - - Reference: DICOM PS3.16 Supplement 247, Table TID 60x5 """ def __init__( @@ -449,15 +565,31 @@ def __init__( ---------- algorithm_id: highdicom.sr.AlgorithmIdentification Identification of the algorithm used to produce the macular - thickness measurements (TID 4019, mandatory per Supplement 247 - row 4). + thickness measurements (TID 4019). Mandatory per Supplement 247 + Table TID 60x5 row 4 (HAS_OBS_CONTEXT). measurement_groups: Sequence[highdicom.sr.OphthalmologyMeasurementsGroup] - One or two measurement groups (TID 6001), one per eye. Each - group carries the laterality and the individual macular thickness - measurements from CID 42x4. + One or two :class:`OphthalmologyMeasurementsGroup` instances + (TID 6001), one per eye. Each group carries the laterality + and the individual macular thickness measurements from CID 42x4. + ETDRS subfield measurements use LOINC codes; the unit for all + thickness values is ``codes.UCUM.Micrometer`` (``um``). + Total volume (``MacularTotalVolume``) uses :data:`UCUM_MICROLITER` + (``uL``). Mandatory per Supplement 247 Table TID 60x5 row 5. language_of_content_item_and_descendants: Union[highdicom.sr.LanguageOfContentItemAndDescendants, None], optional - Language specification for all content items (defaults to English - when omitted). + Language specification for all SR content items (row 2, + HAS_CONCEPT_MOD). 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 an + :class:`OphthalmologyMeasurementsGroup`. + ValueError + If ``measurement_groups`` is empty or contains more than two + items. """ # noqa: E501 super().__init__() @@ -477,6 +609,11 @@ def __init__( 'Each item of "measurement_groups" must have type ' 'OphthalmologyMeasurementsGroup.' ) + if not isinstance(algorithm_id, AlgorithmIdentification): + raise TypeError( + 'Argument "algorithm_id" must have type ' + 'AlgorithmIdentification.' + ) item = ContainerContentItem( name=_CONCEPT_MACULAR_THICKNESS_KEY, @@ -484,22 +621,16 @@ def __init__( ) item.ContentSequence = ContentSequence() - # Row 2 — Language (optional) + # Row 2 — Language of Content Item and Descendants (U). if language_of_content_item_and_descendants is None: - from highdicom.sr.templates.tid1500 import DEFAULT_LANGUAGE language_of_content_item_and_descendants = \ LanguageOfContentItemAndDescendants(DEFAULT_LANGUAGE) item.ContentSequence.extend(language_of_content_item_and_descendants) - # Row 4 — Algorithm Identification (mandatory) - if not isinstance(algorithm_id, AlgorithmIdentification): - raise TypeError( - 'Argument "algorithm_id" must have type ' - 'AlgorithmIdentification.' - ) + # Row 4 — Algorithm Identification (M, TID 4019, HAS_OBS_CONTEXT). item.ContentSequence.extend(algorithm_id) - # Row 5 — Measurement Groups (1-2, mandatory) + # Row 5 — Ophthalmology Measurements Group (M, 1-2, CONTAINS). for group in measurement_groups: item.ContentSequence.extend(group) From 6f680f9a2f848a47489263cc457fbc8a7a0be076 Mon Sep 17 00:00:00 2001 From: Noel Jaurrieta Date: Fri, 10 Apr 2026 22:50:30 -0600 Subject: [PATCH 3/6] test: add ophthalmic SR tests (TID 6001/6004/6005) --- tests/test_sr_ophthalmic.py | 638 ++++++++++++++++++++++++++++++++++++ 1 file changed, 638 insertions(+) create mode 100644 tests/test_sr_ophthalmic.py diff --git a/tests/test_sr_ophthalmic.py b/tests/test_sr_ophthalmic.py new file mode 100644 index 000000000..fcee32b89 --- /dev/null +++ b/tests/test_sr_ophthalmic.py @@ -0,0 +1,638 @@ +"""Tests for Supplement 247 Eyecare Measurement SR templates (TID 6001/6004/6005). + +Tests cover: +- OphthalmologyMeasurementsGroup (TID 6001) +- CircumpapillaryRNFLKeyMeasurements (TID 6004) +- MacularThicknessKeyMeasurements (TID 6005) +- Two ComprehensiveSR roundtrips (serialise → dcmread → verify values survive) + +Content item navigation paths (post laterality-nesting fix): + group[0] → ContainerContentItem "Measurement Group" + group[0].ContentSequence[0] → Finding Site (CodeContentItem) + group[0].ContentSequence[0].ContentSequence[0] → Laterality (CodeContentItem) + group[0].ContentSequence[1] → first Measurement NumContentItem (no tracking) + group[0].ContentSequence[1] → Tracking Identifier text (with tracking) + group[0].ContentSequence[2] → first Measurement NumContentItem (with tracking) + + report[0] → root ContainerContentItem + report[0].ContentSequence[0] → Language item + report[0].ContentSequence[1] → AlgorithmId item 1 + report[0].ContentSequence[2] → AlgorithmId item 2 + report[0].ContentSequence[3] → first OphthalmologyMeasurementsGroup container + report[0].ContentSequence[4] → second group container (bilateral) +""" + +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 +from highdicom.sr.templates.tid6000 import ( + AverageMacularThickness, + CircumpapillaryRNFLKeyMeasurements, + MacularCenterSubfieldThickness, + MacularInnerSuperiorThickness, + MacularInnerNasalThickness, + MacularInnerInferiorThickness, + MacularInnerTemporalThickness, + MacularOuterSuperiorThickness, + MacularOuterNasalThickness, + MacularOuterInferiorThickness, + MacularOuterTemporalThickness, + MacularThicknessKeyMeasurements, + MacularTotalVolume, + OphthalmologyMeasurementsGroup, + RNFLAverageThickness, + RNFLInferiorThickness, + RNFLSuperiorThickness, + RNFLTemporalThickness, + RNFLNasalThickness, + RetinalROIRadius, + UCUM_MICROLITER, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_DATA_DIR = Path(__file__).parent.parent / 'data' / 'test_files' + + +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) + + +# --------------------------------------------------------------------------- +# OphthalmologyMeasurementsGroup (TID 6001) +# --------------------------------------------------------------------------- + + +class TestOphthalmologyMeasurementsGroup: + def test_basic_construction(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_rnfl_measurement()], + ) + assert len(group) == 1 + + def test_container_name_is_measurement_group(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_rnfl_measurement()], + ) + container = group[0] + assert container.ConceptNameCodeSequence[0].CodeValue == '125007' + + def test_template_id(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_rnfl_measurement()], + ) + container = group[0] + assert container.ContentTemplateSequence[0].TemplateIdentifier == '6001' + + def test_finding_site_default_is_eye(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_rnfl_measurement()], + ) + container = group[0] + site_item = container.ContentSequence[0] + # Concept name: Finding Site + assert site_item.ConceptNameCodeSequence[0].CodeValue == '363698007' + # Value: Eye + assert site_item.ConceptCodeSequence[0].CodeValue == '81745001' + + def test_finding_site_custom(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Left, + measurements=[_make_rnfl_measurement()], + finding_site=codes.cid4209.OpticNerveHead, + ) + container = group[0] + site_item = container.ContentSequence[0] + assert site_item.ConceptCodeSequence[0].CodeValue == \ + codes.cid4209.OpticNerveHead.value + + def test_laterality_nested_inside_finding_site(self): + """Row 3 >> in TID 60x1: laterality is child of Finding Site, not container.""" + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_rnfl_measurement()], + ) + container = group[0] + site_item = container.ContentSequence[0] + # Finding Site must have its own ContentSequence with Laterality inside + assert hasattr(site_item, 'ContentSequence') + lat_item = site_item.ContentSequence[0] + assert lat_item.ConceptNameCodeSequence[0].CodeValue == '272741003' + + def test_laterality_right(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_rnfl_measurement()], + ) + lat = group[0].ContentSequence[0].ContentSequence[0] + # Right eye: EV (24028007, SCT, "Right") + assert lat.ConceptCodeSequence[0].CodeValue == '24028007' + + def test_laterality_left(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Left, + measurements=[_make_rnfl_measurement()], + ) + lat = group[0].ContentSequence[0].ContentSequence[0] + # Left eye: EV (7771000, SCT, "Left") + assert lat.ConceptCodeSequence[0].CodeValue == '7771000' + + def test_measurement_present(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_rnfl_measurement(121.0)], + ) + container = group[0] + # Without tracking: measurement is at index 1 + meas_item = container.ContentSequence[1] + assert float(meas_item.MeasuredValueSequence[0].NumericValue) == 121.0 + + def test_measurement_unit_micrometer(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_rnfl_measurement()], + ) + meas_item = group[0].ContentSequence[1] + assert meas_item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0].CodeValue == 'um' + + def test_tracking_identifier(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_rnfl_measurement()], + tracking_identifier='RNFL-OD-001', + ) + container = group[0] + # With tracking: [0]=site, [1]=tracking, [2]=measurement + tracking_item = container.ContentSequence[1] + assert tracking_item.ConceptNameCodeSequence[0].CodeValue == '112039' + assert tracking_item.TextValue == 'RNFL-OD-001' + + def test_measurement_index_shifts_with_tracking(self): + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_rnfl_measurement(99.0)], + tracking_identifier='track-01', + ) + # With tracking identifier, measurement moves to index 2 + meas_item = group[0].ContentSequence[2] + assert float(meas_item.MeasuredValueSequence[0].NumericValue) == 99.0 + + 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( + laterality=codes.cid244.Right, + measurements=meas_list, + ) + container = group[0] + # [0]=site, [1],[2],[3]=3 measurements + assert len(container.ContentSequence) == 4 + vals = [ + float(container.ContentSequence[i].MeasuredValueSequence[0].NumericValue) + for i in (1, 2, 3) + ] + assert vals == [121.0, 145.0, 138.0] + + def test_empty_measurements_raises(self): + with pytest.raises(ValueError, match="at least one"): + OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[], + ) + + def test_wrong_measurement_type_raises(self): + with pytest.raises(TypeError, match="type Measurement"): + OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=["not a measurement"], + ) + + +# --------------------------------------------------------------------------- +# CircumpapillaryRNFLKeyMeasurements (TID 6004) +# --------------------------------------------------------------------------- + + +class TestCircumpapillaryRNFLKeyMeasurements: + def _make_group(self, laterality=None) -> OphthalmologyMeasurementsGroup: + lat = laterality or codes.cid244.Right + return OphthalmologyMeasurementsGroup( + laterality=lat, + measurements=[_make_rnfl_measurement()], + ) + + def test_basic_construction(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[self._make_group()], + ) + assert len(report) == 1 + + def test_root_container_code(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[self._make_group()], + ) + root = report[0] + assert root.ConceptNameCodeSequence[0].CodeValue == 'nnn102' + assert root.ConceptNameCodeSequence[0].CodingSchemeDesignator == '99OPHTHALMO' + + def test_template_id(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[self._make_group()], + ) + assert report[0].ContentTemplateSequence[0].TemplateIdentifier == '6004' + + def test_content_sequence_structure(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[self._make_group()], + ) + root = report[0] + # [0]=Language, [1],[2]=AlgoId (2 items), [3]=Measurement Group + assert len(root.ContentSequence) == 4 + + def test_algo_id_present(self): + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo('MyOCT', '2.3'), + measurement_groups=[self._make_group()], + ) + root = report[0] + algo_name_item = root.ContentSequence[1] + assert algo_name_item.TextValue == 'MyOCT' + + def test_bilateral(self): + od_group = self._make_group(codes.cid244.Right) + os_group = self._make_group(codes.cid244.Left) + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[od_group, os_group], + ) + root = report[0] + # [0]=Language, [1],[2]=AlgoId, [3]=OD group, [4]=OS group + assert len(root.ContentSequence) == 5 + + def test_laterality_in_bilateral(self): + od_group = self._make_group(codes.cid244.Right) + os_group = self._make_group(codes.cid244.Left) + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[od_group, os_group], + ) + root = report[0] + od_container = root.ContentSequence[3] + os_container = root.ContentSequence[4] + + od_lat = od_container.ContentSequence[0].ContentSequence[0] + os_lat = os_container.ContentSequence[0].ContentSequence[0] + assert od_lat.ConceptCodeSequence[0].CodeValue == '24028007' # Right + assert os_lat.ConceptCodeSequence[0].CodeValue == '7771000' # Left + + def test_more_than_two_groups_raises(self): + with pytest.raises(ValueError, match="at most two"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[ + self._make_group(), + self._make_group(), + self._make_group(), + ], + ) + + def test_empty_groups_raises(self): + with pytest.raises(ValueError, match="at least one"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[], + ) + + def test_wrong_algo_type_raises(self): + with pytest.raises(TypeError, match="AlgorithmIdentification"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id="not an algo", + measurement_groups=[self._make_group()], + ) + + def test_wrong_group_type_raises(self): + with pytest.raises(TypeError, match="OphthalmologyMeasurementsGroup"): + CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=["not a group"], + ) + + def test_rnfl_codes_are_99ophthalmo(self): + """All RNFL measurement concept names use scheme 99OPHTHALMO.""" + for concept in ( + RNFLAverageThickness, + RNFLInferiorThickness, + RNFLSuperiorThickness, + RNFLTemporalThickness, + RNFLNasalThickness, + RetinalROIRadius, + ): + assert concept.scheme_designator == '99OPHTHALMO', ( + f"{concept.meaning} should use 99OPHTHALMO, got {concept.scheme_designator}" + ) + + def test_full_rnfl_quadrants(self): + """All five RNFL quadrant measurements encode correctly.""" + 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), + Measurement(name=RNFLTemporalThickness, value=80.0, unit=codes.UCUM.Micrometer), + Measurement(name=RNFLNasalThickness, value=95.0, unit=codes.UCUM.Micrometer), + ] + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=meas_list, + ) + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[group], + ) + root = report[0] + mg_container = root.ContentSequence[3] + # [0]=site, [1..5]=5 measurements + assert len(mg_container.ContentSequence) == 6 + avg = mg_container.ContentSequence[1] + assert float(avg.MeasuredValueSequence[0].NumericValue) == 121.0 + + +# --------------------------------------------------------------------------- +# MacularThicknessKeyMeasurements (TID 6005) +# --------------------------------------------------------------------------- + + +class TestMacularThicknessKeyMeasurements: + def _make_group(self, value: float = 281.4) -> OphthalmologyMeasurementsGroup: + return OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_macular_measurement(value)], + ) + + def test_basic_construction(self): + report = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[self._make_group()], + ) + assert len(report) == 1 + + def test_root_container_code(self): + report = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[self._make_group()], + ) + root = report[0] + assert root.ConceptNameCodeSequence[0].CodeValue == 'nnn103' + assert root.ConceptNameCodeSequence[0].CodingSchemeDesignator == '99OPHTHALMO' + + def test_template_id(self): + report = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[self._make_group()], + ) + assert report[0].ContentTemplateSequence[0].TemplateIdentifier == '6005' + + def test_content_sequence_structure(self): + report = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[self._make_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_99ophthalmo(self): + assert AverageMacularThickness.scheme_designator == '99OPHTHALMO' + assert AverageMacularThickness.value == 'nnn250' + + 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_total_volume_uses_ul(self): + """MacularTotalVolume measurement must use UCUM_MICROLITER unit.""" + meas = Measurement( + name=MacularTotalVolume, + value=8.42, + unit=UCUM_MICROLITER, + ) + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[meas], + ) + meas_item = group[0].ContentSequence[1] + assert meas_item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0].CodeValue == 'uL' + + def test_full_etdrs_grid(self): + """Nine ETDRS subfields + total volume encode correctly.""" + meas_list = [ + Measurement(name=MacularCenterSubfieldThickness, value=281.4, unit=codes.UCUM.Micrometer), + Measurement(name=MacularInnerSuperiorThickness, value=338.0, unit=codes.UCUM.Micrometer), + Measurement(name=MacularInnerNasalThickness, value=350.0, unit=codes.UCUM.Micrometer), + Measurement(name=MacularInnerInferiorThickness, value=335.0, unit=codes.UCUM.Micrometer), + Measurement(name=MacularInnerTemporalThickness, value=320.0, unit=codes.UCUM.Micrometer), + Measurement(name=MacularOuterSuperiorThickness, value=290.0, unit=codes.UCUM.Micrometer), + Measurement(name=MacularOuterNasalThickness, value=305.0, unit=codes.UCUM.Micrometer), + Measurement(name=MacularOuterInferiorThickness, value=285.0, unit=codes.UCUM.Micrometer), + Measurement(name=MacularOuterTemporalThickness, value=265.0, unit=codes.UCUM.Micrometer), + Measurement(name=MacularTotalVolume, value=8.42, unit=UCUM_MICROLITER), + ] + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=meas_list, + ) + report = MacularThicknessKeyMeasurements( + algorithm_id=_make_algo(), + measurement_groups=[group], + ) + root = report[0] + mg_container = root.ContentSequence[3] + # [0]=site, [1..10]=10 measurements + assert len(mg_container.ContentSequence) == 11 + + # Verify CMT value + cmt_item = mg_container.ContentSequence[1] + assert float(cmt_item.MeasuredValueSequence[0].NumericValue) == 281.4 + + # Verify total volume uses uL + vol_item = mg_container.ContentSequence[10] + assert vol_item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0].CodeValue == 'uL' + + def test_bilateral_macular(self): + od = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[_make_macular_measurement(281.4)], + ) + os = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Left, + measurements=[_make_macular_measurement(275.0)], + ) + 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 + + od_meas = root.ContentSequence[3].ContentSequence[1] + os_meas = root.ContentSequence[4].ContentSequence[1] + assert float(od_meas.MeasuredValueSequence[0].NumericValue) == 281.4 + assert float(os_meas.MeasuredValueSequence[0].NumericValue) == 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=[ + self._make_group(), + self._make_group(), + self._make_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 6004: numeric value 121.0 µm and code 'nnn400' survive dcmread.""" + meas = Measurement( + name=RNFLAverageThickness, + value=121.0, + unit=codes.UCUM.Micrometer, + ) + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[meas], + ) + template = CircumpapillaryRNFLKeyMeasurements( + 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='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_item = mg.ContentSequence[1] + assert float(meas_item.MeasuredValueSequence[0].NumericValue) == 121.0 + assert meas_item.ConceptNameCodeSequence[0].CodeValue == 'nnn400' + assert meas_item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0].CodeValue == 'um' + + def test_macular_roundtrip_loinc_code_survives(self, ref_ds): + """TID 6005: LOINC code '57109-1' (CMT) survives serialisation.""" + meas = Measurement( + name=MacularCenterSubfieldThickness, + value=281.4, + unit=codes.UCUM.Micrometer, + ) + group = OphthalmologyMeasurementsGroup( + laterality=codes.cid244.Right, + measurements=[meas], + ) + 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_item = mg.ContentSequence[1] + assert float(meas_item.MeasuredValueSequence[0].NumericValue) == 281.4 + assert meas_item.ConceptNameCodeSequence[0].CodeValue == '57109-1' + assert meas_item.ConceptNameCodeSequence[0].CodingSchemeDesignator == 'LN' From 1ea577f787777969ab1c52381dacad577bab0b9f Mon Sep 17 00:00:00 2001 From: Noel Jaurrieta Date: Mon, 20 Apr 2026 23:51:49 -0600 Subject: [PATCH 4/6] fix: remove redundant super().__init__() in TID 6004/6005 constructors --- src/highdicom/sr/templates/tid6000.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/highdicom/sr/templates/tid6000.py b/src/highdicom/sr/templates/tid6000.py index ee1f3de63..31c383f60 100644 --- a/src/highdicom/sr/templates/tid6000.py +++ b/src/highdicom/sr/templates/tid6000.py @@ -443,7 +443,6 @@ def __init__( items. """ # noqa: E501 - super().__init__() if not measurement_groups: raise ValueError( @@ -592,7 +591,6 @@ def __init__( items. """ # noqa: E501 - super().__init__() if not measurement_groups: raise ValueError( From c258cb33c6220f3909bed3a95f345fb94f95392b Mon Sep 17 00:00:00 2001 From: Noel Jaurrieta Date: Thu, 3 Sep 2026 18:12:58 -0600 Subject: [PATCH 5/6] feat: rename TID 6001/6004/6005 to ratified TID 2120/2123/2124; rebase group class on TID 1501 Addresses the first two of CPBridge's three high-level review comments on #407. - All Sup247 draft codes now use final ratified DCM/LOINC values (container codes 131242/131243, RNFL measurements 131264-131268, average macular thickness 131255); the private 99OPHTHALMO scheme is no longer needed, so no companion pydicom PR is required. - Laterality moves from CID 244 to CID 247 per the ratified TID 2120 table. - OphthalmologyMeasurementsGroup now subclasses the public MeasurementsAndQualitativeEvaluations (TID 1501) instead of hand-rolling content items, reusing FindingSite and TrackingIdentifier (TID 4108). Its Content Template Sequence is corrected back to '2120' since the base class hardcodes '1501'. - tracking_identifier is now a mandatory TrackingIdentifier object (was an optional str), matching the rest of the TID 1501 family. Sector-vs-clockface group split and RNFL-symmetry (still part of the ratified TID 2123 table) are deferred to the follow-up addressing constructor-level conformance enforcement (review comment 3, Option B). --- src/highdicom/sr/templates/__init__.py | 10 +- src/highdicom/sr/templates/tid2120.py | 561 ++++++++++++++++++++++ src/highdicom/sr/templates/tid6000.py | 635 ------------------------- tests/test_sr_ophthalmic.py | 340 ++++++------- 4 files changed, 744 insertions(+), 802 deletions(-) create mode 100644 src/highdicom/sr/templates/tid2120.py delete mode 100644 src/highdicom/sr/templates/tid6000.py diff --git a/src/highdicom/sr/templates/__init__.py b/src/highdicom/sr/templates/__init__.py index 2ac6e0ce0..e34f8e4f2 100644 --- a/src/highdicom/sr/templates/__init__.py +++ b/src/highdicom/sr/templates/__init__.py @@ -4,7 +4,9 @@ DICOM PS3.16. Each sub-module covers one top-level template family: - :mod:`tid1500` — TID 1500 Measurement Report (general imaging measurements) -- :mod:`tid6000` — Supplement 247 Eyecare Measurement Templates (TID 6001–6009) +- :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. @@ -38,8 +40,8 @@ VolumetricROIMeasurementsAndQualitativeEvaluations, ) -# Supplement 247 — Eyecare Measurement Templates -from highdicom.sr.templates.tid6000 import ( +# Eyecare Measurement Templates (TID 2120, 2123, 2124) +from highdicom.sr.templates.tid2120 import ( CircumpapillaryRNFLKeyMeasurements, MacularThicknessKeyMeasurements, OphthalmologyMeasurementsGroup, @@ -72,7 +74,7 @@ "TimePointContext", "TrackingIdentifier", "VolumetricROIMeasurementsAndQualitativeEvaluations", - # Supplement 247 family + # Eyecare Measurement Templates family "OphthalmologyMeasurementsGroup", "CircumpapillaryRNFLKeyMeasurements", "MacularThicknessKeyMeasurements", diff --git a/src/highdicom/sr/templates/tid2120.py b/src/highdicom/sr/templates/tid2120.py new file mode 100644 index 000000000..cba32cd1a --- /dev/null +++ b/src/highdicom/sr/templates/tid2120.py @@ -0,0 +1,561 @@ +"""DICOM SR templates for Supplement 247 — 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 ` + +Scope of this implementation +----------------------------- +TID 2123 *Circumpapillary Retinal Nerve Fiber Layer Key Measurements* is +defined by the ratified standard as invoking TID 2120 in two structurally +distinct roles: a "sector" group (:dcm:`CID 4282 ` +sector methods, e.g. Garway-Heath or quadrant sectors) and a separate +"clockface" group (12 clockface-position measurements), plus an optional +bilateral RNFL-symmetry measurement. This implementation does not yet +distinguish these roles: :class:`CircumpapillaryRNFLKeyMeasurements` accepts +a flat list of :class:`OphthalmologyMeasurementsGroup` instances (one or two, +per eye), same as :class:`MacularThicknessKeyMeasurements`. Enforcing the +sector/clockface split (and validating measurements against the CID that +matches each role) is deferred to a follow-up that also addresses +constructor-level conformance enforcement more broadly. +""" + +from __future__ import annotations + +from collections.abc import 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 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' +) + +# --------------------------------------------------------------------------- +# 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 1–2 times (once per eye) by the root templates + :class:`CircumpapillaryRNFLKeyMeasurements` (TID 2123) and + :class:`MacularThicknessKeyMeasurements` (TID 2124). + """ + + 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' + + +# --------------------------------------------------------------------------- +# 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). + + See the module docstring for the scope of this implementation relative + to the ratified standard's sector/clockface group split, which is not + yet enforced here. + + Usage example:: + + from highdicom.sr.templates import ( + AlgorithmIdentification, + CircumpapillaryRNFLKeyMeasurements, + Measurement, + OphthalmologyMeasurementsGroup, + TrackingIdentifier, + ) + from highdicom.sr.templates.tid2120 import RNFLAverageThickness + from pydicom.sr.codedict import codes + + algo = AlgorithmIdentification(name='Revo FC130', version='1.0') + meas = [ + Measurement( + name=RNFLAverageThickness, + value=121.0, + unit=codes.UCUM.Micrometer, + ), + ] + group = OphthalmologyMeasurementsGroup( + tracking_identifier=TrackingIdentifier(identifier='RNFL-OD'), + laterality=codes.cid247.Right, + measurements=meas, + ) + report = CircumpapillaryRNFLKeyMeasurements( + algorithm_id=algo, + measurement_groups=[group], + ) + """ + + def __init__( + self, + algorithm_id: AlgorithmIdentification, + measurement_groups: Sequence[OphthalmologyMeasurementsGroup], + 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. + measurement_groups: Sequence[highdicom.sr.OphthalmologyMeasurementsGroup] + One or two :class:`OphthalmologyMeasurementsGroup` instances + (TID 2120), one per eye. Two groups are used for bilateral + studies (one per eye). + 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 + element of ``measurement_groups`` is not an + :class:`OphthalmologyMeasurementsGroup`. + 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, OphthalmologyMeasurementsGroup): + raise TypeError( + 'Each item of "measurement_groups" must have type ' + 'OphthalmologyMeasurementsGroup.' + ) + if not isinstance(algorithm_id, AlgorithmIdentification): + raise TypeError( + 'Argument "algorithm_id" must have type ' + 'AlgorithmIdentification.' + ) + + 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 measurement_groups: + item.ContentSequence.extend(group) + + super().__init__([item], is_root=True) + + +# --------------------------------------------------------------------------- +# 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. + + The nine ETDRS subfield concept names use their LOINC codes + (``57108-3`` through ``57117-4``). Total macular volume uses + ``LN 57118-2``; the unit must be ``UCUM_MICROLITER`` (``uL``). Average + macular thickness uses DCM ``131255``. + + Usage example:: + + from highdicom.sr.templates import ( + AlgorithmIdentification, + MacularThicknessKeyMeasurements, + Measurement, + OphthalmologyMeasurementsGroup, + TrackingIdentifier, + ) + from highdicom.sr.templates.tid2120 import MacularCenterSubfieldThickness + from pydicom.sr.codedict import codes + + algo = AlgorithmIdentification(name='Cirrus HD-OCT', version='11.0') + meas = [ + Measurement( + name=MacularCenterSubfieldThickness, + value=288.49, + unit=codes.UCUM.Micrometer, + ), + ] + group = OphthalmologyMeasurementsGroup( + tracking_identifier=TrackingIdentifier(identifier='Macula-OD'), + laterality=codes.cid247.Right, + measurements=meas, + ) + report = MacularThicknessKeyMeasurements( + algorithm_id=algo, + measurement_groups=[group], + ) + """ + + def __init__( + self, + algorithm_id: AlgorithmIdentification, + measurement_groups: Sequence[OphthalmologyMeasurementsGroup], + 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.OphthalmologyMeasurementsGroup] + One or two :class:`OphthalmologyMeasurementsGroup` instances + (TID 2120), one per eye. Each group carries the laterality + and the individual macular thickness measurements. 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 an + :class:`OphthalmologyMeasurementsGroup`. + 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, OphthalmologyMeasurementsGroup): + raise TypeError( + 'Each item of "measurement_groups" must have type ' + 'OphthalmologyMeasurementsGroup.' + ) + 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/src/highdicom/sr/templates/tid6000.py b/src/highdicom/sr/templates/tid6000.py deleted file mode 100644 index 31c383f60..000000000 --- a/src/highdicom/sr/templates/tid6000.py +++ /dev/null @@ -1,635 +0,0 @@ -"""DICOM SR templates for Supplement 247 — Eyecare Measurement Templates. - -Implements TIDs 6001, 6004, and 6005 as defined in DICOM Supplement 247 -"Eyecare Measurement Templates", incorporated into DICOM PS3.16 2025c. - -Reference: DICOM PS3.16 Supplement 247 - https://dicom.nema.org/Dicom/News/January2025/docs/sups/sup247.pdf - -Note on provisional concept codes ----------------------------------- -Supplement 247 was balloted with several DCM concept codes shown as -``nnnXXX`` placeholders — the final numeric values had not yet been -assigned by DICOM WG-09 at time of publication. Those codes are defined -here as private ``CodedConcept`` objects under scheme ``99OPHTHALMO`` -(≤ 16 chars, required for DICOM VR SH) and are marked with -``# Sup247 draft — nnnXXX`` inline comments. - -Codes pending a companion PR to pydicom (``pydicom.sr.codedict``): - -Container concept names (99OPHTHALMO, draft placeholders): - - ``nnn102`` — "RNFL Key Measurements" - - ``nnn103`` — "Macular Thickness Key Measurements" - -CID 42x3 RNFL Key Measurements (99OPHTHALMO): - - ``nnn400`` – average thickness - - ``nnn401–nnn404`` – inferior / superior / temporal / nasal quadrants - - ``nnn406`` – retinal ROI radius - -CID 42x4 Macular Thickness Key Measurements: - - ``nnn250`` – average macular thickness (99OPHTHALMO) - - ``LN 57108-3`` through ``LN 57118-2`` – ETDRS grid (LOINC, stable, - not yet exposed as convenience attributes in ``pydicom.sr.codedict``) - -No ``coding_ophthalmology.py`` is created in this PR; per CPBridge guidance -in ImagingDataCommons/highdicom#406, new CIDs belong in a parallel pydicom PR. -""" - -from __future__ import annotations - -from collections.abc import Sequence - -from pydicom.sr.codedict import codes -from pydicom.sr.coding import Code - -from highdicom.sr.coding import CodedConcept -from highdicom.sr.enum import RelationshipTypeValues -from highdicom.sr.value_types import ( - CodeContentItem, - ContainerContentItem, - ContentSequence, - TextContentItem, - UIDRefContentItem, -) -from highdicom.sr.templates.tid1500 import ( - AlgorithmIdentification, - DEFAULT_LANGUAGE, - LanguageOfContentItemAndDescendants, - Measurement, - Template, -) - -# --------------------------------------------------------------------------- -# 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') - -# --------------------------------------------------------------------------- -# Provisional concept codes — Supplement 247 draft (nnnXXX placeholders). -# --------------------------------------------------------------------------- -_SCHEME = '99OPHTHALMO' - -# Container concept names for root templates TID 6004 and 6005. -# Sup247 draft: EV (nnn102, DCM, "RNFL Key Measurements") etc. -_CONCEPT_RNFL_KEY = CodedConcept( - value='nnn102', scheme_designator=_SCHEME, - meaning='RNFL Key Measurements' -) -_CONCEPT_MACULAR_THICKNESS_KEY = CodedConcept( - value='nnn103', scheme_designator=_SCHEME, - meaning='Macular Thickness Key Measurements' -) - -# --------------------------------------------------------------------------- -# CID 42x3 — RNFL Key Measurements (Supplement 247 Table CID 42x3) -# All measurements in µm; unit: (um, UCUM, "um"). -# Codes are Sup247 draft placeholders under 99OPHTHALMO. -# --------------------------------------------------------------------------- - -#: Average circumpapillary RNFL thickness. Sup247 draft nnn400. -RNFLAverageThickness: CodedConcept = CodedConcept( - value='nnn400', scheme_designator=_SCHEME, - meaning='Retinal nerve fiber layer average thickness' -) - -#: Inferior quadrant RNFL thickness. Sup247 draft nnn401. -RNFLInferiorThickness: CodedConcept = CodedConcept( - value='nnn401', scheme_designator=_SCHEME, - meaning='Retinal nerve fiber layer inferior thickness' -) - -#: Superior quadrant RNFL thickness. Sup247 draft nnn402. -RNFLSuperiorThickness: CodedConcept = CodedConcept( - value='nnn402', scheme_designator=_SCHEME, - meaning='Retinal nerve fiber layer superior thickness' -) - -#: Temporal quadrant RNFL thickness. Sup247 draft nnn403. -RNFLTemporalThickness: CodedConcept = CodedConcept( - value='nnn403', scheme_designator=_SCHEME, - meaning='Retinal nerve fiber layer temporal thickness' -) - -#: Nasal quadrant RNFL thickness. Sup247 draft nnn404. -RNFLNasalThickness: CodedConcept = CodedConcept( - value='nnn404', scheme_designator=_SCHEME, - meaning='Retinal nerve fiber layer nasal thickness' -) - -#: Radius of the circumpapillary circle scan used for RNFL measurement, in mm. -#: Sup247 draft nnn406. -RetinalROIRadius: CodedConcept = CodedConcept( - value='nnn406', scheme_designator=_SCHEME, - meaning='Retinal ROI radius' -) - -# --------------------------------------------------------------------------- -# CID 42x4 — Macular Thickness Key Measurements (Supplement 247 Table CID 42x4) -# -# The nine ETDRS grid subfields and total volume use LOINC codes that are -# finalised in DICOM 2025c. The "average macular thickness" concept (nnn250) -# remains a Sup247 draft placeholder. -# --------------------------------------------------------------------------- - -#: 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. -#: Sup247 draft nnn250. -AverageMacularThickness: CodedConcept = CodedConcept( - value='nnn250', scheme_designator=_SCHEME, - meaning='Average macular thickness' -) - - -# --------------------------------------------------------------------------- -# TID 6001 (Sup247 TID 60x1) — Ophthalmology Measurements Group -# --------------------------------------------------------------------------- - -class OphthalmologyMeasurementsGroup(Template): - """:dcm:`TID 6001 ` - Ophthalmology Measurements Group - - Type: Extensible · Order: Non-Significant · Root: No - - Sub-template that wraps a set of ophthalmic measurements for a single - eye. Structurally equivalent to TID 1501 - *Measurement and Qualitative Evaluation Group* but specialised for - ophthalmology: the finding site is constrained to - ``EV (81745001, SCT, "Eye")`` and laterality is mandatory. - - Per Supplement 247 Table TID 60x1, the content item hierarchy is:: - - Row 1 CONTAINER (125007, DCM, "Measurement Group") [M] - Row 2 > Finding Site (363698007, SCT) → Eye (81745001) [M] - Row 3 >> Laterality (272741003, SCT) → CID 244 [M] - Row 6 > Tracking Identifier (112039, DCM) [U] - Row 8 > Measurement (TID 300) × 1-n [MC] - - Note on HTML anchor: the URL fragment ``#sect_TID_6001`` will resolve - once DICOM PS3.16 is updated to incorporate Supplement 247. Until then - refer to the supplement PDF directly: - https://dicom.nema.org/Dicom/News/January2025/docs/sups/sup247.pdf - - This template is invoked 1–2 times (once per eye) by the root templates - :class:`CircumpapillaryRNFLKeyMeasurements` (TID 6004) and - :class:`MacularThicknessKeyMeasurements` (TID 6005). - """ - - def __init__( - self, - laterality: CodedConcept | Code, - measurements: Sequence[Measurement], - finding_site: CodedConcept | Code | None = None, - tracking_identifier: str | None = None, - ) -> None: - """ - Parameters - ---------- - laterality: Union[highdicom.sr.CodedConcept, pydicom.sr.coding.Code] - Laterality of the eye measured. See - :dcm:`CID 244 ` "Laterality - Left-Right Only" for options; typically ``codes.cid244.Right`` - or ``codes.cid244.Left``. Encoded as a HAS_CONCEPT_MOD of - the Finding Site item (Table TID 60x1, row 3, double-indent). - measurements: Sequence[highdicom.sr.Measurement] - One or more :class:`~highdicom.sr.Measurement` instances - (TID 300), each encoding a single numeric ophthalmic finding - (e.g. average RNFL thickness from CID 42x3, or a macular - subfield thickness from CID 42x4). Corresponds to row 8 of - TID 60x1 — mandatory when the invoking template supplies a - non-empty ``$Measurement`` parameter. - 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 Supplement 247 row 2. - tracking_identifier: str, optional - Free-text identifier for tracking this measurement group across - studies or analyses. Encoded as - ``(112039, DCM, "Tracking Identifier")`` with relationship type - HAS_OBS_CONTEXT (row 6 of TID 60x1). - - """ # noqa: E501 - super().__init__() - - if finding_site is None: - finding_site = codes.cid4209.Eye - - if not measurements: - raise ValueError( - "Argument 'measurements' must contain at least one item." - ) - for meas in measurements: - if not isinstance(meas, Measurement): - raise TypeError( - 'Each item of "measurements" must have type Measurement.' - ) - - item = ContainerContentItem( - name=codes.DCM.MeasurementGroup, - template_id='6001', - relationship_type=RelationshipTypeValues.CONTAINS, - ) - item.ContentSequence = ContentSequence() - - # Row 2 — Finding Site (M, HAS_CONCEPT_MOD of container). - # Value set constraint: EV (81745001, SCT, "Eye"). - site_item = CodeContentItem( - name=CodedConcept( - value='363698007', - scheme_designator='SCT', - meaning='Finding Site', - ), - value=finding_site, - relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD, - ) - # Row 3 (>>) — Laterality is HAS_CONCEPT_MOD of the Finding Site - # item, NOT of the container. The double-indent (>>) in Table TID - # 60x1 encodes this nested modifier relationship. - site_item.ContentSequence = ContentSequence() - lat_item = CodeContentItem( - name=CodedConcept( - value='272741003', - scheme_designator='SCT', - meaning='Laterality', - ), - value=laterality, - relationship_type=RelationshipTypeValues.HAS_CONCEPT_MOD, - ) - site_item.ContentSequence.append(lat_item) - item.ContentSequence.append(site_item) - - # Row 6 — Tracking Identifier (U, HAS_OBS_CONTEXT). - if tracking_identifier is not None: - tracking_item = TextContentItem( - name=CodedConcept( - value='112039', - scheme_designator='DCM', - meaning='Tracking Identifier', - ), - value=tracking_identifier, - relationship_type=RelationshipTypeValues.HAS_OBS_CONTEXT, - ) - item.ContentSequence.append(tracking_item) - - # Row 8 — Measurements (MC, 1-n, CONTAINS via TID 300). - for meas in measurements: - item.ContentSequence.extend(meas) - - self.append(item) - - -# --------------------------------------------------------------------------- -# TID 6004 (Sup247 TID 60x4) — Retinal Nerve Fiber Layer Key Measurements -# --------------------------------------------------------------------------- - -class CircumpapillaryRNFLKeyMeasurements(Template): - """:dcm:`TID 6004 ` - 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). - Encodes the key quadrant and average RNFL values as defined by - CID 42x3 *RNFL Key Measurements* of Supplement 247. - - Per Supplement 247 Table TID 60x4, the content item hierarchy is:: - - Row 1 CONTAINER (nnn102, 99OPHTHALMO, "RNFL Key Measurements") - Row 2 > Language of Content Item (TID 1204) [U] - Row 4 > Algorithm Identification (TID 4019) [M] - Row 5 > Ophthalmology Measurements Group (TID 6001) × 1-2 [M] - - (Row 3, Observer Context, is not implemented in this PR. - Row 6, bilateral symmetry measurement, is an optional extension.) - - Note: the HTML anchor ``#sect_TID_6004`` will resolve once DICOM PS3.16 - is updated to incorporate Supplement 247. Until then refer to: - https://dicom.nema.org/Dicom/News/January2025/docs/sups/sup247.pdf - - Usage example:: - - from highdicom.sr.templates import ( - AlgorithmIdentification, - CircumpapillaryRNFLKeyMeasurements, - Measurement, - OphthalmologyMeasurementsGroup, - ) - from highdicom.sr.templates.tid6000 import RNFLAverageThickness - from pydicom.sr.codedict import codes - - algo = AlgorithmIdentification(name='Revo FC130', version='1.0') - meas = [ - Measurement( - name=RNFLAverageThickness, - value=121.0, - unit=codes.UCUM.Micrometer, - ), - ] - group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, - measurements=meas, - ) - report = CircumpapillaryRNFLKeyMeasurements( - algorithm_id=algo, - measurement_groups=[group], - ) - """ - - def __init__( - self, - algorithm_id: AlgorithmIdentification, - measurement_groups: Sequence[OphthalmologyMeasurementsGroup], - 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 per Supplement 247 - Table TID 60x4 row 4 (HAS_OBS_CONTEXT). - measurement_groups: Sequence[highdicom.sr.OphthalmologyMeasurementsGroup] - One or two :class:`OphthalmologyMeasurementsGroup` instances - (TID 6001), one per eye. Each group carries the laterality - and the individual RNFL thickness measurements from CID 42x3. - Two groups are used for bilateral studies (one per eye). - Mandatory per Supplement 247 Table TID 60x4 row 5. - language_of_content_item_and_descendants: Union[highdicom.sr.LanguageOfContentItemAndDescendants, None], optional - Language specification for all SR content items (row 2 of - TID 60x4, HAS_CONCEPT_MOD). 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 - element of ``measurement_groups`` is not an - :class:`OphthalmologyMeasurementsGroup`. - 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, OphthalmologyMeasurementsGroup): - raise TypeError( - 'Each item of "measurement_groups" must have type ' - 'OphthalmologyMeasurementsGroup.' - ) - if not isinstance(algorithm_id, AlgorithmIdentification): - raise TypeError( - 'Argument "algorithm_id" must have type ' - 'AlgorithmIdentification.' - ) - - item = ContainerContentItem( - name=_CONCEPT_RNFL_KEY, - template_id='6004', - ) - item.ContentSequence = ContentSequence() - - # Row 2 — Language of Content Item and Descendants (U). - 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) - - # Row 4 — Algorithm Identification (M, TID 4019, HAS_OBS_CONTEXT). - item.ContentSequence.extend(algorithm_id) - - # Row 5 — Ophthalmology Measurements Group (M, 1-2, CONTAINS). - for group in measurement_groups: - item.ContentSequence.extend(group) - - super().__init__([item], is_root=True) - - -# --------------------------------------------------------------------------- -# TID 6005 (Sup247 TID 60x5) — Macular Thickness Key Measurements -# --------------------------------------------------------------------------- - -class MacularThicknessKeyMeasurements(Template): - """:dcm:`TID 6005 ` - 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. Encodes the central subfield thickness (CMT) and the - inner/outer ring sector values defined by CID 42x4 *Macular Thickness - Key Measurements* of Supplement 247. - - The nine ETDRS subfield concept names use their LOINC codes - (``57108-3`` through ``57117-4``), which are finalised in the standard. - Total macular volume uses ``LN 57118-2``; the unit must be - ``UCUM_MICROLITER`` (``uL``). Average macular thickness (``nnn250``) - remains a Supplement 247 draft placeholder under scheme ``99OPHTHALMO``. - - Per Supplement 247 Table TID 60x5, the content item hierarchy is:: - - Row 1 CONTAINER (nnn103, 99OPHTHALMO, "Macular Thickness Key Measurements") - Row 2 > Language of Content Item (TID 1204) [U] - Row 4 > Algorithm Identification (TID 4019) [M] - Row 5 > Ophthalmology Measurements Group (TID 6001) × 1-2 [M] - - (Row 3, Observer Context, is not implemented in this PR.) - - Note: the HTML anchor ``#sect_TID_6005`` will resolve once DICOM PS3.16 - is updated to incorporate Supplement 247. Until then refer to: - https://dicom.nema.org/Dicom/News/January2025/docs/sups/sup247.pdf - - Usage example:: - - from highdicom.sr.templates import ( - AlgorithmIdentification, - MacularThicknessKeyMeasurements, - Measurement, - OphthalmologyMeasurementsGroup, - ) - from highdicom.sr.templates.tid6000 import MacularCenterSubfieldThickness - from pydicom.sr.codedict import codes - - algo = AlgorithmIdentification(name='Cirrus HD-OCT', version='11.0') - meas = [ - Measurement( - name=MacularCenterSubfieldThickness, - value=288.49, - unit=codes.UCUM.Micrometer, - ), - ] - group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, - measurements=meas, - ) - report = MacularThicknessKeyMeasurements( - algorithm_id=algo, - measurement_groups=[group], - ) - """ - - def __init__( - self, - algorithm_id: AlgorithmIdentification, - measurement_groups: Sequence[OphthalmologyMeasurementsGroup], - 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 per Supplement 247 - Table TID 60x5 row 4 (HAS_OBS_CONTEXT). - measurement_groups: Sequence[highdicom.sr.OphthalmologyMeasurementsGroup] - One or two :class:`OphthalmologyMeasurementsGroup` instances - (TID 6001), one per eye. Each group carries the laterality - and the individual macular thickness measurements from CID 42x4. - ETDRS subfield measurements use LOINC codes; the unit for all - thickness values is ``codes.UCUM.Micrometer`` (``um``). - Total volume (``MacularTotalVolume``) uses :data:`UCUM_MICROLITER` - (``uL``). Mandatory per Supplement 247 Table TID 60x5 row 5. - language_of_content_item_and_descendants: Union[highdicom.sr.LanguageOfContentItemAndDescendants, None], optional - Language specification for all SR content items (row 2, - HAS_CONCEPT_MOD). 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 an - :class:`OphthalmologyMeasurementsGroup`. - 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, OphthalmologyMeasurementsGroup): - raise TypeError( - 'Each item of "measurement_groups" must have type ' - 'OphthalmologyMeasurementsGroup.' - ) - if not isinstance(algorithm_id, AlgorithmIdentification): - raise TypeError( - 'Argument "algorithm_id" must have type ' - 'AlgorithmIdentification.' - ) - - item = ContainerContentItem( - name=_CONCEPT_MACULAR_THICKNESS_KEY, - template_id='6005', - ) - item.ContentSequence = ContentSequence() - - # Row 2 — Language of Content Item and Descendants (U). - 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) - - # Row 4 — Algorithm Identification (M, TID 4019, HAS_OBS_CONTEXT). - item.ContentSequence.extend(algorithm_id) - - # Row 5 — Ophthalmology Measurements Group (M, 1-2, CONTAINS). - 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 index fcee32b89..408828963 100644 --- a/tests/test_sr_ophthalmic.py +++ b/tests/test_sr_ophthalmic.py @@ -1,25 +1,12 @@ -"""Tests for Supplement 247 Eyecare Measurement SR templates (TID 6001/6004/6005). +"""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 6001) -- CircumpapillaryRNFLKeyMeasurements (TID 6004) -- MacularThicknessKeyMeasurements (TID 6005) +- OphthalmologyMeasurementsGroup (TID 2120) +- CircumpapillaryRNFLKeyMeasurements (TID 2123) +- MacularThicknessKeyMeasurements (TID 2124) - Two ComprehensiveSR roundtrips (serialise → dcmread → verify values survive) - -Content item navigation paths (post laterality-nesting fix): - group[0] → ContainerContentItem "Measurement Group" - group[0].ContentSequence[0] → Finding Site (CodeContentItem) - group[0].ContentSequence[0].ContentSequence[0] → Laterality (CodeContentItem) - group[0].ContentSequence[1] → first Measurement NumContentItem (no tracking) - group[0].ContentSequence[1] → Tracking Identifier text (with tracking) - group[0].ContentSequence[2] → first Measurement NumContentItem (with tracking) - - report[0] → root ContainerContentItem - report[0].ContentSequence[0] → Language item - report[0].ContentSequence[1] → AlgorithmId item 1 - report[0].ContentSequence[2] → AlgorithmId item 2 - report[0].ContentSequence[3] → first OphthalmologyMeasurementsGroup container - report[0].ContentSequence[4] → second group container (bilateral) """ from io import BytesIO @@ -31,8 +18,12 @@ from pydicom.uid import generate_uid from highdicom.sr.sop import ComprehensiveSR -from highdicom.sr.templates import AlgorithmIdentification, Measurement -from highdicom.sr.templates.tid6000 import ( +from highdicom.sr.templates import ( + AlgorithmIdentification, + Measurement, + TrackingIdentifier, +) +from highdicom.sr.templates.tid2120 import ( AverageMacularThickness, CircumpapillaryRNFLKeyMeasurements, MacularCenterSubfieldThickness, @@ -52,7 +43,6 @@ RNFLSuperiorThickness, RNFLTemporalThickness, RNFLNasalThickness, - RetinalROIRadius, UCUM_MICROLITER, ) @@ -63,6 +53,13 @@ _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, @@ -84,127 +81,137 @@ def _make_algo(name: str = 'Revo FC130', version: str = '1.0') -> AlgorithmIdent # --------------------------------------------------------------------------- -# OphthalmologyMeasurementsGroup (TID 6001) +# OphthalmologyMeasurementsGroup (TID 2120) # --------------------------------------------------------------------------- class TestOphthalmologyMeasurementsGroup: def test_basic_construction(self): group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + 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( - laterality=codes.cid244.Right, + 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( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[_make_rnfl_measurement()], ) container = group[0] - assert container.ContentTemplateSequence[0].TemplateIdentifier == '6001' + assert container.ContentTemplateSequence[0].TemplateIdentifier == '2120' def test_finding_site_default_is_eye(self): group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[_make_rnfl_measurement()], ) - container = group[0] - site_item = container.ContentSequence[0] - # Concept name: Finding Site - assert site_item.ConceptNameCodeSequence[0].CodeValue == '363698007' - # Value: Eye - assert site_item.ConceptCodeSequence[0].CodeValue == '81745001' + site = group.finding_sites[0] + assert site.value == codes.cid4209.Eye def test_finding_site_custom(self): group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Left, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Left, measurements=[_make_rnfl_measurement()], finding_site=codes.cid4209.OpticNerveHead, ) - container = group[0] - site_item = container.ContentSequence[0] - assert site_item.ConceptCodeSequence[0].CodeValue == \ - codes.cid4209.OpticNerveHead.value + site = group.finding_sites[0] + assert site.value == codes.cid4209.OpticNerveHead def test_laterality_nested_inside_finding_site(self): - """Row 3 >> in TID 60x1: laterality is child of Finding Site, not container.""" + """Laterality is a modifier of Finding Site, not of the container.""" group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[_make_rnfl_measurement()], ) - container = group[0] - site_item = container.ContentSequence[0] - # Finding Site must have its own ContentSequence with Laterality inside - assert hasattr(site_item, 'ContentSequence') - lat_item = site_item.ContentSequence[0] - assert lat_item.ConceptNameCodeSequence[0].CodeValue == '272741003' + site = group.finding_sites[0] + assert hasattr(site, 'ContentSequence') + assert site.ContentSequence[0].ConceptNameCodeSequence[0].CodeValue == \ + '272741003' def test_laterality_right(self): group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[_make_rnfl_measurement()], ) - lat = group[0].ContentSequence[0].ContentSequence[0] + site = group.finding_sites[0] # Right eye: EV (24028007, SCT, "Right") - assert lat.ConceptCodeSequence[0].CodeValue == '24028007' + assert site.laterality.value == '24028007' def test_laterality_left(self): group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Left, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Left, measurements=[_make_rnfl_measurement()], ) - lat = group[0].ContentSequence[0].ContentSequence[0] + site = group.finding_sites[0] # Left eye: EV (7771000, SCT, "Left") - assert lat.ConceptCodeSequence[0].CodeValue == '7771000' + 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( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[_make_rnfl_measurement(121.0)], ) - container = group[0] - # Without tracking: measurement is at index 1 - meas_item = container.ContentSequence[1] - assert float(meas_item.MeasuredValueSequence[0].NumericValue) == 121.0 + measurements = group.get_measurements() + assert len(measurements) == 1 + assert measurements[0].value == 121.0 def test_measurement_unit_micrometer(self): group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[_make_rnfl_measurement()], ) - meas_item = group[0].ContentSequence[1] - assert meas_item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0].CodeValue == 'um' + assert group.get_measurements()[0].unit == codes.UCUM.Micrometer def test_tracking_identifier(self): group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id('RNFL-OD-001'), + laterality=codes.cid247.Right, measurements=[_make_rnfl_measurement()], - tracking_identifier='RNFL-OD-001', ) - container = group[0] - # With tracking: [0]=site, [1]=tracking, [2]=measurement - tracking_item = container.ContentSequence[1] - assert tracking_item.ConceptNameCodeSequence[0].CodeValue == '112039' - assert tracking_item.TextValue == 'RNFL-OD-001' + assert group.tracking_identifier == 'RNFL-OD-001' + assert group.tracking_uid is not None - def test_measurement_index_shifts_with_tracking(self): - group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, - measurements=[_make_rnfl_measurement(99.0)], - tracking_identifier='track-01', - ) - # With tracking identifier, measurement moves to index 2 - meas_item = group[0].ContentSequence[2] - assert float(meas_item.MeasuredValueSequence[0].NumericValue) == 99.0 + 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 = [ @@ -213,42 +220,43 @@ def test_multiple_measurements(self): Measurement(name=RNFLSuperiorThickness, value=138.0, unit=codes.UCUM.Micrometer), ] group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=meas_list, ) - container = group[0] - # [0]=site, [1],[2],[3]=3 measurements - assert len(container.ContentSequence) == 4 - vals = [ - float(container.ContentSequence[i].MeasuredValueSequence[0].NumericValue) - for i in (1, 2, 3) - ] + vals = [m.value for m in group.get_measurements()] assert vals == [121.0, 145.0, 138.0] - def test_empty_measurements_raises(self): - with pytest.raises(ValueError, match="at least one"): - OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, - measurements=[], - ) + 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( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=["not a measurement"], ) # --------------------------------------------------------------------------- -# CircumpapillaryRNFLKeyMeasurements (TID 6004) +# CircumpapillaryRNFLKeyMeasurements (TID 2123) # --------------------------------------------------------------------------- class TestCircumpapillaryRNFLKeyMeasurements: def _make_group(self, laterality=None) -> OphthalmologyMeasurementsGroup: - lat = laterality or codes.cid244.Right + lat = laterality or codes.cid247.Right return OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), laterality=lat, measurements=[_make_rnfl_measurement()], ) @@ -266,15 +274,15 @@ def test_root_container_code(self): measurement_groups=[self._make_group()], ) root = report[0] - assert root.ConceptNameCodeSequence[0].CodeValue == 'nnn102' - assert root.ConceptNameCodeSequence[0].CodingSchemeDesignator == '99OPHTHALMO' + assert root.ConceptNameCodeSequence[0].CodeValue == '131242' + assert root.ConceptNameCodeSequence[0].CodingSchemeDesignator == 'DCM' def test_template_id(self): report = CircumpapillaryRNFLKeyMeasurements( algorithm_id=_make_algo(), measurement_groups=[self._make_group()], ) - assert report[0].ContentTemplateSequence[0].TemplateIdentifier == '6004' + assert report[0].ContentTemplateSequence[0].TemplateIdentifier == '2123' def test_content_sequence_structure(self): report = CircumpapillaryRNFLKeyMeasurements( @@ -295,8 +303,8 @@ def test_algo_id_present(self): assert algo_name_item.TextValue == 'MyOCT' def test_bilateral(self): - od_group = self._make_group(codes.cid244.Right) - os_group = self._make_group(codes.cid244.Left) + od_group = self._make_group(codes.cid247.Right) + os_group = self._make_group(codes.cid247.Left) report = CircumpapillaryRNFLKeyMeasurements( algorithm_id=_make_algo(), measurement_groups=[od_group, os_group], @@ -306,8 +314,8 @@ def test_bilateral(self): assert len(root.ContentSequence) == 5 def test_laterality_in_bilateral(self): - od_group = self._make_group(codes.cid244.Right) - os_group = self._make_group(codes.cid244.Left) + od_group = self._make_group(codes.cid247.Right) + os_group = self._make_group(codes.cid247.Left) report = CircumpapillaryRNFLKeyMeasurements( algorithm_id=_make_algo(), measurement_groups=[od_group, os_group], @@ -316,10 +324,13 @@ def test_laterality_in_bilateral(self): od_container = root.ContentSequence[3] os_container = root.ContentSequence[4] - od_lat = od_container.ContentSequence[0].ContentSequence[0] - os_lat = os_container.ContentSequence[0].ContentSequence[0] - assert od_lat.ConceptCodeSequence[0].CodeValue == '24028007' # Right - assert os_lat.ConceptCodeSequence[0].CodeValue == '7771000' # Left + od_lat = od_group.finding_sites[0].laterality + os_lat = os_group.finding_sites[0].laterality + assert od_lat.value == '24028007' # Right + assert os_lat.value == '7771000' # Left + # sanity: the containers landed at the expected root positions + assert od_container.ConceptNameCodeSequence[0].CodeValue == '125007' + assert os_container.ConceptNameCodeSequence[0].CodeValue == '125007' def test_more_than_two_groups_raises(self): with pytest.raises(ValueError, match="at most two"): @@ -353,19 +364,18 @@ def test_wrong_group_type_raises(self): measurement_groups=["not a group"], ) - def test_rnfl_codes_are_99ophthalmo(self): - """All RNFL measurement concept names use scheme 99OPHTHALMO.""" - for concept in ( - RNFLAverageThickness, - RNFLInferiorThickness, - RNFLSuperiorThickness, - RNFLTemporalThickness, - RNFLNasalThickness, - RetinalROIRadius, - ): - assert concept.scheme_designator == '99OPHTHALMO', ( - f"{concept.meaning} should use 99OPHTHALMO, got {concept.scheme_designator}" - ) + 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', + } + for concept, value in expected.items(): + assert concept.scheme_designator == 'DCM' + assert concept.value == value def test_full_rnfl_quadrants(self): """All five RNFL quadrant measurements encode correctly.""" @@ -377,30 +387,29 @@ def test_full_rnfl_quadrants(self): Measurement(name=RNFLNasalThickness, value=95.0, unit=codes.UCUM.Micrometer), ] group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=meas_list, ) report = CircumpapillaryRNFLKeyMeasurements( algorithm_id=_make_algo(), measurement_groups=[group], ) - root = report[0] - mg_container = root.ContentSequence[3] - # [0]=site, [1..5]=5 measurements - assert len(mg_container.ContentSequence) == 6 - avg = mg_container.ContentSequence[1] - assert float(avg.MeasuredValueSequence[0].NumericValue) == 121.0 + assert len(report) == 1 + vals = [m.value for m in group.get_measurements()] + assert vals == [121.0, 145.0, 138.0, 80.0, 95.0] # --------------------------------------------------------------------------- -# MacularThicknessKeyMeasurements (TID 6005) +# MacularThicknessKeyMeasurements (TID 2124) # --------------------------------------------------------------------------- class TestMacularThicknessKeyMeasurements: def _make_group(self, value: float = 281.4) -> OphthalmologyMeasurementsGroup: return OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[_make_macular_measurement(value)], ) @@ -417,15 +426,15 @@ def test_root_container_code(self): measurement_groups=[self._make_group()], ) root = report[0] - assert root.ConceptNameCodeSequence[0].CodeValue == 'nnn103' - assert root.ConceptNameCodeSequence[0].CodingSchemeDesignator == '99OPHTHALMO' + 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=[self._make_group()], ) - assert report[0].ContentTemplateSequence[0].TemplateIdentifier == '6005' + assert report[0].ContentTemplateSequence[0].TemplateIdentifier == '2124' def test_content_sequence_structure(self): report = MacularThicknessKeyMeasurements( @@ -455,9 +464,9 @@ def test_etdrs_codes_are_loinc(self): f"{concept.meaning} should use LN, got {concept.scheme_designator}" ) - def test_average_macular_thickness_is_99ophthalmo(self): - assert AverageMacularThickness.scheme_designator == '99OPHTHALMO' - assert AverageMacularThickness.value == 'nnn250' + 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.""" @@ -472,11 +481,11 @@ def test_total_volume_uses_ul(self): unit=UCUM_MICROLITER, ) group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[meas], ) - meas_item = group[0].ContentSequence[1] - assert meas_item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0].CodeValue == 'uL' + assert group.get_measurements()[0].unit == UCUM_MICROLITER def test_full_etdrs_grid(self): """Nine ETDRS subfields + total volume encode correctly.""" @@ -493,47 +502,42 @@ def test_full_etdrs_grid(self): Measurement(name=MacularTotalVolume, value=8.42, unit=UCUM_MICROLITER), ] group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=meas_list, ) report = MacularThicknessKeyMeasurements( algorithm_id=_make_algo(), measurement_groups=[group], ) - root = report[0] - mg_container = root.ContentSequence[3] - # [0]=site, [1..10]=10 measurements - assert len(mg_container.ContentSequence) == 11 - - # Verify CMT value - cmt_item = mg_container.ContentSequence[1] - assert float(cmt_item.MeasuredValueSequence[0].NumericValue) == 281.4 + assert len(report) == 1 - # Verify total volume uses uL - vol_item = mg_container.ContentSequence[10] - assert vol_item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0].CodeValue == 'uL' + measurements = group.get_measurements() + assert len(measurements) == 10 + assert measurements[0].value == 281.4 + assert measurements[-1].unit == UCUM_MICROLITER def test_bilateral_macular(self): od = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[_make_macular_measurement(281.4)], ) - os = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Left, + os_ = OphthalmologyMeasurementsGroup( + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Left, measurements=[_make_macular_measurement(275.0)], ) report = MacularThicknessKeyMeasurements( algorithm_id=_make_algo(), - measurement_groups=[od, os], + measurement_groups=[od, os_], ) root = report[0] # [0]=Language, [1],[2]=AlgoId, [3]=OD, [4]=OS assert len(root.ContentSequence) == 5 - od_meas = root.ContentSequence[3].ContentSequence[1] - os_meas = root.ContentSequence[4].ContentSequence[1] - assert float(od_meas.MeasuredValueSequence[0].NumericValue) == 281.4 - assert float(os_meas.MeasuredValueSequence[0].NumericValue) == 275.0 + 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"): @@ -560,14 +564,15 @@ def ref_ds(self): return dcmread(str(_DATA_DIR / 'ct_image.dcm')) def test_rnfl_roundtrip(self, ref_ds): - """TID 6004: numeric value 121.0 µm and code 'nnn400' survive dcmread.""" + """TID 2123: numeric value 121.0 µm and code '131264' survive dcmread.""" meas = Measurement( name=RNFLAverageThickness, value=121.0, unit=codes.UCUM.Micrometer, ) group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[meas], ) template = CircumpapillaryRNFLKeyMeasurements( @@ -593,20 +598,25 @@ def test_rnfl_roundtrip(self, ref_ds): # children directly: [0]=Language, [1][2]=AlgoId, [3]=MeasGroup mg = sr_back.ContentSequence[3] assert mg.ValueType == 'CONTAINER' - meas_item = mg.ContentSequence[1] + 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) == 121.0 - assert meas_item.ConceptNameCodeSequence[0].CodeValue == 'nnn400' + assert meas_item.ConceptNameCodeSequence[0].CodeValue == '131264' assert meas_item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0].CodeValue == 'um' def test_macular_roundtrip_loinc_code_survives(self, ref_ds): - """TID 6005: LOINC code '57109-1' (CMT) survives serialisation.""" + """TID 2124: LOINC code '57109-1' (CMT) survives serialisation.""" meas = Measurement( name=MacularCenterSubfieldThickness, value=281.4, unit=codes.UCUM.Micrometer, ) group = OphthalmologyMeasurementsGroup( - laterality=codes.cid244.Right, + tracking_identifier=_make_tracking_id(), + laterality=codes.cid247.Right, measurements=[meas], ) template = MacularThicknessKeyMeasurements( @@ -632,7 +642,11 @@ def test_macular_roundtrip_loinc_code_survives(self, ref_ds): # children directly: [0]=Language, [1][2]=AlgoId, [3]=MeasGroup mg = sr_back.ContentSequence[3] assert mg.ValueType == 'CONTAINER' - meas_item = mg.ContentSequence[1] + 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' From d81e2bdb8c57f05ed522d76bb4acbb711f512599 Mon Sep 17 00:00:00 2001 From: Noel Jaurrieta Date: Thu, 3 Sep 2026 19:07:55 -0600 Subject: [PATCH 6/6] feat: implement TID 2123 sector/clockface split and named-parameter groups (Option B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the third of CPBridge's three high-level review comments on #407 — the conformance-enforcement approach Noel committed to in the PR thread. - RNFLSectorMeasurementsGroup: TID 2120 invoked in the "sector" role (TID 2123 Row 5). Named params for CID 4283 sector values, mandatory retinal_roi_width, and sector_method validated against the 5 CID 4282 codes (Garway-Heath, quadrant, semicircular, SNIT, quadrant-octant). - RNFLClockfaceMeasurementsGroup: TID 2120 invoked in the "clockface" role (TID 2123 Row 6). Fixed RNFL Clockface Method; clockface_measurements maps position 1-12 to CID 4284 thickness values. - CircumpapillaryRNFLKeyMeasurements now takes sector_measurement_groups and clockface_measurement_groups separately (either or both), plus rnfl_symmetry (Row 7). Bilaterality is judged across both lists combined; rnfl_symmetry is required when both eyes are represented and rejected otherwise, matching the standard's MC condition strictly. - MacularMeasurementsGroup: same named-parameter treatment for TID 2124 Row 5 (9 ETDRS subfields + total volume + average thickness). A successful construction now guarantees a conformant object, per the Option B philosophy discussed with Chris. OphthalmologyMeasurementsGroup remains available directly for other TID 2120 invocations. --- src/highdicom/sr/__init__.py | 9 + src/highdicom/sr/templates/__init__.py | 6 + src/highdicom/sr/templates/tid2120.py | 664 +++++++++++++++++++++---- tests/test_sr_ophthalmic.py | 509 ++++++++++++------- 4 files changed, 926 insertions(+), 262 deletions(-) diff --git a/src/highdicom/sr/__init__.py b/src/highdicom/sr/__init__.py index 8bd434768..039813e82 100644 --- a/src/highdicom/sr/__init__.py +++ b/src/highdicom/sr/__init__.py @@ -38,6 +38,7 @@ ImageLibrary, ImageLibraryEntryDescriptors, LanguageOfContentItemAndDescendants, + MacularMeasurementsGroup, MacularThicknessKeyMeasurements, Measurement, MeasurementProperties, @@ -51,6 +52,8 @@ PersonObserverIdentifyingAttributes, PlanarROIMeasurementsAndQualitativeEvaluations, QualitativeEvaluation, + RNFLClockfaceMeasurementsGroup, + RNFLSectorMeasurementsGroup, SubjectContext, SubjectContextDevice, SubjectContextFetus, @@ -100,6 +103,7 @@ __all__ = [ 'AlgorithmIdentification', + 'CircumpapillaryRNFLKeyMeasurements', 'CodeContentItem', 'CodedConcept', 'CompositeContentItem', @@ -125,6 +129,8 @@ 'ImageRegion3D', 'LanguageOfContentItemAndDescendants', 'LongitudinalTemporalOffsetFromEvent', + 'MacularMeasurementsGroup', + 'MacularThicknessKeyMeasurements', 'Measurement', 'MeasurementProperties', 'MeasurementReport', @@ -134,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 index e34f8e4f2..42911ee85 100644 --- a/src/highdicom/sr/templates/__init__.py +++ b/src/highdicom/sr/templates/__init__.py @@ -43,8 +43,11 @@ # Eyecare Measurement Templates (TID 2120, 2123, 2124) from highdicom.sr.templates.tid2120 import ( CircumpapillaryRNFLKeyMeasurements, + MacularMeasurementsGroup, MacularThicknessKeyMeasurements, OphthalmologyMeasurementsGroup, + RNFLClockfaceMeasurementsGroup, + RNFLSectorMeasurementsGroup, ) __all__ = [ @@ -76,6 +79,9 @@ "VolumetricROIMeasurementsAndQualitativeEvaluations", # Eyecare Measurement Templates family "OphthalmologyMeasurementsGroup", + "RNFLSectorMeasurementsGroup", + "RNFLClockfaceMeasurementsGroup", + "MacularMeasurementsGroup", "CircumpapillaryRNFLKeyMeasurements", "MacularThicknessKeyMeasurements", ] diff --git a/src/highdicom/sr/templates/tid2120.py b/src/highdicom/sr/templates/tid2120.py index cba32cd1a..0ed8df12b 100644 --- a/src/highdicom/sr/templates/tid2120.py +++ b/src/highdicom/sr/templates/tid2120.py @@ -1,4 +1,4 @@ -"""DICOM SR templates for Supplement 247 — Eyecare Measurement Templates. +"""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; @@ -9,25 +9,32 @@ :dcm:`TID 2123 `, :dcm:`TID 2124 ` -Scope of this implementation ------------------------------ -TID 2123 *Circumpapillary Retinal Nerve Fiber Layer Key Measurements* is -defined by the ratified standard as invoking TID 2120 in two structurally -distinct roles: a "sector" group (:dcm:`CID 4282 ` -sector methods, e.g. Garway-Heath or quadrant sectors) and a separate -"clockface" group (12 clockface-position measurements), plus an optional -bilateral RNFL-symmetry measurement. This implementation does not yet -distinguish these roles: :class:`CircumpapillaryRNFLKeyMeasurements` accepts -a flat list of :class:`OphthalmologyMeasurementsGroup` instances (one or two, -per eye), same as :class:`MacularThicknessKeyMeasurements`. Enforcing the -sector/clockface split (and validating measurements against the CID that -matches each role) is deferred to a follow-up that also addresses -constructor-level conformance enforcement more broadly. +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 Sequence +from collections.abc import Mapping, Sequence from pydicom.sr.codedict import codes from pydicom.sr.coding import Code @@ -76,6 +83,49 @@ 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 @@ -112,6 +162,58 @@ 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. # @@ -213,9 +315,13 @@ class OphthalmologyMeasurementsGroup(MeasurementsAndQualitativeEvaluations): rather than reimplementing shared TID 1501 behavior (tracking identifier, source images, etc.). - This template is invoked 1–2 times (once per eye) by the root templates - :class:`CircumpapillaryRNFLKeyMeasurements` (TID 2123) and - :class:`MacularThicknessKeyMeasurements` (TID 2124). + 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__( @@ -313,6 +419,229 @@ def __init__( 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 # --------------------------------------------------------------------------- @@ -324,47 +653,46 @@ class CircumpapillaryRNFLKeyMeasurements(Template): Type: Extensible · Order: Non-Significant · Root: Yes Root template for circumpapillary retinal nerve fiber layer (RNFL) - thickness measurements obtained by ophthalmic tomography (OCT). - - See the module docstring for the scope of this implementation relative - to the ratified standard's sector/clockface group split, which is not - yet enforced here. + 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, + from highdicom.sr.templates import AlgorithmIdentification, TrackingIdentifier + from highdicom.sr.templates.tid2120 import ( CircumpapillaryRNFLKeyMeasurements, - Measurement, - OphthalmologyMeasurementsGroup, - TrackingIdentifier, + GarwayHeathSectors, + RNFLSectorMeasurementsGroup, ) - from highdicom.sr.templates.tid2120 import RNFLAverageThickness from pydicom.sr.codedict import codes algo = AlgorithmIdentification(name='Revo FC130', version='1.0') - meas = [ - Measurement( - name=RNFLAverageThickness, - value=121.0, - unit=codes.UCUM.Micrometer, - ), - ] - group = OphthalmologyMeasurementsGroup( + group = RNFLSectorMeasurementsGroup( tracking_identifier=TrackingIdentifier(identifier='RNFL-OD'), laterality=codes.cid247.Right, - measurements=meas, + sector_method=GarwayHeathSectors, + retinal_roi_width=3.4, + average=121.0, ) report = CircumpapillaryRNFLKeyMeasurements( algorithm_id=algo, - measurement_groups=[group], + sector_measurement_groups=[group], ) """ def __init__( self, algorithm_id: AlgorithmIdentification, - measurement_groups: Sequence[OphthalmologyMeasurementsGroup], + 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, @@ -375,10 +703,17 @@ def __init__( algorithm_id: highdicom.sr.AlgorithmIdentification Identification of the algorithm used to produce the RNFL measurements (TID 4019). Mandatory. - measurement_groups: Sequence[highdicom.sr.OphthalmologyMeasurementsGroup] - One or two :class:`OphthalmologyMeasurementsGroup` instances - (TID 2120), one per eye. Two groups are used for bilateral - studies (one per eye). + 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. @@ -387,29 +722,43 @@ def __init__( ------ TypeError If ``algorithm_id`` is not an - :class:`~highdicom.sr.AlgorithmIdentification`, or if any - element of ``measurement_groups`` is not an - :class:`OphthalmologyMeasurementsGroup`. + :class:`~highdicom.sr.AlgorithmIdentification`, or if any group + does not have the expected type. ValueError - If ``measurement_groups`` is empty or contains more than two - items. + 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 measurement_groups: + if not sector_measurement_groups and not clockface_measurement_groups: raise ValueError( - "Argument 'measurement_groups' must contain at least one item." + 'At least one of "sector_measurement_groups" or ' + '"clockface_measurement_groups" must be provided.' ) - if len(measurement_groups) > 2: + if len(sector_measurement_groups) > 2: raise ValueError( - "Argument 'measurement_groups' must contain at most two items " - "(one per eye)." + 'Argument "sector_measurement_groups" must contain at most ' + 'two items (one per eye).' ) - for g in measurement_groups: - if not isinstance(g, OphthalmologyMeasurementsGroup): + 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 "measurement_groups" must have type ' - 'OphthalmologyMeasurementsGroup.' + '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( @@ -417,6 +766,23 @@ def __init__( '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', @@ -430,12 +796,145 @@ def __init__( item.ContentSequence.extend(algorithm_id) - for group in measurement_groups: + 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 # --------------------------------------------------------------------------- @@ -448,37 +947,23 @@ class MacularThicknessKeyMeasurements(Template): Root template for macular thickness measurements obtained by ophthalmic tomography (OCT) using the Early Treatment of Diabetic Retinopathy Study - (ETDRS) grid. - - The nine ETDRS subfield concept names use their LOINC codes - (``57108-3`` through ``57117-4``). Total macular volume uses - ``LN 57118-2``; the unit must be ``UCUM_MICROLITER`` (``uL``). Average - macular thickness uses DCM ``131255``. + (ETDRS) grid. Wraps one or two :class:`MacularMeasurementsGroup` + instances, one per eye. Usage example:: - from highdicom.sr.templates import ( - AlgorithmIdentification, + from highdicom.sr.templates import AlgorithmIdentification, TrackingIdentifier + from highdicom.sr.templates.tid2120 import ( + MacularMeasurementsGroup, MacularThicknessKeyMeasurements, - Measurement, - OphthalmologyMeasurementsGroup, - TrackingIdentifier, ) - from highdicom.sr.templates.tid2120 import MacularCenterSubfieldThickness from pydicom.sr.codedict import codes algo = AlgorithmIdentification(name='Cirrus HD-OCT', version='11.0') - meas = [ - Measurement( - name=MacularCenterSubfieldThickness, - value=288.49, - unit=codes.UCUM.Micrometer, - ), - ] - group = OphthalmologyMeasurementsGroup( + group = MacularMeasurementsGroup( tracking_identifier=TrackingIdentifier(identifier='Macula-OD'), laterality=codes.cid247.Right, - measurements=meas, + center_subfield=288.49, ) report = MacularThicknessKeyMeasurements( algorithm_id=algo, @@ -489,7 +974,7 @@ class MacularThicknessKeyMeasurements(Template): def __init__( self, algorithm_id: AlgorithmIdentification, - measurement_groups: Sequence[OphthalmologyMeasurementsGroup], + measurement_groups: Sequence[MacularMeasurementsGroup], language_of_content_item_and_descendants: ( LanguageOfContentItemAndDescendants | None ) = None, @@ -500,10 +985,9 @@ def __init__( algorithm_id: highdicom.sr.AlgorithmIdentification Identification of the algorithm used to produce the macular thickness measurements (TID 4019). Mandatory. - measurement_groups: Sequence[highdicom.sr.OphthalmologyMeasurementsGroup] - One or two :class:`OphthalmologyMeasurementsGroup` instances - (TID 2120), one per eye. Each group carries the laterality - and the individual macular thickness measurements. 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. @@ -513,8 +997,8 @@ def __init__( TypeError If ``algorithm_id`` is not an :class:`~highdicom.sr.AlgorithmIdentification`, or if any - element of ``measurement_groups`` is not an - :class:`OphthalmologyMeasurementsGroup`. + element of ``measurement_groups`` is not a + :class:`MacularMeasurementsGroup`. ValueError If ``measurement_groups`` is empty or contains more than two items. @@ -531,10 +1015,10 @@ def __init__( "(one per eye)." ) for g in measurement_groups: - if not isinstance(g, OphthalmologyMeasurementsGroup): + if not isinstance(g, MacularMeasurementsGroup): raise TypeError( 'Each item of "measurement_groups" must have type ' - 'OphthalmologyMeasurementsGroup.' + 'MacularMeasurementsGroup.' ) if not isinstance(algorithm_id, AlgorithmIdentification): raise TypeError( diff --git a/tests/test_sr_ophthalmic.py b/tests/test_sr_ophthalmic.py index 408828963..bd0327e45 100644 --- a/tests/test_sr_ophthalmic.py +++ b/tests/test_sr_ophthalmic.py @@ -3,8 +3,12 @@ TIDs 6001/6004/6005). Tests cover: -- OphthalmologyMeasurementsGroup (TID 2120) -- CircumpapillaryRNFLKeyMeasurements (TID 2123) +- 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) """ @@ -26,11 +30,13 @@ from highdicom.sr.templates.tid2120 import ( AverageMacularThickness, CircumpapillaryRNFLKeyMeasurements, + GarwayHeathSectors, MacularCenterSubfieldThickness, MacularInnerSuperiorThickness, MacularInnerNasalThickness, MacularInnerInferiorThickness, MacularInnerTemporalThickness, + MacularMeasurementsGroup, MacularOuterSuperiorThickness, MacularOuterNasalThickness, MacularOuterInferiorThickness, @@ -38,11 +44,17 @@ MacularThicknessKeyMeasurements, MacularTotalVolume, OphthalmologyMeasurementsGroup, + QuadrantSectors, RNFLAverageThickness, + RNFLClockfaceMeasurementsGroup, + RNFLClockfaceMethod, RNFLInferiorThickness, + RNFLSectorMeasurementsGroup, RNFLSuperiorThickness, + RNFLSymmetry, RNFLTemporalThickness, RNFLNasalThickness, + RetinalROIWidth, UCUM_MICROLITER, ) @@ -80,8 +92,42 @@ def _make_algo(name: str = 'Revo FC130', version: str = '1.0') -> AlgorithmIdent 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) +# OphthalmologyMeasurementsGroup (TID 2120, generic) # --------------------------------------------------------------------------- @@ -248,30 +294,158 @@ def test_wrong_measurement_type_raises(self): # --------------------------------------------------------------------------- -# CircumpapillaryRNFLKeyMeasurements (TID 2123) +# RNFLSectorMeasurementsGroup (TID 2120, sector role, TID 2123 Row 5) # --------------------------------------------------------------------------- -class TestCircumpapillaryRNFLKeyMeasurements: - def _make_group(self, laterality=None) -> OphthalmologyMeasurementsGroup: - lat = laterality or codes.cid247.Right - return OphthalmologyMeasurementsGroup( +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=lat, - measurements=[_make_rnfl_measurement()], + 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(), - measurement_groups=[self._make_group()], + 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(), - measurement_groups=[self._make_group()], + sector_measurement_groups=[_make_sector_group()], ) root = report[0] assert root.ConceptNameCodeSequence[0].CodeValue == '131242' @@ -280,88 +454,109 @@ def test_root_container_code(self): def test_template_id(self): report = CircumpapillaryRNFLKeyMeasurements( algorithm_id=_make_algo(), - measurement_groups=[self._make_group()], + sector_measurement_groups=[_make_sector_group()], ) assert report[0].ContentTemplateSequence[0].TemplateIdentifier == '2123' - def test_content_sequence_structure(self): - report = CircumpapillaryRNFLKeyMeasurements( - algorithm_id=_make_algo(), - measurement_groups=[self._make_group()], - ) - root = report[0] - # [0]=Language, [1],[2]=AlgoId (2 items), [3]=Measurement Group - assert len(root.ContentSequence) == 4 - def test_algo_id_present(self): report = CircumpapillaryRNFLKeyMeasurements( algorithm_id=_make_algo('MyOCT', '2.3'), - measurement_groups=[self._make_group()], + sector_measurement_groups=[_make_sector_group()], ) root = report[0] algo_name_item = root.ContentSequence[1] assert algo_name_item.TextValue == 'MyOCT' - def test_bilateral(self): - od_group = self._make_group(codes.cid247.Right) - os_group = self._make_group(codes.cid247.Left) - report = CircumpapillaryRNFLKeyMeasurements( - algorithm_id=_make_algo(), - measurement_groups=[od_group, os_group], - ) - root = report[0] - # [0]=Language, [1],[2]=AlgoId, [3]=OD group, [4]=OS group - assert len(root.ContentSequence) == 5 + 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_laterality_in_bilateral(self): - od_group = self._make_group(codes.cid247.Right) - os_group = self._make_group(codes.cid247.Left) + 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(), - measurement_groups=[od_group, os_group], + sector_measurement_groups=[od, os_], + rnfl_symmetry=97.3, ) root = report[0] - od_container = root.ContentSequence[3] - os_container = root.ContentSequence[4] + 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_], + ) - od_lat = od_group.finding_sites[0].laterality - os_lat = os_group.finding_sites[0].laterality - assert od_lat.value == '24028007' # Right - assert os_lat.value == '7771000' # Left - # sanity: the containers landed at the expected root positions - assert od_container.ConceptNameCodeSequence[0].CodeValue == '125007' - assert os_container.ConceptNameCodeSequence[0].CodeValue == '125007' + 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_more_than_two_groups_raises(self): + 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(), - measurement_groups=[ - self._make_group(), - self._make_group(), - self._make_group(), + sector_measurement_groups=[ + _make_sector_group(identifier='1'), + _make_sector_group(identifier='2'), + _make_sector_group(identifier='3'), ], ) - def test_empty_groups_raises(self): - with pytest.raises(ValueError, match="at least one"): + def test_more_than_two_clockface_groups_raises(self): + with pytest.raises(ValueError, match="at most two"): CircumpapillaryRNFLKeyMeasurements( algorithm_id=_make_algo(), - measurement_groups=[], + 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", - measurement_groups=[self._make_group()], + sector_measurement_groups=[_make_sector_group()], ) - def test_wrong_group_type_raises(self): - with pytest.raises(TypeError, match="OphthalmologyMeasurementsGroup"): + def test_wrong_sector_group_type_raises(self): + with pytest.raises(TypeError, match="RNFLSectorMeasurementsGroup"): CircumpapillaryRNFLKeyMeasurements( algorithm_id=_make_algo(), - measurement_groups=["not a group"], + 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): @@ -372,32 +567,70 @@ def test_rnfl_codes_are_final_dcm_codes(self): 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 - def test_full_rnfl_quadrants(self): - """All five RNFL quadrant measurements encode correctly.""" - 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), - Measurement(name=RNFLTemporalThickness, value=80.0, unit=codes.UCUM.Micrometer), - Measurement(name=RNFLNasalThickness, value=95.0, unit=codes.UCUM.Micrometer), - ] - group = OphthalmologyMeasurementsGroup( + +# --------------------------------------------------------------------------- +# 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, - measurements=meas_list, + total_volume=8.42, ) - report = CircumpapillaryRNFLKeyMeasurements( - algorithm_id=_make_algo(), - measurement_groups=[group], + vol = next( + m for m in group.get_measurements() if m.name == MacularTotalVolume ) - assert len(report) == 1 - vals = [m.value for m in group.get_measurements()] - assert vals == [121.0, 145.0, 138.0, 80.0, 95.0] + 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' # --------------------------------------------------------------------------- @@ -406,24 +639,17 @@ def test_full_rnfl_quadrants(self): class TestMacularThicknessKeyMeasurements: - def _make_group(self, value: float = 281.4) -> OphthalmologyMeasurementsGroup: - return OphthalmologyMeasurementsGroup( - tracking_identifier=_make_tracking_id(), - laterality=codes.cid247.Right, - measurements=[_make_macular_measurement(value)], - ) - def test_basic_construction(self): report = MacularThicknessKeyMeasurements( algorithm_id=_make_algo(), - measurement_groups=[self._make_group()], + measurement_groups=[_make_macular_group()], ) assert len(report) == 1 def test_root_container_code(self): report = MacularThicknessKeyMeasurements( algorithm_id=_make_algo(), - measurement_groups=[self._make_group()], + measurement_groups=[_make_macular_group()], ) root = report[0] assert root.ConceptNameCodeSequence[0].CodeValue == '131243' @@ -432,14 +658,14 @@ def test_root_container_code(self): def test_template_id(self): report = MacularThicknessKeyMeasurements( algorithm_id=_make_algo(), - measurement_groups=[self._make_group()], + 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=[self._make_group()], + measurement_groups=[_make_macular_group()], ) root = report[0] # [0]=Language, [1],[2]=AlgoId (2 items), [3]=Measurement Group @@ -473,61 +699,9 @@ def test_ucum_microliter_constant(self): assert UCUM_MICROLITER.value == 'uL' assert UCUM_MICROLITER.scheme_designator == 'UCUM' - def test_total_volume_uses_ul(self): - """MacularTotalVolume measurement must use UCUM_MICROLITER unit.""" - meas = Measurement( - name=MacularTotalVolume, - value=8.42, - unit=UCUM_MICROLITER, - ) - group = OphthalmologyMeasurementsGroup( - tracking_identifier=_make_tracking_id(), - laterality=codes.cid247.Right, - measurements=[meas], - ) - assert group.get_measurements()[0].unit == UCUM_MICROLITER - - def test_full_etdrs_grid(self): - """Nine ETDRS subfields + total volume encode correctly.""" - meas_list = [ - Measurement(name=MacularCenterSubfieldThickness, value=281.4, unit=codes.UCUM.Micrometer), - Measurement(name=MacularInnerSuperiorThickness, value=338.0, unit=codes.UCUM.Micrometer), - Measurement(name=MacularInnerNasalThickness, value=350.0, unit=codes.UCUM.Micrometer), - Measurement(name=MacularInnerInferiorThickness, value=335.0, unit=codes.UCUM.Micrometer), - Measurement(name=MacularInnerTemporalThickness, value=320.0, unit=codes.UCUM.Micrometer), - Measurement(name=MacularOuterSuperiorThickness, value=290.0, unit=codes.UCUM.Micrometer), - Measurement(name=MacularOuterNasalThickness, value=305.0, unit=codes.UCUM.Micrometer), - Measurement(name=MacularOuterInferiorThickness, value=285.0, unit=codes.UCUM.Micrometer), - Measurement(name=MacularOuterTemporalThickness, value=265.0, unit=codes.UCUM.Micrometer), - Measurement(name=MacularTotalVolume, value=8.42, unit=UCUM_MICROLITER), - ] - group = OphthalmologyMeasurementsGroup( - tracking_identifier=_make_tracking_id(), - laterality=codes.cid247.Right, - measurements=meas_list, - ) - report = MacularThicknessKeyMeasurements( - algorithm_id=_make_algo(), - measurement_groups=[group], - ) - assert len(report) == 1 - - measurements = group.get_measurements() - assert len(measurements) == 10 - assert measurements[0].value == 281.4 - assert measurements[-1].unit == UCUM_MICROLITER - def test_bilateral_macular(self): - od = OphthalmologyMeasurementsGroup( - tracking_identifier=_make_tracking_id(), - laterality=codes.cid247.Right, - measurements=[_make_macular_measurement(281.4)], - ) - os_ = OphthalmologyMeasurementsGroup( - tracking_identifier=_make_tracking_id(), - laterality=codes.cid247.Left, - measurements=[_make_macular_measurement(275.0)], - ) + 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_], @@ -544,12 +718,19 @@ def test_more_than_two_groups_raises(self): MacularThicknessKeyMeasurements( algorithm_id=_make_algo(), measurement_groups=[ - self._make_group(), - self._make_group(), - self._make_group(), + _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 @@ -565,19 +746,10 @@ def ref_ds(self): def test_rnfl_roundtrip(self, ref_ds): """TID 2123: numeric value 121.0 µm and code '131264' survive dcmread.""" - meas = Measurement( - name=RNFLAverageThickness, - value=121.0, - unit=codes.UCUM.Micrometer, - ) - group = OphthalmologyMeasurementsGroup( - tracking_identifier=_make_tracking_id(), - laterality=codes.cid247.Right, - measurements=[meas], - ) + group = _make_sector_group(average=121.0) template = CircumpapillaryRNFLKeyMeasurements( algorithm_id=_make_algo(), - measurement_groups=[group], + sector_measurement_groups=[group], ) sr = ComprehensiveSR( @@ -598,27 +770,20 @@ def test_rnfl_roundtrip(self, ref_ds): # 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) == 121.0 - assert meas_item.ConceptNameCodeSequence[0].CodeValue == '131264' - assert meas_item.MeasuredValueSequence[0].MeasurementUnitsCodeSequence[0].CodeValue == 'um' + 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.""" - meas = Measurement( - name=MacularCenterSubfieldThickness, - value=281.4, - unit=codes.UCUM.Micrometer, - ) - group = OphthalmologyMeasurementsGroup( - tracking_identifier=_make_tracking_id(), - laterality=codes.cid247.Right, - measurements=[meas], - ) + group = _make_macular_group(281.4) template = MacularThicknessKeyMeasurements( algorithm_id=_make_algo(), measurement_groups=[group],