8000 Support multiframe source images in SEG and PM conversion by michaelonken · Pull Request #558 · QIICR/dcmqi · GitHub
[go: up one dir, main page]

Skip to content

Support multiframe source images in SEG and PM conversion - #558

Merged
fedorov merged 11 commits into
masterfrom
enhanced_multiframe
Sep 14, 2026
Merged

fedorov merged 11 commits into
masterfrom
enhanced_multiframe

Conversation

@michaelonken
@michaelonken michaelonken commented Aug 21, 2026 •
Copy link
Copy Markdown
Member

Support Enhanced Multiframe in itkimage2segimage and itkimage2paramap

Fixes #150. Covers the conversion direction of #217.

Based on #557 — please merge that one first; this PR targets its
branch, so the diff shown here is only the feature. The base switches to
master automatically once the other PR is merged.

The problem

Both converters assumed one image per instance. The slice mapping read
ImagePositionPatient from the top level of each dataset, where a multiframe
image does not carry it, and the created references could only name whole
instances. Multiframe sources therefore produced a meaningless mapping and
unusable references.

What changes

SourceImageIndex now inventories multiframe datasets frame by frame,
recognizing them by their Per-frame Functional Groups Sequence and reading the
positions from the Plane Position (Patient) functional group through
FGInterface. The geometric slice mapping then works for any mix of
single-frame and multiframe sources. Old-style multiframe images without
functional groups are excluded from the mapping with a warning; they can still
be referenced as whole instances when the geometry check is disabled.

References created from that mapping restrict Source Image Sequence items of
multiframe instances to the frames actually used, via Referenced Frame Number.
It is omitted when all frames of an instance are referenced, since a reference
without frame numbers denotes the whole instance — required for this type 1C
attribute (PS3.3 Table 10-3 together with PS3.5 Section 7.4.2). The Common
Instance Reference module stays instance-based and needed no change. Both
converters pick this up through the shared component.

Two deliberate fixes to the shared Derivation Image group written when
--referencesGeometryCheck 0 is used: it is now also created for a single
source dataset (previously it required more than one, which left a single
multiframe source entirely unreferenced), and its Purpose of Reference Code is
now (DCM,121322,"Source image for image processing operation") from CID 7202
instead of the copy-pasted derivation code (DCM,113076,"Segmentation") from
CID 7203.

Tests

The 24x38x3 and 23x38x3 phantoms already shipped a multiframe encoding of
their source series (58d8fe7) that no test used. The existing conversion and
round-trip test pairs are now also run against those, plus dciodvfy on the
results.

Three structures that real Enhanced images use are not covered by that
phantom, and each changes how the references have to be written. Two small
fixtures were generated for them (util/make_multiframe_test_data.py,
deterministic, ~10-15 KB each, derived from the existing mf.dcm so patient,
study and frame of reference stay consistent):

  • several frames on one plane — every segmentation frame must reference
    all source frames of its plane, so Referenced Frame Number becomes
    multi-valued, and non-contiguous with interleaved acquisitions;
  • a shared instead of per-frame Plane Position — only the slice on that
    plane gets references, and since all frames apply, Referenced Frame Number
    must be absent;
  • two multiframe instances from different series referenced at once.

MultiframeSourceVariantsTest checks all three by resolving every Referenced
Frame Number back to the plane position of the source frame, rather than
hardcoding expected numbers. Each of its checks was confirmed to fail when the
corresponding behavior is mutated away. Test count goes from 76 to 92.

Validation against real data

The last commit adds util/multiframe-validation/, a manually run tool that
takes a segmentation from the Imaging Data Commons, converts its referenced
series into a Legacy Converted Enhanced image using highdicom — i.e. a
producer independent of dcmqi — and then runs itkimage2segimage against both
encodings of the same source and compares the results. It asserts identical
segmentation content and that the frame references denote the same source
images as the instance references of the classic run, tracing each frame
number back through the Conversion Source Attributes Sequence.

