From b8267489656cf619fc9faa75acf779f2a0144407 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 14 May 2026 09:12:20 -0400 Subject: [PATCH 1/6] feat(cine): add ultrasound multi-frame DICOM playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands a self-contained cine pipeline for single-file ultrasound DICOMs (SOP Class UID 1.2.840.10008.5.1.4.1.1.3 / .3.1, NumberOfFrames > 1) alongside the existing volume pipeline. Multi-chunk volume imports never match the cine router, so CT/MR streaming and 3D volume behavior are unchanged. Core additions under src/core/cine/: - parseCineDicom.ts wraps cornerstonejs/dicom-parser to extract the header (transfer syntax, geometry, FrameTime, patient/study/series, SequenceOfUltrasoundRegions) and per-frame byte views — zero-copy for native PixelData, fragment-aware for encapsulated JPEG-Baseline (with populated BOT, empty BOT, and JPEG-SOI scan fallbacks). Supports Implicit + Explicit VR LE and JPEG-Baseline. - DicomCineImage extends BaseProgressiveImage, owns one 2D vtkImageData (extent [0,cols-1, 0,rows-1, 0,0], 3-component RGB uint8), and swaps scalars in-place when the selected frame changes. setFrame() bumps a decode token unconditionally so any new request — cached or decode — invalidates in-flight decodes. - frameCache.ts: byte-budgeted LRU keyed by frame index; decodeJpegFrame via createImageBitmap + OffscreenCanvas; decodeNativeFrame for native RGB / MONOCHROME2. - isCineImage / getCineImage helpers and getRenderSlice that returns 0 for cine so the VTK mapper renders slice 0 while the semantic slice is the frame index. Import routing in src/store/datasets-dicom.ts: when a chunk group has a single chunk, an UltrasoundMultiframe SOP UID (current or retired), and NumberOfFrames > 1, it diverts to _importCineChunk before the legacy DicomChunkImage path. View integration: - VtkBaseSliceRepresentation.vue: render-slice helper pins VTK slice to 0 for cine; conditional W/L sync (cine pixels are display-encoded, so wlConfig defaults don't clobber them); slice watch is immediate so restored sessions paint the saved frame on first mount. - SliceViewerOverlay shows "Frame: N/M" and hosts a new CineTransport component (play/pause/loop/fps via useIntervalFn, FPS seeded from FrameTime). - Ruler/Rectangle/Polygon widgets use getRenderSlice for their plane manipulator origin so annotations still scope to a frame. - view-configs/slicing.ts overrides the slice range to [0, numberOfFrames - 1] for cine. - image-cache.removeImage now calls dispose() before delete; cine's dispose clears the LRU, drops compressed frame refs, and deletes the vtkImageData. - image-stats early-returns for cine ids — histogram/auto-range is meaningless on display-encoded data. Testing: - 3 vitest tests build a synthetic DICOM in-memory (Explicit VR LE native, encapsulated with populated BOT, encapsulated with empty BOT) to exercise the parser without external fixtures. - New tests/specs/cine-rendering.e2e.ts loads US-MONO2-8-8x-execho.dcm from the BSD-licensed GDCM corpus on SourceForge (cached via the existing wdio onPrepare hook into .tmp/), asserts the cine transport renders with "1 / 8", and asserts the counter advances on ArrowUp. Adds dicom-parser ^1.8.21 (MIT, 0 deps, ~6.9 KB gzipped) to devDependencies. --- package-lock.json | 8 + package.json | 1 + src/components/PlayControls.vue | 141 ++++++++ src/components/SliceViewer.vue | 11 +- src/components/SliceViewerOverlay.vue | 19 +- .../tools/polygon/PolygonWidget2D.vue | 4 +- .../tools/rectangle/RectangleWidget2D.vue | 4 +- src/components/tools/ruler/RulerWidget2D.vue | 4 +- .../vtk/VtkBaseSliceRepresentation.vue | 53 ++- src/core/cine/DicomCineImage.ts | 283 ++++++++++++++++ .../cine/__tests__/parseCineDicom.spec.ts | 243 ++++++++++++++ src/core/cine/frameCache.ts | 184 +++++++++++ src/core/cine/getRenderSlice.ts | 17 + src/core/cine/isCineImage.ts | 16 + src/core/cine/parseCineDicom.ts | 309 ++++++++++++++++++ src/core/dicomTags.ts | 26 +- src/store/datasets-dicom.ts | 88 ++++- src/store/image-cache.ts | 5 + src/store/image-stats.ts | 5 + src/store/view-configs/slicing.ts | 14 + tests/specs/cine-rendering.e2e.ts | 38 +++ tests/specs/configTestUtils.ts | 7 + wdio.shared.conf.ts | 4 + 23 files changed, 1469 insertions(+), 15 deletions(-) create mode 100644 src/components/PlayControls.vue create mode 100644 src/core/cine/DicomCineImage.ts create mode 100644 src/core/cine/__tests__/parseCineDicom.spec.ts create mode 100644 src/core/cine/frameCache.ts create mode 100644 src/core/cine/getRenderSlice.ts create mode 100644 src/core/cine/isCineImage.ts create mode 100644 src/core/cine/parseCineDicom.ts create mode 100644 tests/specs/cine-rendering.e2e.ts diff --git a/package-lock.json b/package-lock.json index 9573fada4..448150066 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "cors": "^2.8.5", "cross-env": "^10.1.0", "deep-equal": "^2.2.3", + "dicom-parser": "^1.8.21", "dicomweb-client-typed": "^0.8.6", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", @@ -11938,6 +11939,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dicom-parser": { + "version": "1.8.21", + "resolved": "https://registry.npmjs.org/dicom-parser/-/dicom-parser-1.8.21.tgz", + "integrity": "sha512-lYCweHQDsC8UFpXErPlg86Px2A8bay0HiUY+wzoG3xv5GzgqVHU3lziwSc/Gzn7VV7y2KeP072SzCviuOoU02w==", + "dev": true, + "license": "MIT" + }, "node_modules/dicomweb-client-typed": { "version": "0.8.6", "resolved": "https://registry.npmjs.org/dicomweb-client-typed/-/dicomweb-client-typed-0.8.6.tgz", diff --git a/package.json b/package.json index 6822ffe4e..0715c9995 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "cors": "^2.8.5", "cross-env": "^10.1.0", "deep-equal": "^2.2.3", + "dicom-parser": "^1.8.21", "dicomweb-client-typed": "^0.8.6", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", diff --git a/src/components/PlayControls.vue b/src/components/PlayControls.vue new file mode 100644 index 000000000..0ccfeb53e --- /dev/null +++ b/src/components/PlayControls.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/src/components/SliceViewer.vue b/src/components/SliceViewer.vue index d2a07dbc2..508bab229 100644 --- a/src/components/SliceViewer.vue +++ b/src/components/SliceViewer.vue @@ -186,6 +186,7 @@ import { useAnnotationToolStore, useToolStore } from '@/src/store/tools'; import { doesToolFrameMatchViewAxis } from '@/src/composables/annotationTool'; import { useWebGLWatchdog } from '@/src/composables/useWebGLWatchdog'; import { useSliceConfig } from '@/src/composables/useSliceConfig'; +import { isCineImage } from '@/src/core/cine/isCineImage'; import VtkSliceViewWindowManipulator from '@/src/components/vtk/VtkSliceViewWindowManipulator.vue'; import VtkSliceViewSlicingManipulator from '@/src/components/vtk/VtkSliceViewSlicingManipulator.vue'; import VtkSliceViewSlicingKeyManipulator from '@/src/components/vtk/VtkSliceViewSlicingKeyManipulator.vue'; @@ -240,9 +241,13 @@ useViewAnimationListener(vtkView, viewId, '2D'); // active tool const { currentTool } = storeToRefs(useToolStore()); -const windowingManipulatorProps = computed(() => - currentTool.value === Tools.WindowLevel ? { button: 1 } : { button: -1 } -); +const windowingManipulatorProps = computed(() => { + // W/L is meaningless for 8-bit display-encoded cine; keep the manipulator + // off so dragging doesn't crush colors. + if (currentTool.value !== Tools.WindowLevel) return { button: -1 }; + if (isCineImage(currentImageID.value)) return { button: -1 }; + return { button: 1 }; +}); // base image const { diff --git a/src/components/SliceViewerOverlay.vue b/src/components/SliceViewerOverlay.vue index 5fb910bae..bc29430ae 100644 --- a/src/components/SliceViewerOverlay.vue +++ b/src/components/SliceViewerOverlay.vue @@ -9,6 +9,9 @@ import { useOrientationLabels } from '@/src/composables/useOrientationLabels'; import DicomQuickInfoButton from '@/src/components/DicomQuickInfoButton.vue'; import ViewTypeSwitcher from '@/src/components/ViewTypeSwitcher.vue'; import { useImage } from '@/src/composables/useCurrentImage'; +import { isCineImage } from '@/src/core/cine/isCineImage'; +import PlayControls from '@/src/components/PlayControls.vue'; +import { computed } from 'vue'; interface Props { viewId: string; @@ -34,6 +37,8 @@ const { level: windowLevel, } = useWindowingConfig(viewId, imageId); const { metadata } = useImage(imageId); + +const isCine = computed(() => isCineImage(imageId.value));