Skip to content

fix(segmentation): reuse VTK actors on scroll instead of destroy/recreate#2676

Closed
pedrokohler wants to merge 2 commits into
cornerstonejs:mainfrom
pedrokohler:fix/memory-leak-vtk-actor-reuse-on-scroll
Closed

fix(segmentation): reuse VTK actors on scroll instead of destroy/recreate#2676
pedrokohler wants to merge 2 commits into
cornerstonejs:mainfrom
pedrokohler:fix/memory-leak-vtk-actor-reuse-on-scroll

Conversation

@pedrokohler

@pedrokohler pedrokohler commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Context

Each scroll event on a StackViewport with segmentations destroys all segmentation
actors and creates fresh VTK objects (vtkImageData + backing TypedArray) for
every segmentation on the new slice. With 13 segmentations at 512x512 Uint8
resolution, this allocates ~3.4 MB per scroll of new VTK objects. VTK.js internal
caches retain references to the old objects, preventing garbage collection and
causing a linear memory leak.

Reproduction:

  1. Open a StackViewport displaying a 512x512 series with 13+ labelmap segmentations.
  2. Scroll through 100 slices.
  3. Take a heap snapshot — heap has grown by ~390 MB (unreclaimable after forced GC).

Changes & Results

1. Reuse VTK actors on scroll (imageChangeEventListener.ts):

Instead of destroying actors on every slice change, find existing actors matching
the segmentation's representation UID prefix and update their vtkImageData
origin and pixel data in-place via updateVTKImageDataWithCornerstoneImage. Use
representation UID prefix matching for stale actor cleanup (instead of per-slice
image ID matching), so actors survive across slices.

2. Prevent actor theft for overlapping segments (imageChangeEventListener.ts):

When a segmentation has overlapping segments (multiple labelmap groups per slice),
the reusable-actor search could return the same actor for multiple groups. Track
consumed actor UIDs in a Set<string> so each actor is reused at most once.

3. Prevent callback closure leak (StackViewport.ts):

addImages() spread ...rest of the stack input into the actor entry stored in
the _actors Map. This inadvertently captured the callback closure (which
holds a reference to imageData) in the long-lived actor entry. Destructure
callback out before the spread so it is not persisted.

4. Exclude test files from ESM build (packages/tools/tsconfig.json):