Run over seven IDC collections (hcc_tace_seg, rider_lung_ct,
nsclc_radiomics, c4kc_kits, nlst, lung_pet_ct_dx, qin_breast; CT and
PET sources, 64 to 126 slices, one to five segments); they are single-frames but
are converted using highdicom in order to produce Enhnaced MF test data. dcmqi
conversion is then run twice: one time on the classic single-frame input, then on the
converted multi-frame input. Output is then compared. All pass. One of them
produced a multi-valued Referenced Frame Number from real data, because that
segmentation sits on a different slice grid than the series it references and
two source slices fall into one segmentation slice. That is exactly the case
the mf-2frames-per-plane fixture reproduces synthetically.

Unrelated finding

On some of those datasets dciodvfy reports a missing type 2
ClinicalTrialCoordinatingCenterName. This predates these changes and is
independent of multiframe sources: dcmqi always writes Clinical Trial Series
ID and Time Point ID, which makes the module present, but writes the
Coordinating Center Name only when the metadata supplies a value. Not
addressed here.

help="Series Number of the created series (default: 100)")
args = ap.parse_args()

files = sorted(glob.glob(os.path.join(args.input_dir, "*.dcm")))
Base automatically changed from refactor_seg_converter to master September 2, 2026 14:34
@fedorov
fedorov force-pushed the enhanced_multiframe branch from 64026c3 to 52455d1 Compare September 2, 2026 14:36
@fedorov
fedorov commented Sep 2, 2026
Copy link
Copy Markdown
Member

Run over seven IDC collections (hcc_tace_seg, rider_lung_ct,
nsclc_radiomics, c4kc_kits, nlst, lung_pet_ct_dx, qin_breast; CT and
PET sources, 64 to 126 slices, one to five segments) — all pass.

@michaelonken here did you mean that you ran it over the legacy enhanced content created with highdicom by Chris? Since I do not think there are any multiframe images in IDC proper. Can you please mention the location of the images you used in testing?

@fedorov
fedorov force-pushed the enhanced_multiframe branch from 52455d1 to 832fea3 Compare September 2, 2026 20:52
@michaelonken
Copy link
Copy Markdown
Member Author

@fedorov

Yes, exactly - there are no multiframe images in IDC, and the
multiframe inputs were indeed created with Chris's highdicom. They are not
pre-existing files though: the validation tooling that ships in this PR
(util/multiframe-validation/) generates them on the fly, locally, for each
run:

For each case the script downloads a segmentation from IDC together with
the (classic, single-frame) image series it references, and then converts that
series into a Legacy Converted Enhanced CT/PET image itself, using highdicom
(legacy_convert.py). itkimage2segimage is then run twice on the same ITK
input - once against the classic series, once against the freshly created
multiframe image - and the two results are compared. So the multiframe inputs
are derived images in the run's working directory; they have no
persistent location.

What identifies the test data are the IDC segmentation series used as the
entry point (the tool follows ReferencedSeriesSequence to fetch the image
series automatically):

collection SEG SeriesInstanceUID
hcc_tace_seg 1.2.276.0.7230010.3.1.3.17436516.3978898.1714504806.328050
rider_lung_ct 1.3.6.1.4.1.14519.5.2.1.117319391981555801519305784741525384466
nsclc_radiomics 1.2.276.0.7230010.3.1.3.2323910823.25492.1597258592.177
c4kc_kits 1.2.276.0.7230010.3.1.3.0.74717.1588583589.972999
nlst 1.2.276.0.7230010.3.1.3.17436516.3220075.1720667837.249702
lung_pet_ct_dx 1.2.276.0.7230010.3.1.3.17436516.2375785.1720643480.989179
qin_breast 1.2.276.0.7230010.3.1.3.17436516.258432.1696966555.107327

(The qin_breast segmentation references a PET series, which is what exercised
the LegacyConvertedEnhancedPETImage path.)

Each case should be reproducible with

util/multiframe-validation/validate_idc_case.sh <work-dir> <SEG-SeriesInstanceUID>

which needs idc-index and highdicom; the README in that directory has the
details. I will reword the "run over seven IDC collections" sentence in the PR
description so it does not read as if IDC hosted multiframe data.

8000

michaelonken and others added 4 commits September 11, 2026 10:36
itkimage2segimage and itkimage2paramap so far assumed the source series
to consist of classic single-frame instances: the slice mapping read
Image Position (Patient) from the top-level dataset, and the created
references could only point at whole instances. Enhanced or Legacy
Converted Enhanced multiframe sources ended up with a garbage mapping
and unusable references.

