Skip to content

fix(seg): prevent segmentations from spreading to all viewports before hydration confirmation in 3D four-up#5967

Merged
jbocce merged 12 commits into
OHIF:masterfrom
GhadeerAlbattarni:fix/5953-mpr-viewports
Apr 27, 2026
Merged

fix(seg): prevent segmentations from spreading to all viewports before hydration confirmation in 3D four-up#5967
jbocce merged 12 commits into
OHIF:masterfrom
GhadeerAlbattarni:fix/5953-mpr-viewports

Conversation

@GhadeerAlbattarni
Copy link
Copy Markdown
Collaborator

@GhadeerAlbattarni GhadeerAlbattarni commented Apr 18, 2026

Acknowledgement

This PR is based on the implementation from PR #5907 by @wayfarer3130, so thanks to all his amazing work!

Also, thanks to @jbocce for all the efforts he made to improve the tests!

Context

Fixes #5953

When a segmentation (SEG) series was loaded into one of the MPR viewports in the 3D four-up layout, all viewports rendered the segmentation before user confirm hydration.

This PR updates the behavior so that:

  • SEG loads only in the target viewport initially
  • Other viewports remain unchanged
  • Segmentations render in all viewports only after user confirmation

Changes & Results

  • HangingProtocolService.ts: Return only the active viewport for overlay display sets (SEG/RTSTRUCT) — prevents all viewports being updated with the SEG before user confirmation
  • CornerstoneViewportService.ts: Skip setVolumes() when the same base volume is already loaded; add render() after setPresentations() so segmentation is visible immediately; guard jumpToSlice so it doesn't reset MPR position when volume was unchanged
  • useSegmentationPresentationStore.ts: Filter overlay UIDs from the segmentation presentation ID to match the key used by updateStoredSegmentationPresentation
  • commandsModule.ts: Guard loadSegmentationDisplaySetsForViewport against an empty updatedViewports list and
    set viewports needsRender before hydration
  • hydrationUtils.ts: Add mergeVolumeSharingViewports as a safety-net for non-HP layouts: finds all grid viewports that share the same referenced volume and includes them in the post-hydration update
  • hydrationUtils.test.ts: Unit tests for the new mergeVolumeSharingViewports behaviour
  • waitForViewportsRendered.ts: New test utility with waitForViewportsRendered and waitForAnyViewportNeedsRender
  • checkForScreenshot.ts: Clean up intermediate screenshot artifacts (actual/diff/expected) between retry attempts; increase default retry delay from 500ms to 1250ms
  • Update affected tests accordingly
Screenshot 2026-04-17 at 9 49 40 PM

Testing

  1. Open a study that has segmentation in viewer mode (e.g., StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.1706.8374.643249677828306008300337414785)
  2. Click on 3D four up
  3. Load segmentation display set in one viewport
  4. Verify no segmentations show/load into other viewports
  5. Click yes to confirm hydration
  6. Verify segmentations load to the other viewports

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 10.15.4
  • Node version: v22.13.0
  • Browser: Chrome 83.0.4103.116

Greptile Summary

This PR prevents SEG/RTSTRUCT display sets from spreading to all viewports before hydration confirmation in MPR and 3D four-up layouts. The fix spans the HP service (early return for overlays), CornerstoneViewportService (skip setVolumes when volume unchanged, second render() after presentations), the segmentation presentation store (filter overlay UIDs from key), and a new mergeVolumeSharingViewports utility that ensures all volume-sharing viewports are updated post-hydration. Tests replace fixed page.waitForTimeout calls with event-driven render-cycle helpers.

Confidence Score: 4/5

Safe to merge; one P2 test-reliability concern around waitForViewportRenderCycle used for layout changes.

All production-code changes are well-reasoned and covered by existing + new unit/E2E tests. The only finding is a P2 in the test utility usage that could cause intermittent CI failures on the MPR layout-change step but does not affect production behaviour.

tests/SEGHydrationFromMPR.spec.ts (waitForViewportRenderCycle on layout change), extensions/cornerstone/src/utils/hydrationUtils.ts (pre-existing HP-entry overwrite risk in mergeVolumeSharingViewports).

