Skip to content

fix(adapters): do not drop segmentation slices when merging sparse segment data - #2847

Open
Joeycho wants to merge 1 commit into
cornerstonejs:mainfrom
Joeycho:fix/sparse-merge-drops-seg-slices
Open

fix(adapters): do not drop segmentation slices when merging sparse segment data#2847
Joeycho wants to merge 1 commit into
cornerstonejs:mainfrom
Joeycho:fix/sparse-merge-drops-seg-slices

Conversation

@Joeycho

@Joeycho Joeycho commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Context

Fixes #2846.

getSegmentData() in labelmapImagesFromBuffer.ts builds its result as a sparse array — segmentData[i] is assigned only for images that actually hold voxels:

if (hasWrittenSegmentationData) {
  segmentData[currentLabelMapImageIndex] = segmentationDataForImageId;
}

compactMergeSegmentDataWithoutInformationLoss then merged two of those arrays with largerArray.forEach(...). Array.prototype.forEach skips holes, so the merge only visited indices where largerArray had an element. When the existing layer is the longer of the two, largerArray is that layer — and every image where only the incoming segment had data was never visited, and was silently discarded.

It surfaces when two segments do not collide in-plane (so they merge into one layer instead of being pushed as separate layers) and the incoming segment extends beyond the existing layer's populated index range.

Observed on a 694-slice CT: segment A spanned images [375..525] (length 526), segment B spanned [241..453] (length 454). B merged into A's layer, largerArray became A, and B came back as only [375..453]213 slices reduced to 79. Reloading a saved DICOM-SEG returned a truncated mask, and re-exporting wrote the truncation back, so the loss compounded on every save/reload cycle. Nothing raised an error.

A detail that makes this recognisable: the survivor is always A.start..B.end, regardless of how far B extends below A.start. Masks of 134 and 213 slices both collapsed to exactly 79, which is what made the symptom look so strange from the outside.

Changes & Results

  • compactMergeSegData.ts: iterate the index range (Math.max of both lengths) instead of one array's populated entries.
  • checkHasOverlapping iterates the same way but is correct and is deliberately left unchanged: an overlap requires both arrays to be populated at the same index, so a hole can never be an overlap.
  • Added a regression test.

Before / after (the new test case):

FAIL  should keep new data at indices the existing layer does not span (sparse arrays)
      expected [0,2], got undefined
PASS  should keep new data at indices the existing layer does not span (sparse arrays)

Downstream verification in our application (OHIF-based): reloading the affected DICOM-SEG and re-exporting is now byte-identical to the original — 1428/1428 frames, 13/13 segments, zero differing pixels, compared per (segment label + referenced source SOP instance). Before the fix the same round trip returned 79 of 213 slices for the affected segment.

Testing

cd packages/adapters && yarn test -- compactMergeSegData

Nine cases: the eight existing ones plus the new sparse-array case. The new case fails on main and passes with this change; the existing eight are unaffected either way.

Worth noting why this was not already covered: every existing case uses dense arrays. [undefined, [0, 1]] holds an undefined element, which forEach does visit — that is materially different from a hole, which it skips. The new test builds sparse arrays the way getSegmentData() actually does.

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: Ubuntu 22.04 (Linux 5.15)"
  • "Node version: 24.2.0"
  • "Browser: Chrome 13x (downstream verification in an OHIF 3.x viewer)"

Summary by CodeRabbit

  • Bug Fixes
    • Improved segmentation data merging when image data contains gaps or sparse entries.
    • Preserved newly received voxel data while continuing to merge overlapping image data correctly.

…gment data

`getSegmentData()` builds its result as a SPARSE array -- `segmentData[i]` is
assigned only for images that actually hold voxels:

    if (hasWrittenSegmentationData) {
      segmentData[currentLabelMapImageIndex] = segmentationDataForImageId;
    }

`compactMergeSegmentDataWithoutInformationLoss` then merged two of those arrays
with `largerArray.forEach(...)`. `Array.prototype.forEach` SKIPS HOLES, so the
merge only visited indices where `largerArray` had an element. When the existing
layer is the longer of the two, `largerArray` IS that layer -- and every image
where only the INCOMING segment had data was never visited, and was silently
discarded.

This surfaces when two segments do not collide in-plane (so they merge into one
layer rather than being pushed as separate layers) and the incoming segment
extends beyond the existing layer's populated index range.

Observed on a 694-slice CT: segment A spanned images [375..525] (length 526),
segment B spanned [241..453] (length 454). B merged into A's layer, `largerArray`
became A, and B came back as only [375..453] -- 213 slices reduced to 79.
Reloading a saved DICOM-SEG returned a truncated mask, and re-exporting wrote the
truncation back, so the loss compounded on every save/reload cycle. Nothing
raised an error. Note the survivor is always A.start..B.end regardless of how far
B extends below it, so masks of different sizes collapse to the same size.

Fixed by iterating the index RANGE instead of one array's populated entries.

`checkHasOverlapping` iterates the same way but is CORRECT and is left unchanged:
an overlap requires both arrays to be populated at the same index, so a hole can
never be an overlap.

The existing tests all use DENSE arrays -- `[undefined, [0, 1]]` holds an
`undefined` ELEMENT, which `forEach` does visit -- so none of them exercised a
real hole. Added a regression test that builds sparse arrays the way
`getSegmentData()` does; it fails before this change and passes after, with the
eight existing cases unaffected.
@coderabbitai

coderabbitai Bot commented Aug 6, 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: 7c90af7d-223d-47da-a12c-9f0fd3681b96

📥 Commits

Reviewing files that changed from the base of the PR and between 28a786f and 2ab1f3d.

📒 Files selected for processing (2)
  • packages/adapters/src/adapters/Cornerstone3D/Segmentation/compactMergeSegData.ts
  • packages/adapters/test/compactMergeSegData.jest.js

📝 Walkthrough

Walkthrough

The segment-data merge now traverses all indices up to the maximum input length. It copies incoming data into empty slots, merges overlapping image data, and skips indices without incoming data. A regression test covers sparse arrays with non-overlapping and overlapping indices.

Changes

Segmentation merge

Layer / File(s) Summary
Sparse merge traversal and regression coverage
packages/adapters/src/adapters/Cornerstone3D/Segmentation/compactMergeSegData.ts, packages/adapters/test/compactMergeSegData.jest.js
The merge iterates through the maximum input length, preserves incoming-only image data, and continues pixel-wise merging for overlapping data. The regression test validates sparse-array behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the sparse-segmentation merge fix and follows the repository's semantic-release format.
Description check ✅ Passed The description includes complete context, implementation details, testing results, and a completed checklist.
Linked Issues check ✅ Passed The code and regression test address all coding objectives in issue #2846, including preserving sparse-array data.
Out of Scope Changes check ✅ Passed The source change and regression test are directly related to the linked issue and stated pull request objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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.

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.

DICOM-SEG reload silently drops slices: sparse-array forEach in compactMergeSegmentDataWithoutInformationLoss

1 participant