Skip to content

fix(core): report single-location multi-frame series as non-reconstructable#6138

Open
TFRadicalImaging wants to merge 2 commits into
OHIF:masterfrom
TFRadicalImaging:fix/single-location-multiframe-reconstructable
Open

fix(core): report single-location multi-frame series as non-reconstructable#6138
TFRadicalImaging wants to merge 2 commits into
OHIF:masterfrom
TFRadicalImaging:fix/single-location-multiframe-reconstructable

Conversation

@TFRadicalImaging

@TFRadicalImaging TFRadicalImaging commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Context

A multi-frame cine / multi-time series (e.g. a cardiac cine) is many frames acquired at a single spatial location — every frame shares one ImagePositionPatient, so the series has no through-plane extent (span 0mm).

processMultiframe never checks the spread of the frame positions — that is the intent of the existing // TODO - check spacing consistency — so such a series is reported as isReconstructable: true. Any volume-based layout (MPR / 3D) then builds a degenerate volume: on a real 30-frame cardiac cine we measured dims [208, 174, 30] with spacing [1.92, 1.92, 0] — a zero Z spacing. Every MPR plane renders black, and because the layout switch replaces the hanging protocol there is no way back short of a page reload.

The same hazard exists on the single-frame path: many single-frame instances acquired at one location fall through to the spacing calculation and produce the same degenerate volume.

Changes & Results

platform/core/src/utils/isDisplaySetReconstructable.js:

  • Add zeroExtentTolerance (0.01mm). Real slice spacing is on the order of millimetres, so frames whose positions all collapse within this distance occupy a single location.
  • Add hasSingleSpatialLocation(positions) — true when two or more positions are known and all collapse to one location. It fails open: with fewer than two valid positions it returns false, so a genuine volume is never wrongly blocked when the geometry can't be determined confidently.
  • Add getPerFramePositions(multiFrameInstance) — reads each frame's ImagePositionPatient from PerFrameFunctionalGroupsSequence, falling back to SharedFunctionalGroupsSequence broadcast to NumberOfFrames. This fallback matters: a single-location cine has one constant position, which the more common DICOM encoding stores in the shared group rather than repeating it per frame. Without it the per-frame read returns [] and the check never fires on the exact degenerate case it targets.
  • Guard both processMultiframe (per-frame positions) and processSingleframe (instance positions).
  • Export the two helpers.

Before: single-location cine reports isReconstructable: true → MPR/3D builds a zero-Z-spacing volume → all viewports black, unrecoverable without a reload.
After: it reports isReconstructable: false → volume layouts correctly treat it as non-volumetric (they are gated on isReconstructable), so the series stays in its stack/cine view and nothing goes black. A genuine volume series is unaffected and still reconstructs.

Testing

New unit tests: platform/core/src/utils/isDisplaySetReconstructable.test.js (13 cases, none existed before) — helper-level (collapse within tolerance, real extent, fewer than two positions, malformed positions, per-frame read, shared-group fallback, absent sequences) and end-to-end through isDisplaySetReconstructable for both multi-frame and single-frame series.

cd platform/core && jest src/utils/isDisplaySetReconstructable.test.js

Manually verified against real acquisition data in a downstream deployment, with two series from the same study:

  • 4 CV Cine — 30 frames, all at one ImagePositionPatient, span 0mm → now isReconstructable: false; selecting MPR is a safe no-op instead of turning every viewport black.
  • AXIAL TRUFI CHEST — 20 frames, span 190mm → still isReconstructable: true; MPR builds a real three-plane volume that renders normally.

Checklist

PR

  • My Pull Request title is descriptive, accurate and follows the
    semantic-release format and guidelines.

Code

  • My code has been well-documented (function documentation, inline comments,
    etc.)

Public Documentation Updates

  • The documentation page has been updated as necessary for any public API
    additions or removals.