Important Files Changed

Filename Overview
platform/core/src/services/HangingProtocolService/HangingProtocolService.ts Early-returns for overlay display sets (SEG/RTSTRUCT) before protocol matching, preventing pre-hydration spread to all viewports — core fix for the reported issue.
extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts Adds skip logic for setVolumes() when base volume is already loaded, adds a second render() after setPresentations(), and guards jumpToSlice when volume was unchanged.
extensions/cornerstone/src/utils/hydrationUtils.ts Adds mergeVolumeSharingViewports to spread post-hydration updates to all grid viewports sharing the same referenced volume; the grid-scan add() overwrites HP-computed UID lists (flagged in a prior review thread).
extensions/cornerstone/src/commandsModule.ts Guards loadSegmentationDisplaySetsForViewport against empty updatedViewports; calls setNeedsRender synchronously before setDisplaySetsForViewports to enable reliable Playwright polling.
extensions/cornerstone/src/stores/useSegmentationPresentationStore.ts Filters overlay UIDs from the segmentation presentation ID key so it matches the key written by updateStoredSegmentationPresentation, fixing hydrated segmentations silently not applying.
tests/utils/waitForViewportsRendered.ts New test utility with waitForViewportsRendered and waitForViewportRenderCycle; the doc comment explicitly warns that layout changes don't trigger needsRender, but waitForViewportRenderCycle is used in that scenario in SEGHydrationFromMPR.
tests/SEGHydrationFromMPR.spec.ts Replaces fixed timeouts with render-cycle helpers; uses waitForViewportRenderCycle for the post-layout-change screenshot, which may be incompatible with layout changes that don't synchronously set needsRender.
tests/utils/checkForScreenshot.ts Adds between-retry cleanup of Playwright screenshot artifacts and raises default retry delay from 500ms to 1250ms for more stable comparisons.
tests/SEGHydrationFrom3DFourUp.spec.ts Replaces fixed timeouts with render-cycle utilities; sets renderedTimeout to 180 000 ms for the 4-viewport 3D layout, which is appropriate given render complexity.
extensions/cornerstone/src/utils/hydrationUtils.test.ts Adds a well-structured unit test for mergeVolumeSharingViewports covering the 3D four-up scenario.
platform/core/src/types/HangingProtocol.ts Adds the ViewportUpdate interface needed to type the hydrationUtils merge function; viewportId is correctly optional to match existing HP service return shapes.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: tests/SEGHydrationFromMPR.spec.ts
Line: 59-63

Comment:
**`waitForViewportRenderCycle` may time out on layout change**

`waitForViewportRenderCycle` first calls `waitForAnyViewportNeedsRender` (5 s default timeout) before it will proceed to wait for 'rendered'. The JSDoc on `waitForViewportsRendered` explicitly warns: _"Examples such as changing the hanging protocol currently don't set such a state and thus can't be rendered without a delay."_ A layout switch to `axialPrimary` recreates viewports through the HP path, so newly-created viewports may never be observed in `needsRender` within that window, causing a 5 s timeout failure in CI.

Consider using `waitForViewportsRendered` directly here, or set a generous `needsRenderTimeout`:

