From d1a3dfda2fa159f2c2ba819207384d3e6ebe6da5 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 18 Oct 2026 14:15:44 -0400 Subject: [PATCH 01/13] feat: add shared image layer display controls Add palette, contrast, range, and visibility controls for slice images, plus overlay opacity and background transparency. Persist settings across views, layouts, and saved sessions. --- docs/rendering.md | 49 +- src/components/BaseImageProperties.vue | 231 +++ src/components/ColorMapRangeControl.vue | 486 ++++++ src/components/ColorMapSelect.vue | 63 + src/components/ColorMapSwatch.vue | 32 + src/components/DisplayRangeControl.vue | 275 ++++ src/components/LayerList.vue | 41 +- src/components/LayerProperties.vue | 357 ++++- src/components/ObliqueViewer.vue | 5 +- src/components/RenderingModule.vue | 86 +- .../__tests__/LayerProperties.spec.ts | 385 +++++ .../tools/windowing/WindowLevelControls.vue | 43 +- .../vtk/VtkBaseObliqueSliceRepresentation.vue | 42 +- .../vtk/VtkBaseSliceRepresentation.vue | 36 +- .../vtk/VtkLayerSliceRepresentation.vue | 110 +- .../vtk/VtkSliceViewWindowManipulator.vue | 2 +- src/composables/useBaseSliceColoring.ts | 30 + src/composables/useWindowingConfig.ts | 17 +- src/config.ts | 5 +- src/io/state-file/schema.ts | 18 +- src/io/state-file/serialize.ts | 1 + src/store/datasets-layers.ts | 66 +- src/store/view-configs.ts | 27 +- src/store/view-configs/layers.ts | 201 ++- src/store/view-configs/types.ts | 11 +- src/store/view-configs/windowing.ts | 18 +- src/utils/colorMapGradient.ts | 75 + src/utils/formatDisplayRange.ts | 25 + src/utils/layers.ts | 2 + src/utils/nativeColorWindow.ts | 17 + src/vtk/ColorMaps.ts | 18 +- src/vtk/DicomColorPresets.json | 1312 +++++++++++++++++ src/vtk/LayerColorPresets.ts | 21 + .../layers_max_opacity_2d_view-chrome-1.png | Bin 217013 -> 202192 bytes tests/specs/base-image-display.e2e.ts | 519 +++++++ tests/specs/layer-display-controls.e2e.ts | 577 ++++++++ tests/specs/layerDisplayTestUtils.ts | 33 + tests/specs/layers-and-rendering.e2e.ts | 88 +- tests/specs/native-color-display.e2e.ts | 183 +++ tests/specs/overlay-auto-contrast.e2e.ts | 277 ++++ .../specs/overlay-segmentation-opacity.e2e.ts | 148 ++ tests/specs/palette-window.e2e.ts | 446 ++++++ tests/specs/range-number-steppers.e2e.ts | 146 ++ tests/specs/transparent-background.e2e.ts | 260 ++++ tests/specs/utils.ts | 107 ++ vite.config.ts | 9 +- 46 files changed, 6616 insertions(+), 284 deletions(-) create mode 100644 src/components/BaseImageProperties.vue create mode 100644 src/components/ColorMapRangeControl.vue create mode 100644 src/components/ColorMapSelect.vue create mode 100644 src/components/ColorMapSwatch.vue create mode 100644 src/components/DisplayRangeControl.vue create mode 100644 src/components/__tests__/LayerProperties.spec.ts create mode 100644 src/composables/useBaseSliceColoring.ts create mode 100644 src/utils/colorMapGradient.ts create mode 100644 src/utils/formatDisplayRange.ts create mode 100644 src/utils/layers.ts create mode 100644 src/utils/nativeColorWindow.ts create mode 100644 src/vtk/DicomColorPresets.json create mode 100644 src/vtk/LayerColorPresets.ts create mode 100644 tests/specs/base-image-display.e2e.ts create mode 100644 tests/specs/layer-display-controls.e2e.ts create mode 100644 tests/specs/layerDisplayTestUtils.ts create mode 100644 tests/specs/native-color-display.e2e.ts create mode 100644 tests/specs/overlay-auto-contrast.e2e.ts create mode 100644 tests/specs/overlay-segmentation-opacity.e2e.ts create mode 100644 tests/specs/palette-window.e2e.ts create mode 100644 tests/specs/range-number-steppers.e2e.ts create mode 100644 tests/specs/transparent-background.e2e.ts diff --git a/docs/rendering.md b/docs/rendering.md index 11edadb5a..18d34a271 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -1,9 +1,55 @@ # Cinematic Volume Rendering +## Image layers in 2D views + +Open **Rendering > Layers** to adjust the base image and its overlays. +**Show/Hide**, beside each color-map selector, changes visibility without +removing the image or its display settings. + +- **Color map** shows palettes with gradient previews. Choosing one preserves + the display range and other settings. +- **Display range** shows the palette across the image's full intensity range. + Drag either end handle to change Min or Max, or drag the center handle to move + both together while preserving their separation. A smaller arrow handle marks + a value outside the image range. Dragging it continues from its actual value. + The Min and Max fields below the bar also accept negative values, decimals, + and scientific notation. Invalid or incomplete edits leave the applied range + unchanged. Values use four significant digits, with more precision for narrow + ranges. Hover a field to see its exact value; focusing an unchanged field + preserves that value at full precision. +- **Contrast** opens range options. For overlays, choose Full Range or Low, + Medium, or High Contrast based on that overlay's image statistics. The base + image menu also offers window/level presets and manual Window and Level + fields. Its Min and Max values represent the same window/level used by the + toolbar and mouse controls. +- **Opacity** adjusts how strongly an overlay blends with the images beneath + it. Its percentage appears while adjusting the slider. +- **Transparent background** makes overlay values at or below Min transparent, + with a linear fade over the first 10% of the display range above Min. It + retains the overlay's opacity function and overall opacity. Turn it off to + use the palette and opacity function without this fade. It is on by default, + including for saved sessions that omit the setting. + +Overlay controls apply to every 2D view showing that overlay. Base-image +palette, range, and visibility apply to its 2D and oblique views. These settings +persist through layout changes and saved sessions, including a 3D-only layout. +The separate **3D Rendering** section can be collapsed to reach the layer +controls; its volume-rendering settings are independent. + +RGB and RGBA images retain their native colors. Scalar color-map, range, and +Contrast controls are disabled for these images, as is Transparent background. +Overlay opacity and visibility remain available. Controls that require a +matching slice view remain visible and disabled when that view is unavailable, +with tooltips explaining why. + +Display ranges use image values. These controls do not calculate or imply +standardized uptake values (SUV) for PET data. + +## Cinematic rendering controls + VolView reads the DICOM tags of your data to determine appropriate preset parameter values for cinematic volume rendering for your data, but often you will want to tweak those presets to emphasize specific details. Rendering controls apply to the currently selected/active view. We recommend the following sequence of tweaks to improve your visualizations. 1. Cinematic volume rendering involves two "transfer functions": one maps the recorded intensity values (e.g., CT hounsfield units) to opacity and the other maps recorded intensity values to color. Both can be adjusted using the controls at the top of the Rendering tab. The graph at the top shows a histogram of the recorded intensity values in light gray and the current opacity transfer function as an overlaid curve in black. In that graph, beneath the black curve, is a depiction of the color transfer function. ![Cinematic](./assets/16-volview-rendering.jpg) - 1. Begin by adjusting the opacity transfer function by a press-and-drag right/left on the top graph. ![Opacity](./assets/17-volview-opacity-notes.jpg) 2. Then adjust the color transfer function by moving the blue dots on the colorbar beneath the graph. ![Color](./assets/17-volview-colormap-notes.jpg) @@ -11,7 +57,6 @@ VolView reads the DICOM tags of your data to determine appropriate preset parame [**_Watch the video!_**](https://youtu.be/eyrGd-meg6I) 3. Next, the lighting can be controlled. - 1. Ambient lighting is the general brightness of the scene. 2. Diffuse lighting controls how the light reflects off the data. It is influenced by the position of the light relative to the orientation of the data's local surface. 3. Light-follows-camera can be enabled / disabled to create shadows that highlights details within the data. When enabled, the light will be positioned in-line with the camera. When disabled, the last position of the light stays fixed relative to the data, even if the camera is moved. With light-follows-camera enabled, here is a view when the light is following the camera: ![Default Lighting](./assets/20-volview-lightfollowcamera1.jpg). By moving the camera (and thereby the light) to the front of the data and then disabling light-follows-camera, the view should look light this: ![Light Setup](./assets/20-volview-lightfollowcamera2.jpg). Then moving back to the side view will show shadows that emphasize depth and details: ![Shadows](./assets/20-volview-lightfollowcamera3.jpg). diff --git a/src/components/BaseImageProperties.vue b/src/components/BaseImageProperties.vue new file mode 100644 index 000000000..6ca9fbe85 --- /dev/null +++ b/src/components/BaseImageProperties.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/src/components/ColorMapRangeControl.vue b/src/components/ColorMapRangeControl.vue new file mode 100644 index 000000000..6ca54427e --- /dev/null +++ b/src/components/ColorMapRangeControl.vue @@ -0,0 +1,486 @@ + + + + + diff --git a/src/components/ColorMapSelect.vue b/src/components/ColorMapSelect.vue new file mode 100644 index 000000000..41358c235 --- /dev/null +++ b/src/components/ColorMapSelect.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/components/ColorMapSwatch.vue b/src/components/ColorMapSwatch.vue new file mode 100644 index 000000000..3ebbcc4b9 --- /dev/null +++ b/src/components/ColorMapSwatch.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/src/components/DisplayRangeControl.vue b/src/components/DisplayRangeControl.vue new file mode 100644 index 000000000..2270cf9bc --- /dev/null +++ b/src/components/DisplayRangeControl.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/src/components/LayerList.vue b/src/components/LayerList.vue index b15861756..d1d9cd2b3 100644 --- a/src/components/LayerList.vue +++ b/src/components/LayerList.vue @@ -1,32 +1,25 @@ - diff --git a/src/components/LayerProperties.vue b/src/components/LayerProperties.vue index 37d0b1a9f..406d8d5d8 100644 --- a/src/components/LayerProperties.vue +++ b/src/components/LayerProperties.vue @@ -1,88 +1,303 @@ - + + + + diff --git a/src/components/ColorMapRangeControl.vue b/src/components/ColorMapRangeControl.vue index 6ca54427e..55109cbba 100644 --- a/src/components/ColorMapRangeControl.vue +++ b/src/components/ColorMapRangeControl.vue @@ -2,6 +2,8 @@ import { computed, onBeforeUnmount, ref } from 'vue'; import { useDevicePixelRatio, useElementSize } from '@vueuse/core'; import ColorMapSwatch from './ColorMapSwatch.vue'; +import ColorMapHistogram from './ColorMapHistogram.vue'; +import type { ImageHistogram } from '@/src/store/image-stats'; type Range = readonly [number, number]; type Handle = 'min' | 'max' | 'center'; @@ -19,6 +21,7 @@ type Drag = { const props = defineProps<{ modelValue: Range; dataRange: Range; + histogram?: ImageHistogram; preset: string; transparentBackground?: boolean; disabled?: boolean; @@ -264,6 +267,11 @@ onBeforeUnmount(endDrag); :transparent-background="transparentBackground" aria-hidden="true" /> + import { computed, ref, watch, useId } from 'vue'; import { formatDisplayRange } from '@/src/utils/formatDisplayRange'; +import type { ImageHistogram } from '@/src/store/image-stats'; import ColorMapRangeControl from './ColorMapRangeControl.vue'; const props = withDefaults( defineProps<{ modelValue: readonly [number, number]; dataRange: readonly [number, number]; + histogram?: ImageHistogram; preset: string; transparentBackground?: boolean; disabled?: boolean; @@ -129,6 +131,7 @@ function updateRangeControl(range: number[]) { { const range = image.value?.getPointData().getScalars().getRange() ?? [0, 1]; return [range[0], range[1]] as const; }); +const histogram = computed(() => + !isCineImage(props.layer.selection) && + image.value?.getPointData().getScalars()?.getNumberOfComponents() === 1 + ? imageStatsStore.stats[props.layer.id]?.histogram + : undefined +); const validRange = ( range: readonly [number, number] | undefined ): range is readonly [number, number] => @@ -186,6 +193,7 @@ function setRange(range: [number, number]) { class="mt-1" :model-value="mappingRange ?? [0, 1]" :data-range="imageRange" + :histogram="histogram" :preset="preset" :transparent-background="transparentBackground" :disabled="!!coloringUnavailableReason" diff --git a/src/store/image-stats.ts b/src/store/image-stats.ts index 6f40bc3b6..64eb44787 100644 --- a/src/store/image-stats.ts +++ b/src/store/image-stats.ts @@ -21,10 +21,17 @@ import { useImageCacheStore } from './image-cache'; import { useMessageStore } from './messages'; import { isCineImage } from '@/src/core/cine/isCineImage'; +export type ImageHistogram = { + counts: readonly number[]; + origin: number; + binWidth: number; +}; + export type ImageStats = { scalarMin: number; scalarMax: number; autoRangeValues?: Record; + histogram?: ImageHistogram; }; function getRangesWithCache(scalars: vtkDataArray) { @@ -48,10 +55,10 @@ function getAllComponentRange(scalars: vtkDataArray) { return { min, max }; } -async function computeAutoRangeValues(imageData: vtkImageData) { +async function computeHistogramStats(imageData: vtkImageData) { const scalars = imageData.getPointData()?.getScalars(); if (!scalars) { - return {}; + return { autoRangeValues: {} }; } const { min, max } = getAllComponentRange(scalars); @@ -78,7 +85,7 @@ async function computeAutoRangeValues(imageData: vtkImageData) { const width = (max - min + 1) / WL_HIST_BINS; const totalCount = scalarData.length; - return Object.fromEntries( + const autoRangeValues = Object.fromEntries( Object.entries(WLAutoRanges).map(([key, percentage]) => { const lowerBound = percentage * 0.01 * totalCount; const upperBound = (1 - percentage * 0.01) * totalCount; @@ -91,6 +98,10 @@ async function computeAutoRangeValues(imageData: vtkImageData) { return [key, [start, end] as [number, number]]; }) ); + return { + autoRangeValues, + histogram: { counts: hist, origin: min, binWidth: width }, + }; } export const useImageStatsStore = defineStore('image-stats', () => { @@ -101,7 +112,7 @@ export const useImageStatsStore = defineStore('image-stats', () => { const statsEffectScope: Record = {}; const autoRangeComputations: Record< string, - Promise> + ReturnType > = {}; const internalSetScalarRange = ( @@ -116,13 +127,13 @@ export const useImageStatsStore = defineStore('image-stats', () => { }; }; - const internalSetAutoRangeValues = ( + const internalSetHistogramStats = ( imageID: string, - autoValues: Record + histogramStats: Awaited> ) => { stats[imageID] = { ...stats[imageID], - autoRangeValues: autoValues, + ...histogramStats, }; }; @@ -169,13 +180,13 @@ export const useImageStatsStore = defineStore('image-stats', () => { }); const triggerAutoRangeComputation = (image: vtkImageData) => { - autoRangeComputations[id] = computeAutoRangeValues(image); + autoRangeComputations[id] = computeHistogramStats(image); autoRangeComputations[id] - .then((autoValues) => { + .then((histogramStats) => { if (imageCacheStore.imageIds.includes(id)) { // not deleted yet, save values - internalSetAutoRangeValues(id, autoValues); + internalSetHistogramStats(id, histogramStats); } }) .catch((error) => { diff --git a/tests/specs/image-histogram.e2e.ts b/tests/specs/image-histogram.e2e.ts new file mode 100644 index 000000000..a63949eb8 --- /dev/null +++ b/tests/specs/image-histogram.e2e.ts @@ -0,0 +1,114 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { setValueVueInput } from '../pageobjects/volview.page'; +import { + layeredImageManifest, + openLayerDisplayControls, +} from './layerDisplayTestUtils'; +import { makeTempDir, openVolViewPage, writeManifestToFile } from './utils'; + +function writeFractionalImage(name: string, peaks: [number, number]) { + const directory = makeTempDir(name); + const header = [ + 'NRRD0005', + 'type: float', + 'dimension: 3', + 'sizes: 16 16 16', + 'space: left-posterior-superior', + 'space directions: (1,0,0) (0,1,0) (0,0,1)', + 'space origin: (0,0,0)', + 'encoding: ascii', + ].join('\n'); + const values = Array.from({ length: 4096 }, (_, index) => { + if (index === 0) return 0; + if (index === 4095) return 1; + return peaks[index % 4 === 0 ? 1 : 0]; + }).join(' '); + writeFileSync(join(directory, `${name}.nrrd`), `${header}\n\n${values}`); + return `${name}/${name}.nrrd`; +} + +const control = (id: string) => $(`[data-testid="${id}"]`); + +async function histogramShape(prefix: string, peaks: number[]) { + return browser.execute( + (selector: string, positions: number[]) => { + const svg = document.querySelector(selector)!; + const path = svg.querySelector('path')!; + const inside = (fraction: number, height: number) => + path.isPointInFill( + new DOMPoint(fraction * svg.viewBox.baseVal.width, height) + ); + return { + major: inside(positions[0], 1), + wideMajor: inside(positions[0] + 0.01, 1), + minor: inside(positions[1], 13), + minorTall: inside(positions[1], 8), + empty: inside(0.37, 14.5), + middle: inside(0.5, 14.5), + }; + }, + `[data-testid="${prefix}-histogram"]`, + peaks + ); +} + +describe('Image histograms in display range bars', () => { + it('positions fractional base and overlay peaks on the fixed image axis without intercepting range edits', async () => { + const base = writeFractionalImage('histogram-base', [0.125, 0.625]); + const overlay = writeFractionalImage('histogram-overlay', [0.25, 0.75]); + await writeManifestToFile( + layeredImageManifest(base, overlay), + 'histogram.json' + ); + await openVolViewPage('histogram.json'); + await $('.image-list-card*=histogram-base.nrrd') + .$('[data-testid="dataset-menu-button"]') + .click(); + const show = $('.v-list-item*=Show in all views'); + await show.waitForClickable(); + await show.click(); + await browser.keys('Escape'); + await openLayerDisplayControls(); + + for (const [prefix, peaks, bar] of [ + ['base-image-range', [0.127, 0.627], 'base-image-range-bar'], + ['layer-range', [0.252, 0.752], 'palette-window-bar'], + ] as const) { + const histogram = control(`${prefix}-histogram`); + await histogram.waitForDisplayed(); + await expect(histogram).toHaveAttribute('aria-hidden', 'true'); + expect((await histogram.getCSSProperty('pointer-events')).value).toBe( + 'none' + ); + await browser.waitUntil( + async () => { + const shape = await histogramShape(prefix, [...peaks]); + return ( + shape.major && + shape.wideMajor && + shape.minor && + !shape.minorTall && + !shape.empty && + !shape.middle + ); + }, + { + timeoutMsg: `Expected fractional peaks with a 3:1 height ratio in ${prefix}`, + } + ); + const originalPath = await histogram.$('path').getAttribute('d'); + expect((await control(bar).getSize()).height).toBe(32); + await setValueVueInput(control(`${prefix}-min`).$('input'), '0.05'); + await setValueVueInput(control(`${prefix}-max`).$('input'), '0.9'); + const handle = control(`${prefix}-min-handle`); + await expect(handle).toBeEnabled(); + const previous = await handle.getAttribute('aria-valuenow'); + await handle.execute((element) => element.focus()); + await browser.keys('ArrowRight'); + await expect(handle).not.toHaveAttribute('aria-valuenow', previous!); + expect(await histogram.$('path').getAttribute('d')).toBe(originalPath); + expect((await control(bar).getSize()).height).toBe(32); + } + }); +}); From d0eb55981137c4dd8a21ae8b337b8c77db6452f2 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 18 Oct 2026 14:42:11 -0400 Subject: [PATCH 04/13] chore: trim layer docs and remove unused sample tooling --- .github/workflows/e2e-datasets.yml | 2 +- .github/workflows/e2e.yml | 2 +- docs/rendering.md | 52 +----- .../samples/PET-CT-chest-10-slices.README.md | 57 ------- scripts/samples/create-pet-ct-sample.py | 148 ------------------ {scripts => src}/samples/pet-ct-sources.json | 0 src/samples/petCT.ts | 22 +-- tests/datasets.ts | 22 ++- 8 files changed, 31 insertions(+), 274 deletions(-) delete mode 100644 public/samples/PET-CT-chest-10-slices.README.md delete mode 100644 scripts/samples/create-pet-ct-sample.py rename {scripts => src}/samples/pet-ct-sources.json (100%) diff --git a/.github/workflows/e2e-datasets.yml b/.github/workflows/e2e-datasets.yml index 4b1031d70..5d87fd574 100644 --- a/.github/workflows/e2e-datasets.yml +++ b/.github/workflows/e2e-datasets.yml @@ -22,7 +22,7 @@ jobs: uses: actions/cache@v4 with: path: .tmp/datasets - key: e2e-datasets-${{ hashFiles('tests/datasets.ts', 'scripts/samples/pet-ct-sources.json') }} + key: e2e-datasets-${{ hashFiles('tests/datasets.ts', 'src/samples/petCT.ts', 'src/samples/pet-ct-sources.json') }} restore-keys: e2e-datasets- - name: Download missing datasets run: node tests/downloadDatasets.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f3d655bc5..2930ae4a7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,7 +33,7 @@ jobs: uses: actions/cache@v4 with: path: .tmp/datasets - key: e2e-datasets-${{ hashFiles('tests/datasets.ts', 'scripts/samples/pet-ct-sources.json') }} + key: e2e-datasets-${{ hashFiles('tests/datasets.ts', 'src/samples/petCT.ts', 'src/samples/pet-ct-sources.json') }} restore-keys: e2e-datasets- - name: Install xvfb (linux) if: matrix.os == 'ubuntu-latest' diff --git a/docs/rendering.md b/docs/rendering.md index 18d34a271..c82921e04 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -1,59 +1,23 @@ -# Cinematic Volume Rendering +# Rendering ## Image layers in 2D views -Open **Rendering > Layers** to adjust the base image and its overlays. -**Show/Hide**, beside each color-map selector, changes visibility without -removing the image or its display settings. +The color bar always spans the image's full intensity range, even when the +selected display range extends beyond it. -- **Color map** shows palettes with gradient previews. Choosing one preserves - the display range and other settings. -- **Display range** shows the palette across the image's full intensity range. - Drag either end handle to change Min or Max, or drag the center handle to move - both together while preserving their separation. A smaller arrow handle marks - a value outside the image range. Dragging it continues from its actual value. - The Min and Max fields below the bar also accept negative values, decimals, - and scientific notation. Invalid or incomplete edits leave the applied range - unchanged. Values use four significant digits, with more precision for narrow - ranges. Hover a field to see its exact value; focusing an unchanged field - preserves that value at full precision. -- **Contrast** opens range options. For overlays, choose Full Range or Low, - Medium, or High Contrast based on that overlay's image statistics. The base - image menu also offers window/level presets and manual Window and Level - fields. Its Min and Max values represent the same window/level used by the - toolbar and mouse controls. -- **Opacity** adjusts how strongly an overlay blends with the images beneath - it. Its percentage appears while adjusting the slider. -- **Transparent background** makes overlay values at or below Min transparent, - with a linear fade over the first 10% of the display range above Min. It - retains the overlay's opacity function and overall opacity. Turn it off to - use the palette and opacity function without this fade. It is on by default, - including for saved sessions that omit the setting. - -Overlay controls apply to every 2D view showing that overlay. Base-image -palette, range, and visibility apply to its 2D and oblique views. These settings -persist through layout changes and saved sessions, including a 3D-only layout. -The separate **3D Rendering** section can be collapsed to reach the layer -controls; its volume-rendering settings are independent. - -RGB and RGBA images retain their native colors. Scalar color-map, range, and -Contrast controls are disabled for these images, as is Transparent background. -Overlay opacity and visibility remain available. Controls that require a -matching slice view remain visible and disabled when that view is unavailable, -with tooltips explaining why. - -Display ranges use image values. These controls do not calculate or imply -standardized uptake values (SUV) for PET data. +**Transparent background** makes overlay values at or below Min transparent, +then fades linearly to the selected opacity over the first 10% of the display +range above Min. ## Cinematic rendering controls VolView reads the DICOM tags of your data to determine appropriate preset parameter values for cinematic volume rendering for your data, but often you will want to tweak those presets to emphasize specific details. Rendering controls apply to the currently selected/active view. We recommend the following sequence of tweaks to improve your visualizations. -1. Cinematic volume rendering involves two "transfer functions": one maps the recorded intensity values (e.g., CT hounsfield units) to opacity and the other maps recorded intensity values to color. Both can be adjusted using the controls at the top of the Rendering tab. The graph at the top shows a histogram of the recorded intensity values in light gray and the current opacity transfer function as an overlaid curve in black. In that graph, beneath the black curve, is a depiction of the color transfer function. ![Cinematic](./assets/16-volview-rendering.jpg) +1. Cinematic volume rendering involves two "transfer functions": one maps the recorded intensity values (e.g., CT hounsfield units) to opacity and the other maps recorded intensity values to color. Both can be adjusted by expanding **3D Rendering** in the Rendering tab. The graph at the top shows a histogram of the recorded intensity values in light gray and the current opacity transfer function as an overlaid curve in black. In that graph, beneath the black curve, is a depiction of the color transfer function. ![Cinematic](./assets/16-volview-rendering.jpg) 1. Begin by adjusting the opacity transfer function by a press-and-drag right/left on the top graph. ![Opacity](./assets/17-volview-opacity-notes.jpg) 2. Then adjust the color transfer function by moving the blue dots on the colorbar beneath the graph. ![Color](./assets/17-volview-colormap-notes.jpg) -2. You may decide that the colormaps and/or transfer function aren't suitable for your data. Click on the "Presets" bar to expand and show the available presets that offer alternative opacity and color transfer functions. ![Presets](./assets/18-volview-presets.jpg) +2. You may decide that the colormaps and/or transfer function aren't suitable for your data. Expand **Color Presets** within **3D Rendering** to show the available presets that offer alternative opacity and color transfer functions. ![Presets](./assets/18-volview-presets.jpg) [**_Watch the video!_**](https://youtu.be/eyrGd-meg6I) 3. Next, the lighting can be controlled. diff --git a/public/samples/PET-CT-chest-10-slices.README.md b/public/samples/PET-CT-chest-10-slices.README.md deleted file mode 100644 index b565f59a9..000000000 --- a/public/samples/PET-CT-chest-10-slices.README.md +++ /dev/null @@ -1,57 +0,0 @@ -# PET/CT chest sample - -This sample loads ten contiguous original CT instances and ten -contiguous original PET instances from public TCIA subject `Lung_Dx-A0171`, collection -[Lung-PET-CT-Dx](https://www.cancerimagingarchive.net/collection/lung-pet-ct-dx/). -The browser downloads 6.1 MB of original DICOM data directly from NBIA. The -DICOM file bytes, metadata, and acquired pixel grids are unchanged. This is a -partial study. - -The CT series is `CT WB 1.0 B30f` (512 x 512, 1 mm slice spacing), and the PET -series is `PET WB Corrected` (200 x 200, 3 mm slice spacing). Their slice-center -ranges are -373 through -364 mm and -383 through -356 mm, respectively. They -share a study and frame of reference. The initial axial view is at -368 mm, -with PET over CT in their native patient coordinates. The slabs have different -physical thicknesses; alignment applies to the overlapping region. Pixel -values have not been converted to SUV. - -The initial PET display uses the DICOM Hot Iron palette, image values 0 to 8000, 45% -opacity, and transparent background with a short fade above the minimum. - -## Attribution and license - -Li, P., Wang, S., Li, T., Lu, J., HuangFu, Y., & Wang, D. (2020). -A Large-Scale CT and PET/CT Dataset for Lung Cancer Diagnosis (Lung-PET-CT-Dx) -[Data set]. The Cancer Imaging Archive. -https://doi.org/10.7937/TCIA.2020.NNC2-0461 - -The source DICOM images are licensed under -[Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/). -The collection page and NBIA series metadata identify this license. Preserve -this attribution and license link when redistributing this subset. - -## Reproduction - -Install `pydicom`, then run `python scripts/samples/create-pet-ct-sample.py`. -The script downloads the twenty selected original DICOM instances directly -from the public NBIA `getSingleImage` API, checks their pinned SHA-256 hashes, -and validates their study, frame of reference, orientation, pixel dimensions, -and consecutive slice positions. `--cache-dir` selects a download cache and -`--output-dir` selects the destination for the original DICOM files and this README. -The default destination is the ignored `.tmp/samples` directory. - -The pinned records in `scripts/samples/pet-ct-sources.json` contain every SOP -Instance UID, series UID, original instance number, slice coordinate, byte -count, and SHA-256 hash. The sample data entry lists the twenty public source URLs. Reproduction -verifies the source hashes; opening the sample downloads those URLs and loads -them together as ordinary DICOM files. The application sets the initial CT -view and PET overlay display. - -CT Series Instance UID: -`1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885` - -PET Series Instance UID: -`1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839` - -NBIA API documentation: -https://wiki.cancerimagingarchive.net/display/Public/NBIA+Search+REST+API+Guide diff --git a/scripts/samples/create-pet-ct-sample.py b/scripts/samples/create-pet-ct-sample.py deleted file mode 100644 index a849df4d0..000000000 --- a/scripts/samples/create-pet-ct-sample.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -"""Build the PET/CT sample from original public TCIA DICOM instances. - -Requires pydicom. Run from any directory; downloads are cached outside the -repository by default. Every cached or downloaded file must match its SHA-256. -""" -import argparse -from concurrent.futures import ThreadPoolExecutor -import hashlib -import io -import json -from pathlib import Path -import tempfile -from urllib.parse import urlencode -from urllib.request import urlopen - -import pydicom - -SOURCES = json.loads(Path(__file__).with_name('pet-ct-sources.json').read_text()) -API = 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage' -STUDY = '1.3.6.1.4.1.14519.5.2.1.6655.2359.853320292336090422808460060188' -README = """# PET/CT chest sample - -This sample loads ten contiguous original CT instances and ten -contiguous original PET instances from public TCIA subject `Lung_Dx-A0171`, collection -[Lung-PET-CT-Dx](https://www.cancerimagingarchive.net/collection/lung-pet-ct-dx/). -The browser downloads 6.1 MB of original DICOM data directly from NBIA. The -DICOM file bytes, metadata, and acquired pixel grids are unchanged. This is a -partial study. - -The CT series is `CT WB 1.0 B30f` (512 x 512, 1 mm slice spacing), and the PET -series is `PET WB Corrected` (200 x 200, 3 mm slice spacing). Their slice-center -ranges are -373 through -364 mm and -383 through -356 mm, respectively. They -share a study and frame of reference. The initial axial view is at -368 mm, -with PET over CT in their native patient coordinates. The slabs have different -physical thicknesses; alignment applies to the overlapping region. Pixel -values have not been converted to SUV. - -The initial PET display uses the DICOM Hot Iron palette, image values 0 to 8000, 45% -opacity, and transparent background with a short fade above the minimum. - -## Attribution and license - -Li, P., Wang, S., Li, T., Lu, J., HuangFu, Y., & Wang, D. (2020). -A Large-Scale CT and PET/CT Dataset for Lung Cancer Diagnosis (Lung-PET-CT-Dx) -[Data set]. The Cancer Imaging Archive. -https://doi.org/10.7937/TCIA.2020.NNC2-0461 - -The source DICOM images are licensed under -[Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/). -The collection page and NBIA series metadata identify this license. Preserve -this attribution and license link when redistributing this subset. - -## Reproduction - -Install `pydicom`, then run `python scripts/samples/create-pet-ct-sample.py`. -The script downloads the twenty selected original DICOM instances directly -from the public NBIA `getSingleImage` API, checks their pinned SHA-256 hashes, -and validates their study, frame of reference, orientation, pixel dimensions, -and consecutive slice positions. `--cache-dir` selects a download cache and -`--output-dir` selects the destination for the original DICOM files and this README. -The default destination is the ignored `.tmp/samples` directory. - -The pinned records in `scripts/samples/pet-ct-sources.json` contain every SOP -Instance UID, series UID, original instance number, slice coordinate, byte -count, and SHA-256 hash. The sample data entry lists the twenty public source URLs. Reproduction -verifies the source hashes; opening the sample downloads those URLs and loads -them together as ordinary DICOM files. The application sets the initial CT -view and PET overlay display. - -CT Series Instance UID: -`1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885` - -PET Series Instance UID: -`1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839` - -NBIA API documentation: -https://wiki.cancerimagingarchive.net/display/Public/NBIA+Search+REST+API+Guide -""" - - -def source_url(source): - return API + '?' + urlencode({ - 'SeriesInstanceUID': source['series'], - 'SOPInstanceUID': source['sop'], - }) - - -def fetch(source, cache): - path = cache / source['path'] - if path.exists(): - data = path.read_bytes() - else: - with urlopen(source_url(source), timeout=90) as response: - data = response.read() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(data) - if hashlib.sha256(data).hexdigest() != source['sha256']: - raise ValueError(f"SHA-256 mismatch: {source['path']}") - return data - - -def validate(payloads): - datasets = [pydicom.dcmread(io.BytesIO(data)) for data in payloads] - if {str(ds.StudyInstanceUID) for ds in datasets} != {STUDY}: - raise ValueError('Unexpected study') - if len({str(ds.FrameOfReferenceUID) for ds in datasets}) != 1: - raise ValueError('Frames of reference differ') - if {tuple(float(v) for v in ds.ImageOrientationPatient) for ds in datasets} != {(1, 0, 0, 0, 1, 0)}: - raise ValueError('Unexpected orientation') - for source, ds in zip(SOURCES, datasets): - if (str(ds.SOPInstanceUID), str(ds.SeriesInstanceUID), str(ds.Modality), - int(ds.InstanceNumber), float(ds.ImagePositionPatient[2]), str(ds.PatientID)) != ( - source['sop'], source['series'], source['modality'], source['instance'], - source['z'], 'Lung_Dx-A0171'): - raise ValueError(f"Unexpected metadata: {source['path']}") - for modality, size, spacing in [('CT', 512, 1), ('PT', 200, 3)]: - series = [ds for ds in datasets if ds.Modality == modality] - positions = [float(ds.ImagePositionPatient[2]) for ds in series] - if len(series) != 10 or any(ds.Rows != size or ds.Columns != size for ds in series): - raise ValueError(f'Unexpected dimensions: {modality}') - if any(abs(b - a - spacing) > 1e-6 for a, b in zip(positions, positions[1:])): - raise ValueError(f'Noncontiguous slices: {modality}') - if len({tuple(float(v) for v in ds.PixelSpacing) for ds in series}) != 1: - raise ValueError(f'Inconsistent pixel spacing: {modality}') - if len({tuple(float(v) for v in ds.ImagePositionPatient[:2]) for ds in series}) != 1: - raise ValueError(f'Inconsistent in-plane origin: {modality}') - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--cache-dir', type=Path, default=Path(tempfile.gettempdir()) / 'pet-ct-official') - parser.add_argument('--output-dir', type=Path, default=Path(__file__).resolve().parents[2] / '.tmp' / 'samples') - args = parser.parse_args() - with ThreadPoolExecutor(max_workers=4) as pool: - payloads = list(pool.map(lambda source: fetch(source, args.cache_dir), SOURCES)) - validate(payloads) - args.output_dir.mkdir(parents=True, exist_ok=True) - for source, data in zip(SOURCES, payloads): - output = args.output_dir / source['path'] - output.parent.mkdir(parents=True, exist_ok=True) - output.write_bytes(data) - (args.output_dir / 'PET-CT-chest-10-slices.README.md').write_text(README) - print(f'{args.output_dir}: {sum(len(data) for data in payloads)} DICOM bytes') - - -if __name__ == '__main__': - main() diff --git a/scripts/samples/pet-ct-sources.json b/src/samples/pet-ct-sources.json similarity index 100% rename from scripts/samples/pet-ct-sources.json rename to src/samples/pet-ct-sources.json diff --git a/src/samples/petCT.ts b/src/samples/petCT.ts index 7038c4b02..fffefdce4 100644 --- a/src/samples/petCT.ts +++ b/src/samples/petCT.ts @@ -1,11 +1,13 @@ -import sources from '../../scripts/samples/pet-ct-sources.json'; +import sources from './pet-ct-sources.json' with { type: 'json' }; -export const PET_CT_URLS = sources.map( - (source) => - `https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?${new URLSearchParams( - { - SeriesInstanceUID: source.series, - SOPInstanceUID: source.sop, - } - )}` -); +export const PET_CT_SOURCES = sources.map((source) => ({ + ...source, + url: `https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?${new URLSearchParams( + { + SeriesInstanceUID: source.series, + SOPInstanceUID: source.sop, + } + )}`, +})); + +export const PET_CT_URLS = PET_CT_SOURCES.map(({ url }) => url); diff --git a/tests/datasets.ts b/tests/datasets.ts index 31ddba50d..0d4ff1bd2 100644 --- a/tests/datasets.ts +++ b/tests/datasets.ts @@ -1,8 +1,9 @@ -import petCtSources from '../scripts/samples/pet-ct-sources.json' with { type: 'json' }; +import { PET_CT_SOURCES } from '../src/samples/petCT.ts'; // Every file the suite fetches from the internet. The runner downloads them // into the dataset cache before any spec starts, and specs read them from the -// local server at `/tmp/`. CI keys its dataset cache on this file. +// local server at `/tmp/`. CI hashes this manifest and the PET source +// definitions for its dataset cache. // // Release fixtures and original public DICOM instances are pinned by SHA-256. export const DATASET_RELEASE = @@ -110,19 +111,14 @@ export const { CT_ELECTRODES, } = DATASETS; -export const PET_CT_DICOM_DATASETS = petCtSources.map((source) => { - const query = new URLSearchParams({ - SeriesInstanceUID: source.series, - SOPInstanceUID: source.sop, - }); - const url = `https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?${query}`; - return { - name: `PET-CT-${source.path.replace('/', '-')}`, +export const PET_CT_DICOM_DATASETS = PET_CT_SOURCES.map( + ({ path, url, sha256 }) => ({ + name: `PET-CT-${path.replace('/', '-')}`, origin: url, url, - sha256: source.sha256, - }; -}); + sha256, + }) +); // Everything declared above is downloaded. export const TEST_DATASETS = [ From 638abf007dc5a3decdf2bdb5e8e06f2eec319e73 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 18 Oct 2026 19:24:59 -0400 Subject: [PATCH 05/13] fix: honor consistent DICOM windows for PET overlays --- src/components/SampleDataBrowser.vue | 6 +- src/config.ts | 12 +- src/core/dicomTags.ts | 1 + .../showSampleOverlay.ts} | 11 +- src/store/view-configs/layers.ts | 18 +- src/types/index.ts | 12 +- src/utils/__tests__/dicomWindowRange.spec.ts | 97 ++++++++ src/utils/dicomWindowRange.ts | 56 +++++ tests/specs/pet-ct-sample.e2e.ts | 221 ++++++++++++++---- 9 files changed, 353 insertions(+), 81 deletions(-) rename src/{utils/sampleData.ts => samples/showSampleOverlay.ts} (74%) create mode 100644 src/utils/__tests__/dicomWindowRange.spec.ts create mode 100644 src/utils/dicomWindowRange.ts diff --git a/src/components/SampleDataBrowser.vue b/src/components/SampleDataBrowser.vue index 1967c1e54..48f1c5c0e 100644 --- a/src/components/SampleDataBrowser.vue +++ b/src/components/SampleDataBrowser.vue @@ -14,7 +14,7 @@ import { useImageStore } from '@/src/store/datasets-images'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { fetchFile } from '@/src/utils/fetch'; import { useViewStore } from '@/src/store/views'; -import { applySampleDefaults } from '@/src/utils/sampleData'; +import { showSampleOverlay } from '@/src/samples/showSampleOverlay'; export default defineComponent({ components: { @@ -100,8 +100,8 @@ export default defineComponent({ if (!selections.length) throw new Error('Did not receive a load result'); - if (sample.defaults?.overlay) { - await applySampleDefaults(selections, sample.defaults.overlay); + if (sample.overlay) { + await showSampleOverlay(selections, sample.overlay); } else { const [selection] = selections; useVolumeColoringStore().setDefaults(selection, { diff --git a/src/config.ts b/src/config.ts index c82cb6b53..2fe68fc2f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -152,15 +152,9 @@ export const SAMPLE_DATA: SampleDataset[] = [ description: 'PET layered over chest CT. 6 MB', urls: PET_CT_URLS, image: PETCTChestThumbnail, - defaults: { - overlay: { - baseModality: 'CT', - modality: 'PT', - preset: DEFAULT_LAYER_PRESET, - mappingRange: [0, 8000], - opacity: 0.45, - transparentBackground: true, - }, + overlay: { + baseModality: 'CT', + modality: 'PT', }, }, ]; diff --git a/src/core/dicomTags.ts b/src/core/dicomTags.ts index fe2e13cc7..d042b06ea 100644 --- a/src/core/dicomTags.ts +++ b/src/core/dicomTags.ts @@ -21,6 +21,7 @@ const tags: Tag[] = [ { name: 'SeriesDescription', tag: '0008|103e' }, { name: 'WindowLevel', tag: '0028|1050' }, { name: 'WindowWidth', tag: '0028|1051' }, + { name: 'VOILUTFunction', tag: '0028|1056' }, { name: 'Rows', tag: '0028|0010' }, { name: 'Columns', tag: '0028|0011' }, { name: 'BitsAllocated', tag: '0028|0100' }, diff --git a/src/utils/sampleData.ts b/src/samples/showSampleOverlay.ts similarity index 74% rename from src/utils/sampleData.ts rename to src/samples/showSampleOverlay.ts index a59b53c69..42e0e1c0b 100644 --- a/src/utils/sampleData.ts +++ b/src/samples/showSampleOverlay.ts @@ -1,14 +1,13 @@ import type { SampleDataset } from '@/src/types'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { useLayersStore } from '@/src/store/datasets-layers'; -import { useLayerColoringStore } from '@/src/store/view-configs/layers'; import { useViewStore } from '@/src/store/views'; import { useViewSliceStore } from '@/src/store/view-configs/slicing'; import { untilLoaded } from '@/src/composables/untilLoaded'; -export async function applySampleDefaults( +export async function showSampleOverlay( selections: string[], - overlay: NonNullable['overlay']> + overlay: NonNullable ) { const dicomStore = useDICOMStore(); const byModality = (modality: string) => @@ -20,12 +19,6 @@ export async function applySampleDefaults( await Promise.all([untilLoaded(base), untilLoaded(source)]); const layerID = await useLayersStore().addLayer(base, source); if (!layerID) throw new Error('Sample overlay could not be loaded'); - useLayerColoringStore().updateDisplayConfig(layerID, { - preset: overlay.preset, - mappingRange: overlay.mappingRange, - opacity: overlay.opacity, - transparentBackground: overlay.transparentBackground, - }); const views = useViewStore(); views.setDataForAllViews(base); views.viewIDs.forEach((viewID) => { diff --git a/src/store/view-configs/layers.ts b/src/store/view-configs/layers.ts index cd6445b1c..b032b1b71 100644 --- a/src/store/view-configs/layers.ts +++ b/src/store/view-configs/layers.ts @@ -18,6 +18,8 @@ import { LAYER_PRESET_BY_MODALITY, LAYER_PRESET_DEFAULT } from '@/src/config'; import { resolveLayerColorPreset } from '@/src/vtk/LayerColorPresets'; import { isDicomImage } from '@/src/utils/dataSelection'; import { useImageCacheStore } from '@/src/store/image-cache'; +import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; +import { getConsistentDicomWindowRange } from '@/src/utils/dicomWindowRange'; import { StateFile, ViewConfig } from '../../io/state-file/schema'; import { LayerDisplayConfig, LayersConfig } from './types'; import { useLayersStore } from '../datasets-layers'; @@ -117,10 +119,20 @@ export const useLayerColoringStore = defineStore('layerColoring', () => { const imageDataRange = image.getPointData().getScalars().getRange(); + const layer = useLayersStore().getLayer(dataID); + const source = layer && imageCacheStore.imageById[layer.selection]; + const isPET = + source instanceof DicomChunkImage && source.getModality() === 'PT'; + const dicomRange = + isPET && image.getPointData().getScalars().getNumberOfComponents() === 1 + ? getConsistentDicomWindowRange( + source.getChunks().map((chunk) => chunk.metadata) + ) + : undefined; const ctRange = getColorFunctionRangeFromPreset(preset); const ctFunc: Partial = { preset, - mappingRange: ctRange || imageDataRange, + mappingRange: dicomRange ?? ctRange ?? imageDataRange, }; const opacityFunction = getOpacityFunctionFromPreset(preset); @@ -128,6 +140,10 @@ export const useLayerColoringStore = defineStore('layerColoring', () => { return { ...defaults, transferFunction: { ...defaults.transferFunction, ...ctFunc }, + blendConfig: { + ...defaults.blendConfig, + opacity: isPET ? 0.5 : defaults.blendConfig.opacity, + }, opacityFunction: opacityFunction.mode === vtkPiecewiseFunctionProxy.Mode.Points ? { ...opacityFunction, mappingRange: imageDataRange } diff --git a/src/types/index.ts b/src/types/index.ts index 8ca74708f..34189e419 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,14 +28,10 @@ export type SampleDataset = { image: string; defaults?: { colorPreset?: string; - overlay?: { - baseModality: string; - modality: string; - preset: string; - mappingRange: [number, number]; - opacity: number; - transparentBackground: boolean; - }; + }; + overlay?: { + baseModality: string; + modality: string; }; } & ({ url: string; filename: string } | { urls: string[] }); diff --git a/src/utils/__tests__/dicomWindowRange.spec.ts b/src/utils/__tests__/dicomWindowRange.spec.ts new file mode 100644 index 000000000..4481cbcd4 --- /dev/null +++ b/src/utils/__tests__/dicomWindowRange.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { Tags } from '@/src/core/dicomTags'; +import { getConsistentDicomWindowRange } from '@/src/utils/dicomWindowRange'; + +function windowTags(center: string, width: string, voiFunction?: string) { + return Object.entries({ + [Tags.WindowLevel]: center, + [Tags.WindowWidth]: width, + ...(voiFunction === undefined + ? {} + : { [Tags.VOILUTFunction]: voiFunction }), + }); +} + +describe('DICOM window display range', () => { + it.each([ + ['unsigned identity', '2048', '4096', undefined, [0, 4095]], + ['signed window', '0', '100', 'LINEAR', [-50, 49]], + ['fractional exact window', '0.5', '1', 'LINEAR_EXACT', [0, 1]], + ['sub-unit exact width', '0', '0.25', 'LINEAR_EXACT', [-0.125, 0.125]], + ] as const)( + 'uses the standard boundaries for %s', + (_name, center, width, voiFunction, expected) => { + expect( + getConsistentDicomWindowRange([windowTags(center, width, voiFunction)]) + ).toEqual(expected); + } + ); + + it('compares parsed values across chunks and defaults an absent function to LINEAR', () => { + expect( + getConsistentDicomWindowRange([ + windowTags('1000', '2000'), + windowTags(' +1e3 ', '2.000E+3', ' LINEAR '), + windowTags('1000.0', '2000.', ''), + ]) + ).toEqual([0, 1999]); + }); + + it('chooses the first paired alternative without mixing centers and widths', () => { + expect( + getConsistentDicomWindowRange([ + windowTags('50\\500', '100\\1000'), + windowTags('50.0\\750', '1e2\\1500'), + ]) + ).toEqual([0, 99]); + }); + + it.each([ + ['no chunks', []], + ['missing first metadata', [undefined, windowTags('50', '100')]], + ['missing later metadata', [windowTags('50', '100'), null]], + ['missing center', [[[Tags.WindowWidth, '100']]]], + ['missing width', [[[Tags.WindowLevel, '50']]]], + ['different centers', [windowTags('50', '100'), windowTags('51', '100')]], + ['different widths', [windowTags('50', '100'), windowTags('50', '101')]], + [ + 'different functions', + [windowTags('50', '100'), windowTags('50', '100', 'LINEAR_EXACT')], + ], + ] as const)('rejects %s', (_name, metadata) => { + expect(getConsistentDicomWindowRange(metadata)).toBeUndefined(); + }); + + it.each([ + ['empty center', '', '100', undefined], + ['blank center', ' ', '100', undefined], + ['empty width', '50', '', undefined], + ['blank width', '50', ' ', undefined], + ['incomplete exponent', '1e-', '100', undefined], + ['trailing center text', '50abc', '100', undefined], + ['trailing width text', '50', '100abc', undefined], + ['hexadecimal center', '0x10', '100', undefined], + ['infinite center', 'Infinity', '100', undefined], + ['infinite width', '50', 'Infinity', undefined], + ['nonfinite decimal', '1e999', '100', undefined], + ['NaN center', 'NaN', '100', undefined], + ['zero width', '50', '0', undefined], + ['negative width', '50', '-100', undefined], + ['LINEAR threshold', '50', '1', undefined], + ['sub-unit LINEAR width', '50', '0.5', 'LINEAR'], + ['zero exact width', '50', '0', 'LINEAR_EXACT'], + ['SIGMOID function', '50', '100', 'SIGMOID'], + ['unknown function', '50', '100', 'CUSTOM'], + ['mismatched alternatives', '50\\500', '100', undefined], + ['blank alternative', '50\\', '100\\1000', undefined], + ['malformed alternative', '50\\bad', '100\\1000', undefined], + ['nonpositive alternative', '50\\500', '100\\0', undefined], + ['overflowing endpoint', '1.7e308', '1e308', 'LINEAR_EXACT'], + ['rounded equal endpoints', '1e20', '2', 'LINEAR_EXACT'], + ['underflowing half-width', '0', '5e-324', 'LINEAR_EXACT'], + ] as const)('rejects %s', (_name, center, width, voiFunction) => { + expect( + getConsistentDicomWindowRange([windowTags(center, width, voiFunction)]) + ).toBeUndefined(); + }); +}); diff --git a/src/utils/dicomWindowRange.ts b/src/utils/dicomWindowRange.ts new file mode 100644 index 000000000..e5cc03b6b --- /dev/null +++ b/src/utils/dicomWindowRange.ts @@ -0,0 +1,56 @@ +import { Tags } from '@/src/core/dicomTags'; + +type DicomMetadata = Iterable | null | undefined; + +const decimal = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; +const parseValues = (value?: string) => + value + ?.split('\\') + .map((token) => (decimal.test(token.trim()) ? Number(token) : NaN)) ?? []; + +function readWindow(metadata: DicomMetadata) { + if (!metadata) return undefined; + const tags = Object.fromEntries(metadata); + const centers = parseValues(tags[Tags.WindowLevel]); + const widths = parseValues(tags[Tags.WindowWidth]); + const voiFunction = + tags[Tags.VOILUTFunction]?.trim().toUpperCase() || 'LINEAR'; + if ( + !centers.length || + centers.length !== widths.length || + !centers.every(Number.isFinite) || + !widths.every((width) => Number.isFinite(width) && width > 0) || + !['LINEAR', 'LINEAR_EXACT'].includes(voiFunction) + ) + return undefined; + return { center: centers[0], width: widths[0], voiFunction }; +} + +export function getConsistentDicomWindowRange( + metadata: readonly DicomMetadata[] +): [number, number] | undefined { + const windows = metadata.map(readWindow); + const first = windows[0]; + if ( + !first || + !windows.every( + (window) => + window?.center === first.center && + window.width === first.width && + window.voiFunction === first.voiFunction + ) + ) + return undefined; + // DICOM LINEAR has half-value offsets; LINEAR_EXACT uses the full width. + const offset = first.voiFunction === 'LINEAR' ? 1 : 0; + const center = first.center - offset / 2; + const halfWidth = (first.width - offset) / 2; + const min = center - halfWidth; + const max = center + halfWidth; + return Number.isFinite(min) && + Number.isFinite(max) && + min < max && + Number.isFinite(max - min) + ? [min, max] + : undefined; +} diff --git a/tests/specs/pet-ct-sample.e2e.ts b/tests/specs/pet-ct-sample.e2e.ts index 65c55fa03..03ed20cbd 100644 --- a/tests/specs/pet-ct-sample.e2e.ts +++ b/tests/specs/pet-ct-sample.e2e.ts @@ -1,11 +1,15 @@ import * as path from 'path'; -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import dicomParser from 'dicom-parser'; import { TEMP_DIR } from '@/wdio.shared.conf'; import { PET_CT_DICOM_DATASETS } from '../datasets'; import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; import { capture2DViewScreenshotMetrics, + makeTempDir, + openVolViewPage, + SESSION_SAVE_TIMEOUT, + waitForDownload, selectLayout, waitForModuleTransitions, } from './utils'; @@ -14,8 +18,63 @@ async function captureAxialPixels() { return (await capture2DViewScreenshotMetrics('pet-ct-capture.png'))[0]; } +async function useLocalSampleFiles(files: { url: string; name: string }[]) { + await browser.addInitScript((datasets) => { + const localUrls = new Map( + datasets.map(({ url, name }) => [url, `/tmp/${name}`]) + ); + const originalFetch = window.fetch.bind(window); + window.fetch = (input, init) => { + const url = input instanceof Request ? input.url : String(input); + const local = localUrls.get(url); + if (!local) return originalFetch(input, init); + const mapped = + input instanceof Request ? new Request(local, input) : local; + return originalFetch(mapped, init); + }; + }, files); +} + +async function openAxialSample() { + await volViewPage.open(); + await selectLayout('Axial Only', 1, false); + const sample = volViewPage.samplesList.$('[title="PET/CT Chest"]'); + await sample.scrollIntoView(); + await sample.waitForClickable(); + await sample.click(); + await volViewPage.waitForViews(); +} + +async function displayedPetRange() { + return Promise.all( + ['min', 'max'].map( + async (endpoint) => + (await $(`[data-testid="layer-range-${endpoint}"] input`).getAttribute( + 'title' + ))! + ) + ); +} + +async function expectFullRangeDefault() { + const initial = await displayedPetRange(); + const contrast = $('[data-testid="layer-contrast"]'); + await contrast.scrollIntoView({ block: 'center', behavior: 'instant' }); + await contrast.click(); + const full = $('[data-testid="layer-contrast-FullRange"]'); + await full.waitForClickable(); + await full.click(); + await browser.waitUntil( + async () => !(await $('[data-testid="layer-contrast-menu"]').isDisplayed()) + ); + expect(await displayedPetRange()).toEqual(initial); + return initial; +} + async function reopenSample( - expected: Awaited> + expected: Awaited>, + range: string[], + preset = 'DICOM Hot Iron' ) { await $('[data-testid="module-tab-Data"]').click(); await waitForModuleTransitions(); @@ -48,18 +107,21 @@ async function reopenSample( await expect($$(`[data-testid="${control}"]`)).toBeElementsArrayOfSize(1); await expect($('[data-testid="layer-range-min"] input')).toHaveAttribute( 'title', - '0' + range[0] ); await expect($('[data-testid="layer-range-max"] input')).toHaveAttribute( 'title', - '8000' + range[1] + ); + await expect($('[data-testid="layer-colormap"]')).toHaveText( + expect.stringContaining(preset) ); await expect( $('[data-testid="layer-transparent-background"] input') ).toBeChecked(); await expect( $('[data-testid="layer-opacity-slider"] [role="slider"]') - ).toHaveAttribute('aria-valuenow', '0.45'); + ).toHaveAttribute('aria-valuenow', '0.5'); await $('header').moveTo(); await browser.waitUntil( async () => { @@ -72,30 +134,60 @@ async function reopenSample( }, { timeoutMsg: - 'Expected reopening the sample to restore exactly one PET overlay with its original appearance', + 'Expected reopening the sample to restore exactly one PET overlay with its expected appearance', + } + ); +} + +async function openPetMetadataVariant( + variant: 'valid' | 'different' | 'missing' | 'invalid' +) { + const directory = makeTempDir(`pet-window-${variant}`); + const petFiles = PET_CT_DICOM_DATASETS.filter(({ name }) => + name.startsWith('PET-CT-PT-') + ); + for (const [index, { name }] of petFiles.entries()) { + const bytes = Buffer.from(readFileSync(path.join(TEMP_DIR, name))); + const dataset = dicomParser.parseDicom(bytes); + for (const [tag, value] of [ + ['x00281050', '3000'], + ['x00281051', '6000'], + ]) { + const element = dataset.elements[tag]; + let replacement = value; + if (index === petFiles.length - 1 && tag === 'x00281051') { + if (variant === 'different') replacement = '7000'; + if (variant === 'invalid') replacement = '0'; + if (variant === 'missing') + bytes.writeUInt16LE(0x7777, element.dataOffset - 8); + } + expect(replacement.length).toBeLessThanOrEqual(element.length); + bytes.write( + replacement.padEnd(element.length, ' '), + element.dataOffset, + element.length, + 'ascii' + ); } + writeFileSync(path.join(directory, name), bytes); + } + await useLocalSampleFiles( + PET_CT_DICOM_DATASETS.map((file) => ({ + url: file.url, + name: file.name.startsWith('PET-CT-PT-') + ? `pet-window-${variant}/${file.name}` + : file.name, + })) ); + await openAxialSample(); + await volViewPage.renderingModuleTab.click(); + await $('[data-testid="layer-range-max"] input').waitForEnabled(); + await waitForModuleTransitions(); } describe('PET/CT sample', () => { before(async () => { - await browser.addInitScript( - (datasets) => { - const localUrls = new Map( - datasets.map(({ url, name }) => [url, `/tmp/${name}`]) - ); - const originalFetch = window.fetch.bind(window); - window.fetch = (input, init) => { - const url = input instanceof Request ? input.url : String(input); - const local = localUrls.get(url); - if (!local) return originalFetch(input, init); - const mapped = - input instanceof Request ? new Request(local, input) : local; - return originalFetch(mapped, init); - }; - }, - PET_CT_DICOM_DATASETS.map(({ url, name }) => ({ url, name })) - ); + await useLocalSampleFiles(PET_CT_DICOM_DATASETS); }); it('preserves Four Up when loading and reopening the sample', async () => { @@ -129,10 +221,11 @@ describe('PET/CT sample', () => { ); await volViewPage.renderingModuleTab.click(); await expect($('h4*=PET WB Corrected')).toBeDisplayed(); - await expect($('[data-testid="layer-range-max"] input')).toHaveAttribute( - 'title', - '8000' - ); + if (loading === 0) await expectFullRangeDefault(); + else + await expect( + $('[data-testid="layer-range-max"] input') + ).toHaveAttribute('title', '4000'); await volViewPage.waitForViewCounts(3, true); const slices = $$('[data-testid~="vtk-two-view"]'); for (const view of await slices) { @@ -159,13 +252,7 @@ describe('PET/CT sample', () => { }); it('keeps one PET overlay when reopening the sample and retains edited colors across layouts', async () => { - await volViewPage.open(); - await selectLayout('Axial Only', 1, false); - const sample = volViewPage.samplesList.$('[title="PET/CT Chest"]'); - await sample.scrollIntoView(); - await sample.waitForClickable(); - await sample.click(); - await volViewPage.waitForViews(); + await openAxialSample(); await volViewPage.waitForViewCounts(1, false); const view = $('[data-testid~="vtk-two-view"]'); await expect(view).toHaveText(expect.stringContaining('CT WB')); @@ -189,11 +276,10 @@ describe('PET/CT sample', () => { await expect($('h4*=PET')).toBeDisplayed(); await expect($('[data-testid="layer-range-min"] input')).toBeEnabled(); await expect($('[data-testid="layer-range-max"] input')).toBeEnabled(); - const minimum = await $('[data-testid="layer-range-min"] input').getValue(); - const maximum = await $('[data-testid="layer-range-max"] input').getValue(); - const opacity = '0.45'; + const [minimum, maximum] = await expectFullRangeDefault(); + const opacity = '0.5'; expect(minimum).toBe('0'); - expect(maximum).toBe('8000'); + expect(Number(maximum)).toBeGreaterThan(Number(minimum)); await expect( $('[data-testid="layer-opacity-slider"] [role="slider"]') ).toHaveAttribute('aria-valuenow', opacity); @@ -203,7 +289,7 @@ describe('PET/CT sample', () => { ); await expect($('[data-testid="layer-range-max"] input')).toHaveAttribute( 'title', - '8000' + maximum ); await expect( $('[data-testid="layer-transparent-background"] input') @@ -289,10 +375,12 @@ describe('PET/CT sample', () => { await selectLayout('3D Only', 0, true); await expect($('[data-testid="layer-range-max"] input')).toBeDisabled(); await selectLayout('Axial Only', 1, false); - await expect($('[data-testid="layer-range-min"] input')).toHaveValue( + await expect($('[data-testid="layer-range-min"] input')).toHaveAttribute( + 'title', minimum ); - await expect($('[data-testid="layer-range-max"] input')).toHaveValue( + await expect($('[data-testid="layer-range-max"] input')).toHaveAttribute( + 'title', narrowedMaximum ); await expect(palette).toHaveText(expect.stringContaining('Cool to Warm')); @@ -314,15 +402,7 @@ describe('PET/CT sample', () => { path.join(TEMP_DIR, 'pet-ct-sample-edited.png') ); for (let reopening = 0; reopening < 2; reopening++) { - await setValueVueInput( - $('[data-testid="layer-range-max"] input'), - narrowedMaximum - ); - await expect($('[data-testid="layer-range-max"] input')).toHaveAttribute( - 'title', - narrowedMaximum - ); - await reopenSample(initial); + await reopenSample(edited, [minimum, narrowedMaximum], 'Cool to Warm'); } await browser.saveScreenshot( path.join(TEMP_DIR, 'pet-ct-sample-reopened.png') @@ -340,7 +420,46 @@ describe('PET/CT sample', () => { await deleteItem.click(); await expect(petCard).not.toExist(); await expect($('.volume-card[html-title="CT WB 1.0 B30f"]')).toExist(); - await reopenSample(initial); + await reopenSample(initial, [minimum, maximum]); expect(await volViewPage.getNotificationsCount()).toBe(0); }); + + it('uses consistent PET DICOM windows and preserves an edited range when saving and reopening', async () => { + await openPetMetadataVariant('valid'); + await expect($('[data-testid="layer-range-min"] input')).toHaveAttribute( + 'title', + '0' + ); + await expect($('[data-testid="layer-range-max"] input')).toHaveAttribute( + 'title', + '5999' + ); + await setValueVueInput($('[data-testid="layer-range-min"] input'), '100'); + await setValueVueInput($('[data-testid="layer-range-max"] input'), '3000'); + await expect($('[data-testid="layer-range-max"] input')).toHaveAttribute( + 'title', + '3000' + ); + const opacity = () => + $('[data-testid="layer-opacity-slider"] [role="slider"]'); + await expect(opacity()).toHaveAttribute('aria-valuenow', '0.5'); + await opacity().execute((element) => element.focus()); + await browser.keys('ArrowLeft'); + await expect(opacity()).toHaveAttribute('aria-valuenow', '0.49'); + const saved = await volViewPage.saveSession(); + await waitForDownload(path.join(TEMP_DIR, saved), SESSION_SAVE_TIMEOUT); + await openVolViewPage(saved); + await volViewPage.renderingModuleTab.click(); + await $('[data-testid="layer-range-max"] input').waitForEnabled(); + expect(await displayedPetRange()).toEqual(['100', '3000']); + await expect(opacity()).toHaveAttribute('aria-valuenow', '0.49'); + }); + + it('falls back to the full overlay range when any PET slice has differing, missing, or invalid window metadata', async () => { + for (const variant of ['different', 'missing', 'invalid'] as const) { + await openPetMetadataVariant(variant); + const initial = await expectFullRangeDefault(); + expect(initial).not.toEqual(['0', '5999']); + } + }); }); From dece63a9c093237ac3d299bbf6e2cc28942cf121 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 19 Oct 2026 11:51:24 -0400 Subject: [PATCH 06/13] test: declare PET fixtures in the test dataset manifest --- .github/workflows/e2e-datasets.yml | 2 +- .github/workflows/e2e.yml | 2 +- tests/datasets.ts | 115 ++++++++++++++++++++++++++--- 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/.github/workflows/e2e-datasets.yml b/.github/workflows/e2e-datasets.yml index 5d87fd574..4d6f01351 100644 --- a/.github/workflows/e2e-datasets.yml +++ b/.github/workflows/e2e-datasets.yml @@ -22,7 +22,7 @@ jobs: uses: actions/cache@v4 with: path: .tmp/datasets - key: e2e-datasets-${{ hashFiles('tests/datasets.ts', 'src/samples/petCT.ts', 'src/samples/pet-ct-sources.json') }} + key: e2e-datasets-${{ hashFiles('tests/datasets.ts') }} restore-keys: e2e-datasets- - name: Download missing datasets run: node tests/downloadDatasets.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2930ae4a7..f540f0e1b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,7 +33,7 @@ jobs: uses: actions/cache@v4 with: path: .tmp/datasets - key: e2e-datasets-${{ hashFiles('tests/datasets.ts', 'src/samples/petCT.ts', 'src/samples/pet-ct-sources.json') }} + key: e2e-datasets-${{ hashFiles('tests/datasets.ts') }} restore-keys: e2e-datasets- - name: Install xvfb (linux) if: matrix.os == 'ubuntu-latest' diff --git a/tests/datasets.ts b/tests/datasets.ts index 0d4ff1bd2..9bbb8bddf 100644 --- a/tests/datasets.ts +++ b/tests/datasets.ts @@ -1,9 +1,6 @@ -import { PET_CT_SOURCES } from '../src/samples/petCT.ts'; - // Every file the suite fetches from the internet. The runner downloads them // into the dataset cache before any spec starts, and specs read them from the -// local server at `/tmp/`. CI hashes this manifest and the PET source -// definitions for its dataset cache. +// local server at `/tmp/`. CI keys its dataset cache on this file. // // Release fixtures and original public DICOM instances are pinned by SHA-256. export const DATASET_RELEASE = @@ -111,14 +108,108 @@ export const { CT_ELECTRODES, } = DATASETS; -export const PET_CT_DICOM_DATASETS = PET_CT_SOURCES.map( - ({ path, url, sha256 }) => ({ - name: `PET-CT-${path.replace('/', '-')}`, - origin: url, - url, - sha256, - }) -); +export const PET_CT_DICOM_DATASETS = [ + { + name: 'PET-CT-PT-01.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.125971577518855152164687925479', + sha256: '082cc2a92409d26dcd465ca37dcb55182f526fa94f0d66299c93196f0dd2b130', + }, + { + name: 'PET-CT-PT-02.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.164470072637482892742730606415', + sha256: '7fc5a40cc7645c64c91df14008e27c8f20f6b613af8f888125d72c6ae6f153d6', + }, + { + name: 'PET-CT-PT-03.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.645656839545863315258382059304', + sha256: 'b4922f66ed2d02e1783c32d23e3bd8aa73cc30f648784de91316890c1a8faaf6', + }, + { + name: 'PET-CT-PT-04.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.171033599854331402388163035916', + sha256: 'ae96aa74535276c86e9e6f06be540b446f349d58e376cd6812832bbb183fed0b', + }, + { + name: 'PET-CT-PT-05.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.757229251338810950547930294076', + sha256: '71d7ea70e1527dcaac130ec4794748710961d0d18b11dc8a0dda3d96c7e1ddea', + }, + { + name: 'PET-CT-PT-06.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.290313076131916328810257384513', + sha256: '4c9f3fac5d2710d72c46ed63f97aed8052245380967efa3f1f4ce72aeb488fa7', + }, + { + name: 'PET-CT-PT-07.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.167034157012705076112347098246', + sha256: 'c5398c842bb35f7c7b7d77398d2246003b4565474961c622820545ab1a42d7dc', + }, + { + name: 'PET-CT-PT-08.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.305525101475115108798705213666', + sha256: '65115e0a3335074976e2178e52e5036bfe4201d3b315a79f733cb470729c220d', + }, + { + name: 'PET-CT-PT-09.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.180547818912813778100247492512', + sha256: '8e0316240d5e8cc3c7e0913b4abb892be1be5879b5cc3f0379a205fd286ca17b', + }, + { + name: 'PET-CT-PT-10.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.316521143696464534574699049839&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.190211187176681563808826571096', + sha256: 'fb424c9c3670d0f0374361ecb16bf342e27cdfcf8be0aead1e4629ea7a2e7928', + }, + { + name: 'PET-CT-CT-01.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.330442471593744252750634668562', + sha256: '262fc85a0a05c57ec96451baf8a1b64f3287d5d6efdf5e4d9e67983e20e59fe2', + }, + { + name: 'PET-CT-CT-02.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.787146087385319992976365569088', + sha256: 'c348f6f8d899ce9e02a4b2fc3f93743491df1a04c99f65c1c77ab1c8baec8cb7', + }, + { + name: 'PET-CT-CT-03.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.754367593034585495537680981967', + sha256: '4935a37d77059028d175a38c515e52beb2e90d0971c9b495ab956433c8e7fa18', + }, + { + name: 'PET-CT-CT-04.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.268694914226381205284654198517', + sha256: '27cff9c1bd6d1fc6543bb8d2a009e232c4f39b6aa333b9d3ce39d7cdeddf0cc3', + }, + { + name: 'PET-CT-CT-05.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.273002091516249828143197374115', + sha256: '038be9136459b5d9d5dbd44b7d35394012a0415f5ed09b2ba46ca34aef1c1f96', + }, + { + name: 'PET-CT-CT-06.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.251520880093056497198942071424', + sha256: 'feceb609cda89e382459a1c6884e5737e5f00b378de19c33e3f9838474f418bd', + }, + { + name: 'PET-CT-CT-07.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.254151550624375545322457061626', + sha256: 'a416fb025436c0d0bcdedb9e36b51a4ac744ac332b83ce8c94da7b459188e39b', + }, + { + name: 'PET-CT-CT-08.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.477776731601464757864806040291', + sha256: '6b8db167b9ddbf9194d9935b94092668d2b7eb6c576dce5cc3f96046046fb6fc', + }, + { + name: 'PET-CT-CT-09.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.831869199671510110705249657284', + sha256: '31df0464f111f0cccd1cba25f2e014f88375de44bc0ba7ead844a015508f5f43', + }, + { + name: 'PET-CT-CT-10.dcm', + url: 'https://services.cancerimagingarchive.net/nbia-api/services/v1/getSingleImage?SeriesInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.114189455733462211540942355885&SOPInstanceUID=1.3.6.1.4.1.14519.5.2.1.6655.2359.243468898389109491372438016818', + sha256: 'c2470a67fd601d7e36130bebd51e8e71b9f6fad9832ea6b79f504309f2fee984', + }, +]; // Everything declared above is downloaded. export const TEST_DATASETS = [ From 84f0a70f0f8bfa6bf88aec7818ae39ae97befaf6 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 19 Oct 2026 13:57:57 -0400 Subject: [PATCH 07/13] fix: preserve precision in display range controls --- src/components/ColorMapRangeControl.vue | 19 ++++------ src/components/DisplayRangeControl.vue | 9 ++++- .../tools/windowing/WindowLevelControls.vue | 22 ++++++----- src/utils/adjacentNumber.ts | 9 +++++ tests/specs/range-number-steppers.e2e.ts | 37 +++++++++++++++++++ 5 files changed, 74 insertions(+), 22 deletions(-) create mode 100644 src/utils/adjacentNumber.ts diff --git a/src/components/ColorMapRangeControl.vue b/src/components/ColorMapRangeControl.vue index 55109cbba..a2148e79f 100644 --- a/src/components/ColorMapRangeControl.vue +++ b/src/components/ColorMapRangeControl.vue @@ -1,5 +1,6 @@