Tested Environment

  • OS: macOS 15 (Darwin 25.5.0)
  • Node version: 22.17.1
  • Browser: Chrome 138

This fix is contributed by the University of Calgary, who are donating it back to OHIF.

It was originally implemented as a layout guard in a downstream fork; @wayfarer3130 pointed out during review that the correct fix belongs in core — reporting the series as non-reconstructable, so that every surface derives the right behaviour from the data rather than from a UI-level guard — which is what this PR does.

Summary by CodeRabbit

  • Bug Fixes
    • Improved reconstruction validation for multi-frame and single-frame imaging datasets.
    • Prevented reconstruction when frames/images collapse to a single spatial location with no measurable through-plane extent.
    • Improved handling of image position data by prioritizing per-frame positions with a shared fallback when needed.
  • Tests
    • Added Jest coverage for single-location detection, per-frame position extraction, and reconstruction outcomes across per-frame and shared metadata scenarios.

…ctable

A cine / multi-time series is many frames acquired at a single spatial
location: every frame shares one ImagePositionPatient, so the series has
no through-plane extent. processMultiframe never checked the spread of
the frame positions, so such a series was reported as reconstructable
and any volume layout built a degenerate volume with zero slice spacing,
which renders black in every plane.

Guard both the multi-frame and single-frame paths on a single spatial
location. Frame positions are read from the per-frame functional groups,
falling back to the shared functional group broadcast to the frame count
(a constant position is commonly stored there rather than repeated per
frame). The check fails open when fewer than two positions are known, so
a genuine volume is never wrongly blocked.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@netlify

netlify Bot commented Jul 13, 2026

Copy link
Copy Markdown

Deploy Preview for ohif-dev ready!

Name Link
🔨 Latest commit 3e5cdfb
🔍 Latest deploy log https://app.netlify.com/projects/ohif-dev/deploys/6a550fae230b800008628c95
😎 Deploy Preview https://deploy-preview-6138--ohif-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@TFRadicalImaging

Copy link
Copy Markdown
Contributor Author

@wayfarer3130 this is the core fix you asked for during the downstream review — reporting a single-location multi-frame series as non-reconstructable, rather than guarding the layout switch in the fork. Could you take a look when you get a chance? cc @jbocce

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b2212bf6-1918-436e-833d-397254b25392

📥 Commits

Reviewing files that changed from the base of the PR and between fcabc11 and 3e5cdfb.

📒 Files selected for processing (2)
  • platform/core/src/utils/isDisplaySetReconstructable.js
  • platform/core/src/utils/isDisplaySetReconstructable.test.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • platform/core/src/utils/isDisplaySetReconstructable.test.js
  • platform/core/src/utils/isDisplaySetReconstructable.js

📝 Walkthrough

Walkthrough

isDisplaySetReconstructable now rejects multiframe and single-frame datasets with no through-plane extent, derives positions from per-frame or shared functional groups, and adds tests for these behaviors.

Changes

Display-set reconstructability

Layer / File(s) Summary
Position detection and reconstruction guards
platform/core/src/utils/isDisplaySetReconstructable.js
Adds position validation, zero-extent detection, shared functional-group fallback, exported helpers, and early guards for multiframe and single-frame reconstruction.
Reconstructability coverage
platform/core/src/utils/isDisplaySetReconstructable.test.js
Tests tolerance handling, malformed and missing positions, functional-group fallback, and reconstruction outcomes across multiframe and single-frame datasets.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, semantic-release formatted, and accurately summarizes the main fix for single-location series.
Description check ✅ Passed The description follows the template and includes context, changes, testing, and a completed checklist with concrete results.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
platform/core/src/utils/isDisplaySetReconstructable.js (1)

308-328: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

getPerFramePositions trusts per-frame data on a single valid entry, bypassing the shared-group fallback.