Add ./src/**/__tests__/** to the exclude array so test files using Jest
matchers don't break the build:esm compilation.

Before: ~3.9 MB/scroll memory leak, heap grows linearly and never reclaims.
After: heap stays flat during scrolling — actors are reused in-place.

Testing

  1. Open a StackViewport with 10+ labelmap segmentations (including overlapping segments if possible).
  2. Scroll through 200+ slices.
  3. Take heap snapshots before and after — heap should be stable (no linear growth).
  4. Verify overlapping segments remain visible on all slices (no "missing" overlap group).
  5. Run unit tests: npx jest imageChangeEventListener — all pass.

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.

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Labelmap actors are now reused in place when possible, reducing unnecessary remove-and-recreate cycles during stack navigation.
    • Legacy viewports still use the previous behavior when reuse isn’t supported.
  • Bug Fixes

    • Improved handling of overlapping labelmap groups so actors are no longer incorrectly reused by multiple groups.
    • Stale labelmap actors are cleaned up more reliably after slice changes.
  • Tests

    • Added coverage for actor reuse, creation, removal, and overlapping group behavior.

@pedrokohler pedrokohler marked this pull request as draft March 25, 2026 22:19
@pedrokohler pedrokohler marked this pull request as ready for review March 27, 2026 11:19
@pedrokohler

pedrokohler commented Mar 27, 2026

Copy link
Copy Markdown
Contributor Author

@sedghi TL;DR this solves a very serious memory leak issue that out team here at Centaur has experienced: as you scroll through the cornerstone viewport, the allocated heap memory increases dramatically. Notice the value at the bottom right side

memory_leak.1.mp4


if (!segmentationActorInput) {
// i guess we need to create here
const reusableEntry = actors.find(

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.

I remember there was an actual reason for this, and i don't remember why. I let QA test it and maybe you fixed it or got fixed

@pedrokohler

Copy link
Copy Markdown
Contributor Author

@sedghi it's been almost 2 months since QA was asked to review. Could we merge?

@sedghi

sedghi commented May 12, 2026

Copy link
Copy Markdown
Member

@james-hanks

@pedrokohler

Copy link
Copy Markdown
Contributor Author

@sedghi 😢

@sedghi

sedghi commented Jun 25, 2026

Copy link
Copy Markdown
Member

can you rebase

…eate

Ported onto the Cornerstone3D 5.0 generic-viewport rearchitecture (cornerstonejs#2748),
which moved the stack labelmap sync into syncStackLabelmapActors.

Each scroll on a StackViewport with segmentations removed every labelmap
actor for the previous slice and created fresh VTK objects (vtkImageData +
backing TypedArray) for the new slice. vtk.js internal caches retain the
discarded objects, so the heap grows linearly while scrolling (~3.9 MB per
scroll with many segmentations) and is never reclaimed.

For the legacy StackViewport (which renders directly from each actor's
vtkImageData and does not track actors through the generic-viewport data
registry), reuse the existing actors on scroll: when no actor exactly
matches a slice's derived image id, swap a stale actor's underlying labelmap
image and origin in place via setDerivedImage /
updateVTKImageDataWithCornerstoneImage. Stale actors form a reuse pool that
is consumed at most once per derived image, so overlapping segments
(multiple labelmap groups on the same slice) cannot steal one another's
actor. Pooled actors not reused are removed, and brand-new actors are only
created when the pool is exhausted.

Registry-backed viewports (PlanarViewport and its legacy adapter, detected
via the presence of removeData) own their actors by dataId, so their
existing remove/recreate flow is preserved to avoid desyncing that state.

Also destructure callback out of addImages in StackViewport before the
spread, so the callback closure (which can reference a large vtkImageData)
is not persisted on the long-lived actor entry in the _actors map.

Made-with: Cursor
…t prevention

Unit-test the in-place actor reuse algorithm in syncStackLabelmapActors:
normal single-actor scroll, distinct actor per overlapping segment group,
new-actor creation only once the reuse pool is exhausted, removal of pooled
actors when groups shrink, exact-match updates not consuming the pool, and a
regression case showing the actor theft that per-derived-image consumption
prevents.

Made-with: Cursor
@pedrokohler pedrokohler force-pushed the fix/memory-leak-vtk-actor-reuse-on-scroll branch from 9a64cab to 9bcfb66 Compare June 25, 2026 21:21
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates StackViewport.addImages to call stack callbacks before persisting actor entries, and changes labelmap actor synchronization to reuse stale actors in place when supported, remove leftover stale entries, and verify the reuse semantics with new unit tests.

Changes

Stack image and labelmap actor updates

Layer / File(s) Summary
Callback handling in addImages
packages/core/src/RenderingEngine/StackViewport.ts
addImages strips callback out of each stack input, calls it with the created imageActor and imageId, and stores actor entries without the callback field.
In-place stale actor reuse
packages/tools/src/tools/displayTools/Labelmap/syncStackLabelmapActors.ts
syncStackLabelmapActors checks viewport.removeData, removes stale labelmap entries through a helper, reuses stale actors in place for derived images when possible, and removes leftover reusable entries after processing.
Reuse semantics tests
packages/tools/src/tools/displayTools/Labelmap/syncStackLabelmapActors.spec.ts
The new spec models reusable-pool consumption and the previous non-consuming behavior, and covers reuse, overlap, exhaustion, shrinking groups, exact-match reuse, and duplicate reuse from the buggy path.

Sequence Diagram(s)

sequenceDiagram
  participant syncStackLabelmapActors
  participant viewport
  participant utilities
  syncStackLabelmapActors->>viewport: removeLabelmapRepresentationData for stale actor entries
  alt canReuseActorsInPlace and reusable actor entry exists
    syncStackLabelmapActors->>utilities: setDerivedImage or updateVTKImageDataWithCornerstoneImage
    syncStackLabelmapActors->>syncStackLabelmapActors: update referencedId and representationUID
  else no reusable actor entry
    syncStackLabelmapActors->>viewport: addImages for the derived image
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • jbocce

Poem

A bunny hopped through stacks so neat,
with callbacks tucked away complete.
Old labelmaps found new feet to wear,
and tests watched closely from their lair.
🐰✨ Thump, thump — the viewport’s sweet!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description follows the template well, but the checklist is incomplete and the tested environment fields are not filled in. Complete all checklist checkboxes and add the OS, Node version, and browser details in the Tested Environment section.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, specific, and accurately reflects the main change: reusing VTK actors during scroll instead of recreating them.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In
`@packages/tools/src/tools/displayTools/Labelmap/syncStackLabelmapActors.spec.ts`:
- Around line 34-107: The spec currently reimplements the syncing logic in
simulateSync and simulateBuggyReuse instead of exercising
syncStackLabelmapActors, so regressions in the real implementation can slip
through. Rewrite these tests to call syncStackLabelmapActors directly with
mocked viewport/actor state and assertions on the resulting actor mutations,
pool cleanup, and viewport interactions, using the existing LABELMAP-related
setup and any helper mocks already present in the file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b87c03ac-3049-45da-a906-f3852b12e7c0

📥 Commits

Reviewing files that changed from the base of the PR and between 0cc8f0d and 9bcfb66.

📒 Files selected for processing (3)
  • packages/core/src/RenderingEngine/StackViewport.ts
  • packages/tools/src/tools/displayTools/Labelmap/syncStackLabelmapActors.spec.ts
  • packages/tools/src/tools/displayTools/Labelmap/syncStackLabelmapActors.ts

Comment on lines +34 to +107
function simulateSync(
existingActors: MockActorEntry[],
derivedImageIds: string[],
segmentationId: string
) {
const derivedImageIdSet = new Set(derivedImageIds);
const reusablePool = existingActors.filter(
(actor) => !derivedImageIdSet.has(actor.referencedId)
);

const updatedInPlaceUids: string[] = [];
const reusedActorUids: string[] = [];
let createdCount = 0;

derivedImageIds.forEach((derivedImageId) => {
const exactMatch = existingActors.find(
(actor) => actor.referencedId === derivedImageId
);

if (exactMatch) {
updatedInPlaceUids.push(exactMatch.uid);
return;
}

const reusable = reusablePool.shift();

if (reusable) {
reusedActorUids.push(reusable.uid);
reusable.referencedId = derivedImageId;
reusable.representationUID = `${segmentationId}-${LABELMAP}-${derivedImageId}`;
return;
}

createdCount++;
});

const removedActorUids = reusablePool.map((actor) => actor.uid);

return { updatedInPlaceUids, reusedActorUids, createdCount, removedActorUids };
}

/**
* The pre-fix path: it picked a reusable actor from the pool without consuming
* it, so two derived images (overlapping segment groups) could resolve to the
* same actor (actor theft), leaving the other group invisible.
*/
function simulateBuggyReuse(
existingActors: MockActorEntry[],
derivedImageIds: string[]
) {
const derivedImageIdSet = new Set(derivedImageIds);
const reusablePool = existingActors.filter(
(actor) => !derivedImageIdSet.has(actor.referencedId)
);
const reusedActorUids: string[] = [];

derivedImageIds.forEach((derivedImageId) => {
const exactMatch = existingActors.find(
(actor) => actor.referencedId === derivedImageId
);

if (exactMatch) {
return;
}

const reusable = reusablePool[0]; // BUG: pool entry is never consumed

if (reusable) {
reusedActorUids.push(reusable.uid);
}
});

return { reusedActorUids };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Exercise syncStackLabelmapActors directly in this spec.

simulateSync and simulateBuggyReuse reimplement the intended algorithm instead of calling the production function, so this suite can stay green even if syncStackLabelmapActors regresses in actor mutation, pool cleanup, or viewport interaction. Please rewrite these as tests around the real function with mocked viewport/actor state.

🤖 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
`@packages/tools/src/tools/displayTools/Labelmap/syncStackLabelmapActors.spec.ts`
around lines 34 - 107, The spec currently reimplements the syncing logic in
simulateSync and simulateBuggyReuse instead of exercising
syncStackLabelmapActors, so regressions in the real implementation can slip
through. Rewrite these tests to call syncStackLabelmapActors directly with
mocked viewport/actor state and assertions on the resulting actor mutations,
pool cleanup, and viewport interactions, using the existing LABELMAP-related
setup and any helper mocks already present in the file.

@sedghi

sedghi commented Jun 25, 2026

Copy link
Copy Markdown
Member

So are you certain we need this? it might have been fixed in the recent push for next segmentation stuff

see, i can't reproduce https://cleanshot.com/share/6NnbgLly

@pedrokohler

Copy link
Copy Markdown
Contributor Author

@sedghi indeed, it looks like this memory leak was fixed sometime over the past 3 months, so I’ll close this PR.

Thanks for taking the initiative to review it.

That said, it’s pretty discouraging to have a PR sit pending review for 3 months, only to find out after rebasing that it’s no longer relevant. Please pass that feedback along to the QA team.

@sedghi

sedghi commented Jun 26, 2026

Copy link
Copy Markdown
Member

I agree, we have a PR review speed problem we are fixing it

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.

2 participants