```suggestion
  const viewportRenderAfterLayoutChange = waitForViewportRenderCycle(page, {
    needsRenderTimeout: 15000,
  });
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (6): Last reviewed commit: "apply PR feedback 2" | Re-trigger Greptile

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 18, 2026

Deploy Preview for ohif-dev ready!

Name Link
🔨 Latest commit d82a9d3
🔍 Latest deploy log https://app.netlify.com/projects/ohif-dev/deploys/69ef764b32121900084a156c
😎 Deploy Preview https://deploy-preview-5967--ohif-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

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

Comment on lines +1161 to +1165
const singleBaseViewport = nextBaseVolumeIds.length === 1;

if (
singleBaseViewport &&
nextBaseVolumeIds.length &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Redundant condition after singleBaseViewport declaration

singleBaseViewport is nextBaseVolumeIds.length === 1, so the subsequent nextBaseVolumeIds.length && check is always true when singleBaseViewport is true. The redundant guard can be dropped to clarify intent.

Suggested change
const singleBaseViewport = nextBaseVolumeIds.length === 1;
if (
singleBaseViewport &&
nextBaseVolumeIds.length &&
if (
singleBaseViewport &&
existingVolumeIds.length >= nextBaseVolumeIds.length &&
volumeIdPrefixesMatch(existingVolumeIds, nextBaseVolumeIds.length, nextBaseVolumeIds) &&
viewportMatchesDesiredVolumePresentation(viewport, viewportInfo)
) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts
Line: 1161-1165

Comment:
**Redundant condition after `singleBaseViewport` declaration**

`singleBaseViewport` is `nextBaseVolumeIds.length === 1`, so the subsequent `nextBaseVolumeIds.length &&` check is always `true` when `singleBaseViewport` is `true`. The redundant guard can be dropped to clarify intent.

```suggestion
      if (
        singleBaseViewport &&
        existingVolumeIds.length >= nextBaseVolumeIds.length &&
        volumeIdPrefixesMatch(existingVolumeIds, nextBaseVolumeIds.length, nextBaseVolumeIds) &&
        viewportMatchesDesiredVolumePresentation(viewport, viewportInfo)
      ) {
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@wayfarer3130 any comment on this? nextBaseVolumeIds.length is only checked in this condition so it is a bit odd IMHO.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@jbocce you're right about this

Comment thread extensions/cornerstone/src/utils/hydrationUtils.ts
Comment thread tests/utils/waitForViewportsRendered.ts
@jbocce jbocce requested review from jbocce and wayfarer3130 April 20, 2026 12:14
const MIN_STACK_VIEWPORTS_TO_ENQUEUE_RESIZE = 12;
const MIN_VOLUME_VIEWPORTS_TO_ENQUEUE_RESIZE = 6;

function getOrderedVolumeActorReferencedIds(viewport: Types.IVolumeViewport): string[] {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The name of this function implies ordering yet I see no ordering. Thoughts @wayfarer3130?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@jbocce you have a point here

.filter(Boolean) as string[];
}

function volumeIdPrefixesMatch(a: string[], bPrefixLen: number, b: string[]): boolean {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's give better names to a, bPrefixLen and b. Thanks.

}

/**
* Only treat base volumes as identical (skip setVolumes) when the enabled viewport
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I suspect this comment is better suited where this function is called???

*/
function viewportMatchesDesiredVolumePresentation(
viewport: Types.IViewport,
viewportInfo: ViewportInfo
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe rename this desiredViewportInfo?

@jbocce
Copy link
Copy Markdown
Collaborator

jbocce commented Apr 20, 2026

Please have a look at the failing Playwright tests. Thanks.

};

if (Array.isArray(hangingProtocolUpdates)) {
for (const u of hangingProtocolUpdates) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

rename u to hpUpdate?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is perhaps one of the greatest additions for our automated tests. Thank you for this.

I was thinking that perhaps a third function should also be added that combines the two we have here. Call it say waitForViewportRenderCycle. The code might be something like the following. In general this new utility would be the most used and the other two just in case someone wants to do something more specific for whatever reason. Thoughts?

type WaitForRenderCycleToCompleteOptions = {
  /**
   * Timeout for waiting until at least one viewport reaches `needsRender`.
   */
  needsRenderTimeout?: number;
  /**
   * Timeout for waiting until all viewports are `rendered`
   * (and optionally volume-loaded).
   */
  renderedTimeout?: number;
  /**
   * If true (default), also waits for any volume actors referenced by the
   * viewports to report loaded during the rendered phase.
   */
  waitVolumeLoad?: boolean;
};

/**
 * Waits for a full render cycle:
 * 1) any viewport requests render (`needsRender`)
 * 2) all viewports finish rendering (`rendered`)
 */
const waitForViewportRenderCycle = async (
  page: Page,
  options: WaitForRenderCycleToCompleteOptions = {}
) => {
  const {
    needsRenderTimeout = 5000,
    renderedTimeout = 15000,
    waitVolumeLoad = true,
  } = options;
  await waitForAnyViewportNeedsRender(page, { timeout: needsRenderTimeout });
  await waitForViewportsRendered(page, { timeout: renderedTimeout, waitVolumeLoad });
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@jbocce This is a smart idea, I like it. I test it and it works great.

Copy link
Copy Markdown
Collaborator

@jbocce jbocce left a comment

Choose a reason for hiding this comment

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

Thanks for the changes. Please see my comments.

…ume viewports

configureViewportForLayerAddition was defaulting to 'stack' via the
isSameDisplaySet check when viewportOptions arrived without a viewportType
(e.g. from the HP overlay early-return path). This caused ORTHOGRAPHIC
MPR viewports to be re-initialised as stack viewports — stack viewports
ignore the orientation field entirely and render images in their native
DICOM acquisition plane (axial).

Fix: check the live Cornerstone viewport type first (same pattern as
the existing orientation recovery on line 65). If the viewport is
already orthographic, keep viewportType as 'volume' and skip the
isSameDisplaySet check.
…set layer

configureViewportForLayerRemoval was unconditionally hardcoding
viewportType as 'volume', which converted stack viewports to orthographic
during layer removal. This was hidden by configureViewportForLayerAddition
accidentally resetting the type back to 'stack' on re-addition, but once
that function was fixed to respect the current viewport type, the hardcoded
'volume' in removal was exposed as a bug.
await viewport.addVolumes(toAdd);
}
} else {
await viewport.setVolumes(baseVolumeInputs);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@jbocce Regarding the test (SEGDataOverlayForUnreferencedDisplaySetNoHydration.spec.ts) we were discussing this morning, this is what caused the change.

Before, setVolumes was called with volumeInputArray, which had both the background display set (Apparent Diffusion Coefficient) and the SEG which caused later the to create its images and displayed in the viewport as we saw.
await viewport.setVolumes(volumeInputArray);

Now setVolumes is called with baseVolumeInputs and this comes from filteredVolumeInputArray which filters out the SEG so base volume has only (Apparent Diffusion Coefficient)

I do think that what we currently have (after the change) is the correct behaviour, just to display the selected background and the SEGs masks on the top of it.

What do you think :) ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@GhadeerAlbattarni, yes I agree. Sorry for the delay to respond.

Copy link
Copy Markdown
Collaborator

@jbocce jbocce left a comment

Choose a reason for hiding this comment

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

Looks great to me. We are now headed for decreased testing time. @wayfarer3130 please have a look, I believe I tagged you on a few items.

function getVolumeActorReferencedIds(viewport: Types.IVolumeViewport): string[] {
const actors = viewport.getActors?.() ?? [];
return actors
.filter(ae => ae.actor?.getClassName?.() === 'vtkVolume')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we use ac instead of ae? ae is typically used to mean application entity in the DICOM sense, so it took me a moment to realize it wasn't that.


if (Array.isArray(hangingProtocolUpdates)) {
for (const hpUpdate of hangingProtocolUpdates) {
const entry = hpUpdate as {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we get the hpUpdates list type declared upstream and passed into this function properly declared? I'd rather add a type of the hanging protocol code that can get applied here and pass it around properly. I'm not sure if that is in grid viewport or hp updates. If you use an interface, it is easy enough to add fields later if we need.

}

viewport.viewportOptions.viewportType = 'volume';
const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you write this
csViewportOrthographic = ...?.type === 'orthographic'
It just makes the assignment to viewportType a bit more obvious.

Copy link
Copy Markdown
Contributor

@wayfarer3130 wayfarer3130 left a comment

Choose a reason for hiding this comment

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

Just a couple of quite minor changes for improved typing/naming in one spot.

@jbocce jbocce merged commit f8ccf9f into OHIF:master Apr 27, 2026
8 checks passed
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.

[Bug] MPR viewports don't load correctly in 3d four up spec test

3 participants