perFramePositions.some(isValidImagePosition) returns the per-frame array whenever just one frame has a valid position, even if the rest are malformed/missing. In that case hasSingleSpatialLocation sees fewer than 2 valid positions and fails open (treats as reconstructable), even though SharedFunctionalGroupsSequence might carry the correct constant position that would reveal a single-location series. Requiring at least two valid per-frame positions before preferring them over the shared fallback would align this threshold with hasSingleSpatialLocation's own fail-open bar.

♻️ Proposed tightening
-    if (perFramePositions.some(isValidImagePosition)) {
+    if (perFramePositions.filter(isValidImagePosition).length >= 2) {
       return perFramePositions;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform/core/src/utils/isDisplaySetReconstructable.js` around lines 308 -
328, Update getPerFramePositions so it only returns the per-frame positions when
at least two entries satisfy isValidImagePosition; otherwise continue to the
shared-position fallback. Preserve the existing shared fallback and frame-count
behavior for insufficient or invalid per-frame data.
platform/core/src/utils/isDisplaySetReconstructable.test.js (1)

27-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Solid coverage; consider adding the exact 2-frame/2-instance boundary case.

The suite covers the helpers and both processing paths well. The PR's core motivating bug was that the old spacing check only runs for instances.length > 2, so a 2-frame single-location series previously slipped through undetected. Adding an explicit 2-instance test (mirroring the existing 3-instance case) would directly guard that regression.

test('not reconstructable when exactly two instances share one location', () => {
  const instances = [singleFrameInstance([0, 0, 0]), singleFrameInstance([0, 0, 0])];
  expect(isDisplaySetReconstructable(instances).value).toBe(false);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform/core/src/utils/isDisplaySetReconstructable.test.js` around lines 27
- 150, Add an explicit boundary test in the “single-frame instances” suite for
exactly two instances with the same spatial position, and assert that
isDisplaySetReconstructable(...).value is false. Mirror the existing
three-instance constant-location test to guard the two-instance regression.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@platform/core/src/utils/isDisplaySetReconstructable.js`:
- Around line 308-328: Update getPerFramePositions so it only returns the
per-frame positions when at least two entries satisfy isValidImagePosition;
otherwise continue to the shared-position fallback. Preserve the existing shared
fallback and frame-count behavior for insufficient or invalid per-frame data.

In `@platform/core/src/utils/isDisplaySetReconstructable.test.js`:
- Around line 27-150: Add an explicit boundary test in the “single-frame
instances” suite for exactly two instances with the same spatial position, and
assert that isDisplaySetReconstructable(...).value is false. Mirror the existing
three-instance constant-location test to guard the two-instance regression.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ea5dc908-8ca7-4981-a88e-3d3b131f0f8e

📥 Commits

Reviewing files that changed from the base of the PR and between 110b293 and fcabc11.

📒 Files selected for processing (2)
  • platform/core/src/utils/isDisplaySetReconstructable.js
  • platform/core/src/utils/isDisplaySetReconstructable.test.js

…tent

getPerFramePositions returned the per-frame positions as soon as a single
frame carried a valid one, shadowing the shared functional group. With
fewer than two valid positions hasSingleSpatialLocation cannot decide and
fails open, so a series whose constant position lives in the shared group
was still reported reconstructable. Require the two positions the check
needs before preferring the per-frame groups.

Also cover the two-instance boundary: the spacing check only runs for
more than two instances, so a co-located pair relies entirely on the
single-location guard.
@TFRadicalImaging

Copy link
Copy Markdown
Contributor Author

getPerFramePositions trusts per-frame data on a single valid entry, bypassing the shared-group fallback.

Applied. The per-frame groups are now preferred only once they carry two valid positions, which is the same bar hasSingleSpatialLocation needs to decide anything — below it, a lone per-frame position was shadowing a shared position that would have shown the frames are co-located. Added a test for that case.

consider adding the exact 2-frame/2-instance boundary case

Added. The spacing check only runs for more than two instances, so a co-located pair rests entirely on the single-location guard; the test fails without it (returns true).

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.

1 participant