feat: restructure sr/templates → package; add Supplement 247 TIDs 6001/6004/6005#407
Conversation
…1/6004/6005
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#406 (draft)
|
@CPBridge This PR is ready for review. All 41 new ophthalmic SR tests pass alongside the 294 existing test_sr.py tests. Happy to address any feedback. |
|
Great! I will take a look at this soon, sorry if it takes a few weeks as I have a lot on my plate at the moment |
There was a problem hiding this comment.
I'm sorry it took me so long to get to this. The last few months have been very busy with personal things, but now I have my head around the templates and what you are trying to do here, I hope future iterations will be much quicker.
I have three high-level comments, and then a bunch of smaller ones below.
-
Firstly, and most importantly, it appears that Supplement 247 was approved into the standard in version 2025b (according to David Clunie's page). It seems your implementation was based on an earlier draft and there have been some changes. Most notably the TIDs have changed:
Would you please update this in line with the approved and released version of the standard?
Relatedly, I would quite like to avoid releasing the codes required as part of the public API of highdicom, since the intention would be to use them only temporarily. To that end, what is the status of the PRs to add required codes to pydicom? I took a look and was not able to see one.
-
TID 2120 (formally TID 6001 in the draft version) is described as "a proper subset of TID 1501 with some optional extensions". Highdicom already has class representing TID1501, namely
highdicom.sr.MeasurementsAndQualitativeEvaluations, so I'm wondering whether this class should be a subclass of that class (or possibly a subclass of its base classhighdicom.sr._MeasurementsAndQualitativeEvaluations). That would seem to remove some redundant code and allow this class to take advantage of some of the optional parts that are already implemented. It would also fix some of the other issues I highlight below -
It took me a while to get my head around what I think is a new pattern in the standard of invoking templates with entire context groups defining the required measurements that must be present within a measurement group. It is a little tricky to fit this pattern into the way we built templates in the past. It seems that the approach that you have taken is to document which measurements are required within the top level classes (CircumpapillaryRNFLKeyMeasurement and MacularThicknessKeyMeasurements). I am concerned that this is insufficient, and that many (most?) SRs produced would likely be non-compliant due to a user failing to follow these requirements. This is at odds with our philosophy that if a highdicom constructor finishes successfully, the new object is compliant with the standard.
I see two potential ways around this:
- We keep things more or less as they are currently structured, but add logic to the constructors of these two top level classes to check that the measurement groups passed to them meet all the requirements for the template, and give clear error messages if they do not. This would be easier to implement, but I do think putting the whole burden on the user to pass the right combination of parameters will be quite confusing to the user.
- We implement one specialized subclass of the OpthalmologyMeasurementsGroup (or MeasurementsAndQualitativeEvaluations, in line with the second point above) for each invocation of that template. These classes would accept as parameters the measurement values and place them into measurements with the correct codes. We might want to remove some of other optional parameters of the Measurement (TID300) class, or assume they are shared between all measurements in the group, to simplify things a bit. Then the top-level classes accept these specialized subclasses, rather than any MeasurementGroup. While this would be more complex to implement (and there would certainly be more details to figure out), I think the result would be a much more simplified process for the user. What do you think?
| ContainerContentItem, | ||
| ContentSequence, | ||
| TextContentItem, | ||
| UIDRefContentItem, |
There was a problem hiding this comment.
Unused import
| UIDRefContentItem, |
| #: 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') |
There was a problem hiding this comment.
I would rather not have this as part of highdicom's public API.
| laterality: CodedConcept | Code, | ||
| measurements: Sequence[Measurement], | ||
| finding_site: CodedConcept | Code | None = None, | ||
| tracking_identifier: str | None = None, |
There was a problem hiding this comment.
We already have an implementation of TID 4108 "Tracking Identifier" template in what is now tid1500.py. It would seem to make more sense to use that here, since it includes both the text and UID identifiers.
| if finding_site is None: | ||
| finding_site = codes.cid4209.Eye |
There was a problem hiding this comment.
My reading of the template is that this value is fixed as "Eye" and it would be wrong to use anything else (this is the meaning of having an EV, =enumerated value in the value set constraint). So I think this should just be hardcoded with no finding_site parameter allowing the user to change it
| measurements: Sequence[Measurement], | ||
| finding_site: CodedConcept | Code | None = None, | ||
| tracking_identifier: str | None = None, | ||
| ) -> None: |
There was a problem hiding this comment.
While generally I don't think we need to include every optional row of the template's table, I think the "source of measurement" (row 11) would be a particularly important one to include such that measurements can be traced back to the image in which they were made
| if len(measurement_groups) > 2: | ||
| raise ValueError( | ||
| "Argument 'measurement_groups' must contain at most two items " | ||
| "(one per eye)." | ||
| ) |
There was a problem hiding this comment.
Why this restriction? The VM for this row is 1-n and the notes explicitly state
TID 2120 may be invoked once for each eye, or multiple times for a trend series for a single eye
|
|
||
| def __init__( | ||
| self, | ||
| algorithm_id: AlgorithmIdentification, |
There was a problem hiding this comment.
Since we already have a class for TID1002 ObserverContext, it would be straightforward to add this as an optional attribute
| # --------------------------------------------------------------------------- | ||
| # TID 6004 (Sup247 TID 60x4) — Retinal Nerve Fiber Layer Key Measurements | ||
| # --------------------------------------------------------------------------- |
There was a problem hiding this comment.
I would prefer not to have these superfluous "header" comments
| if len(measurement_groups) > 2: | ||
| raise ValueError( | ||
| "Argument 'measurement_groups' must contain at most two items " | ||
| "(one per eye)." | ||
| ) |
There was a problem hiding this comment.
As above, I don't think that this restriction is in line with the standard
| for group in measurement_groups: | ||
| item.ContentSequence.extend(group) | ||
|
|
||
| super().__init__([item], is_root=True) |
There was a problem hiding this comment.
How about row 7 (the symmetry measurement)?
|
Hi @CPBridge , Thank you for the detailed review — and no worries about the time, I really appreciate you working through this carefully and myself as well can surely relate to having a busy mind/life. I have mainly three responses:
We will come back with the updated TIDs, the pydicom PR linked, and a revised architecture implementing Option B with proper inheritance from TID 1501. Thanks again, |
Summary
Implements DICOM Supplement 247 "Eyecare Measurement Templates" (incorporated into PS3.16 2025c) and restructures
highdicom/sr/templatesfrom a single module into a package, as discussed in #355.Templates added
OphthalmologyMeasurementsGroup>>nested inside Finding Site item)CircumpapillaryRNFLKeyMeasurementsMacularThicknessKeyMeasurementsPackage restructure (
sr/templates/)templates.py→templates/tid1500.py(rename only, no content changes)templates/__init__.pyre-exports all previous public names — fully backwards-compatibletemplates/tid6000.py— new module for Supplement 247 TIDsProvisional concept codes
Supplement 247 was balloted with several DCM codes shown as
nnnXXXplaceholders (final values pending WG-09 assignment). They are stored here under private scheme99OPHTHALMO(≤ 16 chars, VR SH) with inline comments marking them as draft.Codes pending a companion PR to pydicom (
pydicom.sr.codedict):nnn102("RNFL Key Measurements"),nnn103("Macular Thickness Key Measurements")nnn400–nnn404(average/quadrant thicknesses),nnn406(ROI radius)nnn250("Average macular thickness")LN 57108-3throughLN 57118-2No
coding_ophthalmology.pywas created, per CPBridge guidance in #406.Tests
New file
tests/test_sr_ophthalmic.py— 41 tests:TestOphthalmologyMeasurementsGroup(14): laterality nesting (Row 3>>), finding site default/custom, tracking identifier, multiple measurements, validation errorsTestCircumpapillaryRNFLKeyMeasurements(13): container code, template ID, bilateral, 99OPHTHALMO scheme check, full quadrant encodingTestMacularThicknessKeyMeasurements(12): LOINC scheme check,uLunit for total volume, full 10-item ETDRS grid, bilateralTestRoundtrip(2): TID 6004 and TID 6005 each serialised toComprehensiveSR→dcmwrite→dcmread→ values verifiedAll 41 new tests pass. All 294 pre-existing
test_sr.pytests pass.Validation
Validated with real clinical OCT data from a production Optopol Revo FC130 unit. CMT = 281.4 µm.
This PR also implements the templates/ package restructure discussed in #355 and designed by @CPBridge.