SourceImageIndex now inventories multiframe datasets (recognized by
their Per-frame Functional Groups Sequence) per frame, reading the
positions from the Plane Position (Patient) FG via FGInterface, so the
geometric slice mapping works for any mix of single-frame and
multiframe sources. Old-style multiframe images without per-frame
functional groups are excluded from the mapping with a warning (they
can still be referenced as whole instances with the geometry check
disabled).

References created from the mapping now restrict Source Image Sequence
items of multiframe instances to the frames actually used via
Referenced Frame Number (1-based), omitting it when all frames of the
instance are referenced, as required for this type 1C attribute. Note
that DCMTK's ImageSOPInstanceReferenceMacro::setReferencedFrameNumber()
is unusable for this (putUint16 on the IS-VR element always returns
EC_IllegalCall), so the values are set via addReferencedFrameNumber().
The Common Instance Reference module stays instance-based and needs no
change. Both converters pick this up through the shared code.

Two deliberate fixes to the shared derivation image FG written when the
geometry check is disabled: it is now also created for a single source
dataset (previously only for more than one, leaving e.g. a single
multiframe source entirely unreferenced), and its Purpose of Reference
Code is now (DCM,121322,"Source image for image processing operation")
from CID 7202 instead of the copy-pasted derivation code
(DCM,113076,"Segmentation") from CID 7203.

Tests: the 24x38x3/23x38x3 phantoms already ship a multiframe (Legacy
Converted Enhanced CT) encoding of their source series (58d8fe7), so
the existing conversion/round-trip test pairs are duplicated for the
multiframe sources, plus dciodvfy runs on their outputs, a PM
conversion from the multiframe source, and MultiframeSourceReferencesTest,
which asserts that the multiframe-source SEG has the same content as
the classic-source SEG and checks the created references frame by
frame against the source plane positions.

Closes #150. The conversion direction of #217 (PM from multiframe
sources) is covered as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The multiframe phantom (mf.dcm) has exactly one frame per plane, which is
8000
 the
easiest case: every segmentation frame references a single source frame. Real
Enhanced images routinely use two structures that change how the references
have to be written, both of which went untested. Verified against the WG16
Enhanced MR sample set, then reduced to small generated fixtures:

- Several frames on the same plane (multi-echo, multi-phase, ...). Every
  segmentation frame must reference all source frames of its plane, so
  Referenced Frame Number becomes multi-valued; with interleaved acquisitions
  the numbers are not contiguous. mf-2frames-per-plane.dcm reproduces this
  with six frames on the three phantom planes ([1,4], [2,5], [3,6]).
- A shared instead of per-frame Plane Position (cine acquisitions). Only the
  slice on that plane gets references, and since all frames of the instance
  apply, Referenced Frame Number must be omitted - a reference without frame
  numbers denotes the whole instance. mf-shared-plane-position.dcm reproduces
  this with four frames on the first plane.

Since the new variant carries its own Series Instance UID, combining it with
mf.dcm also covers references spanning two series, i.e. the series grouping in
the Common Instance Reference module.

Adds MultiframeSourceVariantsTest for the three cases (it resolves every
Referenced Frame Number back to the plane position of the source frame instead
of hardcoding expected numbers), end-to-end CLI tests with dciodvfy on their
output, and a parametric map conversion from the multi-frame-per-plane source.
Each new check was confirmed to fail when the corresponding behavior is
mutated away.

The fixtures are generated from mf.dcm by util/make_multiframe_test_data.py
(deterministic UIDs, ~10-15 KB each) so patient, study and frame of reference
stay consistent with the phantom and regenerating them is reproducible. They
carry the same four dciodvfy findings as the mf.dcm they are derived from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The checked-in multiframe fixtures are small, deterministic and built by us
from one converted phantom. They cannot show that dcmqi reads Enhanced images
as other tools write them, nor how the conversion behaves on real geometry.

This tooling closes that gap for manual runs: for a segmentation from the
Imaging Data Commons it downloads the segmentation and the image series it
references, converts the segmentation back to ITK, converts the classic series
into a Legacy Converted Enhanced image using highdicom, runs itkimage2segimage
against both encodings of the same source, and compares the results.

The comparison asserts identical segmentation content and, as the actual
point, that the frame references of the multiframe run denote the same source
images as the instance references of the classic one. It resolves every
Referenced Frame Number back to the SOP Instance UID it came from via the
Conversion Source Attributes Sequence, so it compares what the references mean
rather than assuming a shared slice grid - segmentations do occur that sit on
a different grid than the series they reference, and then several source
frames legitimately map onto one segmentation frame.

Not wired into CTest: it needs network access, the IDC index and highdicom,
and downloads tens to hundreds of megabytes per case. The README documents
what it adds over the CI fixtures, how to pick a case, and two findings worth
knowing before chasing a failure - that many IDC segmentations were themselves
written by dcmqi, and that dciodvfy's complaint about a missing Clinical Trial
Coordinating Center Name is unrelated to multiframe sources.

Validated over seven IDC collections (CT and PET sources, 64 to 126 slices,
one to five segments), all of which pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dciodvfy tests for the three multiframe source variants derived both
the name of the test they depend on and the file they verify from one
loop variable, but the producing tests and their output files used
different suffixes: the tests are named after the full variant
(_2frames_per_plane, _shared_plane_position) while the files were
abbreviated (_2frames, _shared). Two of the three TEST_DEPENDS therefore
named tests that do not exist. CTest ignores an unknown DEPENDS silently,
so nothing failed - but under ctest -j the dciodvfy test could run before
the conversion that writes its input.

This stayed invisible because CI had no dciodvfy until #559/#561, which
install dicom3tools from PyPI on all platforms; the tests are created and
run there now.

Name the output files after the variants as well, so a single identifier
drives the test name, the file and the dependency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fedorov
fedorov force-pushed the enhanced_multiframe branch from aaba02a to d8105e1 Compare September 11, 2026 14:42
@fedorov fedorov left a comment •
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed the code against the pre-refactor originals, the DCMTK APIs it relies on (checked against the pinned 5708ba6), and the new tests and fixtures. This is careful, well-documented work and the DICOM reasoning holds up — in particular, omitting Referenced Frame Number when all frames apply is not merely allowed but required: PS3.5 Section 7.4.3 says a Type 1C element shall not be present when its condition is not met.

Now verified by CI. Before the rebase this PR targeted refactor_seg_converter, and the workflows only trigger on PRs against master, so nothing here had ever been built or tested. It is now green on all five platforms. Two things that were previously unverifiable are now confirmed:

  • dciodvfy landed in CI via #559/#561, so the multiframe conformance tests actually execute. All five pass — _24x38x3_multiframe_source, _23x38x3_multiframe_source, and the three variant tests — with no findings on any multiframe output. The fixtures inherit no conformance problems beyond the phantom's own.
  • 93/93 tests pass. (The PR description's 92 was counted before the two_series test existed as its own dciodvfy case.)

Two notes on what changed under the PR during the rebase onto master:

  • The conflict in SourceImageIndex.h was resolved in favour of master's doc block from #560, which rewrote that same comment to describe the cross-study routing. That drops this branch's deletion of the "Factored out of the SEG and PM converters (#192)" note — worth keeping, so no action needed, but commit 2's diff is 3 lines smaller than what you pushed.
  • apps/seg/Testing/CMakeLists.txt needed a fix (separate commit): the dciodvfy loop derived both the dependency's test name and the file to verify from one variable, but the producers are named _2frames_per_plane / _shared_plane_position while their outputs were abbreviated _2frames / _shared. Two of the three TEST_DEPENDS therefore named tests that do not exist. CTest ignores an unknown DEPENDS silently, so it never failed — but under ctest -j the verification could run before the conversion that writes its input. It was invisible until now precisely because CI had no dciodvfy.

Comments below are all non-blocking; the two in SourceImageIndex.cpp are the only ones touching shipped code, and both are edge cases rather than defects on the paths the tests cover.

Two things outside the diff:

  • SonarCloud is the only red check, on a path-traversal alert in util/multiframe-validation/legacy_convert.py:48. It is a manually-run developer tool that takes a directory argument, so the finding is not meaningful, but it does block the quality gate — worth either validating the constructed path or marking it won't-fix in SonarCloud so the gate can go green.
  • ParaMapConverter's slice-mapping log (changed in #557, not here, so I cannot comment inline) prints "Mapping from the ITK image slices to the DICOM instances in the input list" followed by what are now frame ids rather than dataset indices. Harmless, but the label is misleading once the source is multiframe.

🤖 Generated with Claude Code

Comment thread libsrc/SourceImageIndex.cpp Outdated
for(size_t j=0;j<frameNumbers.size();j++)
if(frameNumbers[j] > 65535)
representable = false;
if(!frameNumbers.empty() && frameNumbers[0] > 0 && frameNumbers.size() < info.numberOfFrames
@fedorov fedorov Sep 11, 2026 •
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two edge cases in this condition, both benign on conformant input.

Two sources of truth for the frame count. The inventory is built from FGInterface::getNumberOfFrames(), which returns the number of Per-frame Functional Groups items, while this "are all frames referenced?" test compares against info.numberOfFrames, which comes from (0028,0008) and is set to 0 when the attribute is absent or unparseable. When they disagree the comparison silently goes the wrong way: with info.numberOfFrames == 0, frameNumbers.size() < 0 is false, so a partial reference is written as a whole-instance reference — i.e. it claims frames that were not used. NumberOfFrames is Type 1 for multiframe images so this needs malformed input, but since the frames were inventoried from the per-frame sequence, comparing against the number of inventoried frames for this dataset would remove the discrepancy entirely.

The representable fallback has the same shape. When a frame number exceeds 65535 the code also falls through to a whole-instance reference, which over-references rather than failing. Unreachable in practice, and the comment is honest about why the guard exists — but given the choice between "claim all frames" and "refuse", the latter seems the safer default for a reference that a consumer wi 8000 ll act on.

Worth noting the workaround itself is correct and necessary: at the pinned DCMTK (5708ba6), setReferencedFrameNumber() goes through setUint16ValuesOnElement() on a VR IS element; only addReferencedFrameNumber() builds the IS string properly. I confirmed the fix is in current DCMTK master, so the TODO will become actionable on the next bump.


🤖 Generated with Claude Code

Comment thread libsrc/SourceImageIndex.cpp Outdated

void SourceImageIndex::addMultiframeSourceFrames(size_t datasetIndex, DcmItem& dataset, const InstanceInfo& info) {
FGInterface fgInterface;
if(fgInterface.read(dataset).bad()){
@fedorov fedorov Sep 11, 2026 •
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A robustness note on the failure mode here: FGInterface::read() returns FG_EC_NoSharedFG when the Shared Functional Groups Sequence is missing or has zero items, and readSingleFG() fails the whole read if any single shared group fails to parse. In either case every frame of that source is dropped from the geometric mapping and the resulting object carries no references to it at all.

That is a fairly wide net for something the converter does not actually need: the mapping only wants Plane Position (Patient), which for the per-frame case is already sitting in the per-frame items. A fallback that reads Plane Position straight out of PerFrameFunctionalGroupsSequence when FGInterface refuses the dataset would keep such files usable — and files with an empty shared sequence do turn up in the wild.

The warning text is good, so at minimum the user can tell what happened.


🤖 Generated with Claude Code

done
command -v "$IDC" >/dev/null 2>&1 || { echo "ERROR: $IDC not found (pip install idc-index)"; exit 1; }

rm -rf "$work"
@fedorov fedorov Sep 11, 2026 • 8BD0
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

$work comes straight from $1 with no validation, so validate_idc_case.sh / <uid> or validate_idc_case.sh ~ <uid> is unrecoverable. set -u guarantees it is set, and the $# -ne 2 guard guarantees it is non-empty, but neither stops it from being something you very much do not want to rm -rf.

Cheapest fix is to refuse anything that is not a fresh or clearly-owned directory — e.g. require it not to exist yet, or bail if it exists and is non-empty and lacks a marker file this script wrote.

Related, in the same script: it runs under set -u but without set -e -o pipefail, and the two itkimage2segimage invocations are piped into grep, so their exit status is discarded. A failed conversion falls straight through to the comparison, which then reports confusing mismatches against stale or missing files rather than the actual error.


🤖 Generated with Claude Code

wanted = set(expected)
resolved = set()
for frames in actual.values():
if frames is None: # whole instance: cannot be narrowed down
@fedorov fedorov Sep 11, 2026 •
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This branch makes check 4 vacuous in exactly the case most worth checking. When ReferencedFrameNumber is absent, resolved is assigned wanted, so the subsequent resolved != wanted can never fire — the check passes by construction.

That matters because a regression that dropped frame numbers entirely would produce whole-instance references everywhere, and this check would still pass. Check 3 does not catch it either: it only asserts that the referenced instance is the multiframe one, not that the reference is frame-level.

Since the classic-vs-enhanced comparison knows how many source instances the classic run referenced for each frame, asserting that a whole-instance reference only appears where all frames of the instance legitimately apply would close the gap — the same assertion MultiframeSourceVariantsTest already makes in-tree.


🤖 Generated with Claude Code

Comment thread util/make_multiframe_test_data.py Outdated

for name, ds in variants:
path = os.path.join(out_dir, name)
ds.save_as(path, write_like_original=False) 10000
@fedorov fedorov Sep 11, 2026 •
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

write_like_original was removed in pydicom 3.0, so this raises TypeError on any current install. The replacement is enforce_file_format=True, which legacy_convert.py in this same PR already uses — so the two scripts disagree about which pydicom generation they target.

Since the whole point of this script is that the committed fixtures can be regenerated reproducibly, it would be a shame for it to fail on the pydicom most people now have.


🤖 Generated with Claude Code

michaelonken and others added 6 commits September 13, 2026 22:08
 Review follow-up on the multiframe references: whether all frames of a
 source are referenced (allowing Referenced Frame Number to be omitted)
 was decided against NumberOfFrames (0028,0008), while the inventory and
 the frame numbers come from the per-frame items - two sources of truth.

 - an absent or unparseable NumberOfFrames was recorded as 0, turning the
 comparison silently false: a partial reference was written as a
 whole-instance claim (needs malformed input; the attribute is type 1)
 - InstanceInfo now records the inventoried frame count per dataset and
 the completeness check uses it; (0028,0008) remains only to recognize
 old-style multiframe images without functional groups
 - new test case strips (0028,0008) from the two-frames-per-plane fixture
 and asserts the frame numbers are still written; fails with the old
 comparison
 - well-formed outputs are byte-identical (all multiframe variants + PM)
Review follow-up, same shape as the NumberOfFrames case: a frame
number above 65535 made the code skip Referenced Frame Number and
write a whole-instance reference, over-referencing frames that were
not used instead of failing.

- both DCMTK methods for the attribute take Uint16, while VR IS
  permits more; the guard stays, but now refuses
- returns FG_EC_InvalidData with the offending frame and instance,
  so the conversion aborts without writing a file
- new test case pushes the real frames past 65535 with in-memory
  filler items and asserts the refusal; it fails with the fallback
- well-formed outputs unchanged (multiframe variants + PM)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FGInterface::read() rejects a dataset as a whole - it returns
FG_EC_NoSharedFG when the Shared Functional Groups Sequence is
missing or empty, and fails if any single shared group does not
parse. Every frame of such a source was dropped from the mapping and
the result referenced it not at all; files like this do occur.

- the mapping only needs Plane Position (Patient), so read it from
  the per-frame items (shared item as fallback) when FGInterface
  refuses the dataset, and say so in the warning
- verified: with a missing or empty shared sequence the phantom went
  from 0 of 3 slices mapped and no references to 3 of 3 with
  frame-level references
- new test case covers both variants in memory; it fails without the
  fallback
- well-formed outputs unchanged (multiframe variants + PM)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two robustness defects in validate_idc_case.sh found in review.

- $work went unvalidated into rm -rf, so passing / or ~ was
  unrecoverable; now refuses anything that exists, is non-empty and
  lacks the marker file the script writes, plus / and empty
- the script ran without pipefail and piped both conversions into
  grep, discarding their exit status: a failed conversion fell
  through to the comparison, which then reported mismatches against
  missing files instead of the real error. Conversions now run
  through a helper that aborts and shows the log tail
- verified: /, ~ and "" are refused, a fresh or previously marked
  directory is accepted, and a failing conversion aborts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In compare_references.py, a source image reference without frame
numbers set resolved = wanted, so the following inequality could
never fire: the check passed by construction in exactly the case
worth checking. A regression dropping frame numbers would produce
whole-instance references everywhere and still pass, and check 3
only asserts which instance is referenced, not that the reference
is frame-level.

- a reference without frame numbers now resolves to all frames of
  the instance, which is what it actually denotes
- added a second assertion that the references are frame-level
  throughout, naming the count when they are not
- verified against the phantom: the old check passed a seg with all
  frame numbers stripped, the new one fails it and exits non-zero

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
make_multiframe_test_data.py passed write_like_original=False while
legacy_convert.py in the same PR already used enforce_file_format,
so the two disagreed on which pydicom generation they target.

- the keyword is chosen by pydicom major version now, so the script
  runs clean on both series; on 3.x it no longer trips the
  deprecation warning ("removed in v4.0"), which is where this would
  have become a hard failure
- checked: the generated fixtures are byte-identical to the
  committed ones under pydicom 2.4.2 and 3.0.2 alike, so
  regeneration stays reproducible across the split
- README now states that highdicom requires pydicom 3 while the
  generator does not

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michaelonken
Copy link
Copy Markdown
Member Author

Hi Andrey,

Thanks for looking through the PR. All six findings are addressed now. Every fix was
confirmed against the phantom before and after, each comes with a test that
fails without it, and well-formed outputs stayed byte-identical throughout.

  • Two sources of truth for the frame count (0acb07c) — completeness was
    decided against NumberOfFrames (0028,0008) while the frame numbers came
    from the per-frame items, so an absent attribute turned a partial reference
    into a whole-instance claim. InstanceInfo now records the inventoried
    frame count and the check uses that; (0028,0008) is left only for
    recognizing old-style multiframe images without functional groups.

  • Frame numbers above 65535 (1a52b0f); the guard now refuses with
    FG_EC_InvalidData, naming frame and instance, instead of falling back to
    a whole-instance reference. Same trade in the same direction as above:
    over-referencing asserts something untrue, refusing only stops a conversion
    that could not have been correct.

  • FGInterface rejecting the whole dataset (ec900a0); confirmed: with the
    Shared Functional Groups Sequence removed, and again with it empty, the
    conversion mapped 0 of 3 slices and referenced the source not at all. Plane
    Position (Patient) is now read straight from the per-frame items (shared
    item as fallback) whenever FGInterface refuses the dataset, which takes both
    variants to 3 of 3 with frame-level references.

  • Unvalidated work directory and swallowed exit codes (9da20f4): the
    directory is only accepted if it does not exist, is empty, or carries a
    marker file the script wrote; /, ~ and "" are refused. pipefail is
    on and both conversions run through a helper that checks their status and
    prints the log tail, so a failure aborts instead of reaching the comparison.

  • Check 4 passing by construction (e14f79e): a reference without frame
    numbers now resolves to all frames of the instance, which is what it
    denotes, plus an explicit assertion that references are frame-level
    throughout. Verified by stripping every Referenced Frame Number from a
    converted segmentation: the old check reported ALL CHECKS PASSED, the new
    one fails and exits non-zero.

  • pydicom generation mismatch (33cbb7f): the generator picks the
    save_as keyword by pydicom major version and runs clean on both series,
    including with warnings as errors. One correction on the specifics:
    write_like_original is only deprecated 3.0.2, it warns that it will be removed
    in v4.0 — so the script worked but was one release from breaking. However,
    the code has been updated to work in all versions without deprecation warning
    now.

Test count is now 93; MultiframeSourceVariantsTest covers the malformed
NumberOfFrames, the frame-number limit and both unparsable-shared-groups
variants, all built in memory so no new fixtures were needed.

The two conversions now go through the convert() helper, which forwards
its arguments with "$@", so an unquoted $itk2dcm is word-split before it
gets there and a DCMQI_BIN containing a space breaks the invocation
rather than just the command word. Quote it, which also clears the two
SC2086 findings the helper introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@fedorov
fedorov merged commit 5571494 into master Sep 14, 2026
6 of 9 checks passed
@fedorov
fedorov deleted the enhanced_multiframe branch September 14, 2026 16:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support conversion of segmentations of multi-frame input series

3 participants

0