From 720a81fdc586501d0f17a02b3da4249f750d842f Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 7 Nov 2025 14:18:16 -0500 Subject: [PATCH 1/3] feat: add config URL parameter to load configs before data Implements ?config= URL parameter to ensure configuration files are loaded before data files, eliminating race conditions when both are specified. Fixes #817 --- docs/configuration_file.md | 8 ++++++++ src/actions/loadUserFiles.ts | 30 ++++++++++++++++++----------- src/components/App.vue | 4 ---- tests/specs/configTestUtils.ts | 27 +++++--------------------- tests/specs/layout-config.e2e.ts | 12 ++++++------ tests/specs/windowing-config.e2e.ts | 4 ++-- 6 files changed, 40 insertions(+), 45 deletions(-) diff --git a/docs/configuration_file.md b/docs/configuration_file.md index 5912ee219..70485e427 100644 --- a/docs/configuration_file.md +++ b/docs/configuration_file.md @@ -8,6 +8,14 @@ By loading a JSON file, you can set VolView's configuration: - Visibility of Sample Data section - Keyboard shortcuts +## Loading Configuration Files + +Use the `config` URL parameter to load configuration before data files: + +``` +https://volview.kitware.com/?config=https://example.com/config.json&urls=https://example.com/data.nrrd +``` + ## View Layouts Define one or more named layouts using the `layouts` key. VolView will use the first layout as the default. Each named layout will be in the layout selector menu. Layout are specified in three formats: diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index 1e175c597..0e89d9f92 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -323,17 +323,25 @@ export async function loadUserPromptedFiles() { return loadFiles(files); } +function urlsToDataSources(urls: string[], names: string[] = []): DataSource[] { + return urls.map((url, idx) => { + const defaultName = + basename(parseUrl(url, window.location.href).pathname) || url; + return uriToDataSource(url, names[idx] || defaultName); + }); +} + export async function loadUrls(params: UrlParams) { - const urls = wrapInArray(params.urls); - const names = wrapInArray(params.names ?? []); // optional names should resolve to [] if params.names === undefined - const sources = urls.map((url, idx) => - uriToDataSource( - url, - names[idx] || - basename(parseUrl(url, window.location.href).pathname) || - url - ) - ); + if (params.config) { + const configUrls = wrapInArray(params.config); + const configSources = urlsToDataSources(configUrls); + await loadDataSources(configSources); + } - return loadDataSources(sources); + if (params.urls) { + const urls = wrapInArray(params.urls); + const names = wrapInArray(params.names ?? []); + const sources = urlsToDataSources(urls, names); + await loadDataSources(sources); + } } diff --git a/src/components/App.vue b/src/components/App.vue index 898eb9105..206417c51 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -149,10 +149,6 @@ export default defineComponent({ const urlParams = vtkURLExtract.extractURLParameters() as UrlParams; onMounted(() => { - if (!urlParams.urls) { - return; - } - loadUrls(urlParams); }); diff --git a/tests/specs/configTestUtils.ts b/tests/specs/configTestUtils.ts index 245de7b1f..5ab53701a 100644 --- a/tests/specs/configTestUtils.ts +++ b/tests/specs/configTestUtils.ts @@ -31,35 +31,18 @@ export type DatasetResource = { name?: string; }; -export const createConfigManifest = async ( +export const openConfigAndDataset = async ( config: unknown, name: string, dataset: DatasetResource = ONE_CT_SLICE_DICOM ) => { const configFileName = `${name}-config.json`; - const manifestFileName = `${name}-manifest.json`; - await writeManifestToFile(config, configFileName); - const manifest = { - resources: [{ url: `/tmp/${configFileName}` }, dataset], - }; - - await writeManifestToFile(manifest, manifestFileName); - return manifestFileName; -}; - -export const openConfigAndWait = async ( - config: unknown, - name: string, - dataset: DatasetResource = ONE_CT_SLICE_DICOM -) => { - const manifestFileNameOnDisk = await createConfigManifest( - config, - name, - dataset + await volViewPage.open( + `?config=[tmp/${configFileName}]&urls=${dataset.url}&names=${ + dataset.name ?? '' + }` ); - - await volViewPage.open(`?urls=[tmp/${manifestFileNameOnDisk}]`); await volViewPage.waitForViews(); }; diff --git a/tests/specs/layout-config.e2e.ts b/tests/specs/layout-config.e2e.ts index 94ca270ee..a158183db 100644 --- a/tests/specs/layout-config.e2e.ts +++ b/tests/specs/layout-config.e2e.ts @@ -1,5 +1,5 @@ import { volViewPage } from '../pageobjects/volview.page'; -import { PROSTATEX_DATASET, openConfigAndWait } from './configTestUtils'; +import { PROSTATEX_DATASET, openConfigAndDataset } from './configTestUtils'; describe('VolView Layout Configuration', () => { it('should create a 2x2 grid layout from simple string array', async () => { @@ -12,7 +12,7 @@ describe('VolView Layout Configuration', () => { }, }; - await openConfigAndWait(config, 'layout-grid'); + await openConfigAndDataset(config, 'layout-grid'); await volViewPage.waitForViewCounts(3, true); }); @@ -33,7 +33,7 @@ describe('VolView Layout Configuration', () => { }, }; - await openConfigAndWait(config, 'layout-nested'); + await openConfigAndDataset(config, 'layout-nested'); await volViewPage.waitForViewCounts(3, true); }); @@ -68,7 +68,7 @@ describe('VolView Layout Configuration', () => { }, }; - await openConfigAndWait(config, 'layout-custom-views'); + await openConfigAndDataset(config, 'layout-custom-views'); await volViewPage.waitForViewCounts(2, true); }); @@ -85,7 +85,7 @@ describe('VolView Layout Configuration', () => { }, }; - await openConfigAndWait(config, 'multiple-layouts', PROSTATEX_DATASET); + await openConfigAndDataset(config, 'multiple-layouts', PROSTATEX_DATASET); await volViewPage.waitForViewCounts(4, false); @@ -129,7 +129,7 @@ describe('VolView Layout Configuration', () => { disabledViewTypes: ['3D', 'Oblique'], }; - await openConfigAndWait(config, 'disabled-view-types'); + await openConfigAndDataset(config, 'disabled-view-types'); await volViewPage.waitForViewCounts(4, false); diff --git a/tests/specs/windowing-config.e2e.ts b/tests/specs/windowing-config.e2e.ts index 58c1c03d9..382e36361 100644 --- a/tests/specs/windowing-config.e2e.ts +++ b/tests/specs/windowing-config.e2e.ts @@ -1,7 +1,7 @@ import { volViewPage } from '../pageobjects/volview.page'; import { openUrls } from './utils'; import { - openConfigAndWait, + openConfigAndDataset, ONE_CT_SLICE_DICOM, MINIMAL_DICOM, } from './configTestUtils'; @@ -17,7 +17,7 @@ describe('VolView windowing configuration', () => { windowing: runtimeWindowLevel, }; - await openConfigAndWait(config, 'windowing'); + await openConfigAndDataset(config, 'windowing'); const view = await $('div[data-testid="vtk-view vtk-two-view"]'); From 09716a197cfbf9604667b10394d34991cbd0bd77 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 7 Nov 2025 15:55:00 -0500 Subject: [PATCH 2/3] feat: add automatic layering by file name for non-DICOM images Implements automatic layering based on file name patterns. When loading files with matching names like base-image.nii and base-image.layer.nii, the layer file is automatically placed on top of the base image. --- docs/configuration_file.md | 30 +++++++++++---- src/actions/loadUserFiles.ts | 53 ++++++++++++++++++++++----- src/io/import/configJson.ts | 5 ++- src/store/load-data.ts | 2 + tests/specs/automatic-layering.e2e.ts | 36 ++++++++++++++++++ tests/specs/configTestUtils.ts | 5 +++ 6 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 tests/specs/automatic-layering.e2e.ts diff --git a/docs/configuration_file.md b/docs/configuration_file.md index 70485e427..0f094c738 100644 --- a/docs/configuration_file.md +++ b/docs/configuration_file.md @@ -181,15 +181,15 @@ Working segment group file formats: hdf5, iwi.cbor, mha, nii, nii.gz, nrrd, vtk -## Automatic Segment Groups by File Name +## Automatic Layers and Segment Groups by File Name -When loading files, VolView can automatically convert images to segment groups -if they follow a naming convention. For example, an image with name like `foo.segmentation.bar` -will be converted to a segment group for a base image named like `foo.baz`. -The `segmentation` extension is defined by the `io.segmentGroupExtension` key, which takes a -string. Files `foo.[segmentGroupExtension].bar` will be automatilly converted to segment groups for a base image named `foo.baz`. The default is `''` and will disable the feature. +When loading multiple files, VolView can automatically associate related images based on file naming patterns. +Files matching `base.[extension].format` will be associated with a base image named `base.format`. +Both features default to `''` which disables them. -This will define `myFile.seg.nrrd` as a segment group for a `myFile.nii` base file. +### Segment Groups + +Use `segmentGroupExtension` to automatically convert matching non-DICOM images to segment groups. For example, `myFile.seg.nrrd` becomes a segment group for `myFile.nii`: ```json { @@ -199,6 +199,18 @@ This will define `myFile.seg.nrrd` as a segment group for a `myFile.nii` base fi } ``` +### Layering + +Use `layerExtension` to automatically layer matching non-DICOM images on top of the base image. For example, `myImage.layer.nii` is layered on top of `myImage.nii`: + +```json +{ + "io": { + "layerExtension": "layer" + } +} +``` + ## Keyboard Shortcuts Configure the keys to activate tools, change selected labels, and more. @@ -285,7 +297,9 @@ To configure a key for an action, add its action name and the key(s) under the ` "showKeyboardShortcuts": "t" }, "io": { - "segmentGroupSaveFormat": "nrrd" + "segmentGroupSaveFormat": "nrrd", + "segmentGroupExtension": "seg", + "layerExtension": "layer" } } ``` diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index 0e89d9f92..254889184 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -80,17 +80,21 @@ function isSegmentation(extension: string, name: string) { return extensions.includes(extension); } -// does not pick segmentation images +// does not pick segmentation or layer images function findBaseImage( loadableDataSources: Array, - segmentGroupExtension: string + segmentGroupExtension: string, + layerExtension: string ) { const baseImages = loadableDataSources .filter(({ dataType }) => dataType === 'image') .filter((importResult) => { const name = getDataSourceName(importResult.dataSource); if (!name) return false; - return !isSegmentation(segmentGroupExtension, name); + return ( + !isSegmentation(segmentGroupExtension, name) && + !isSegmentation(layerExtension, name) + ); }); if (baseImages.length) return baseImages[0]; @@ -138,13 +142,18 @@ function getStudyUID(volumeID: string) { function findBaseDataSource( succeeded: Array, - segmentGroupExtension: string + segmentGroupExtension: string, + layerExtension: string ) { const loadableDataSources = filterLoadableDataSources(succeeded); const baseDicom = findBaseDicom(loadableDataSources); if (baseDicom) return baseDicom; - const baseImage = findBaseImage(loadableDataSources, segmentGroupExtension); + const baseImage = findBaseImage( + loadableDataSources, + segmentGroupExtension, + layerExtension + ); if (baseImage) return baseImage; return loadableDataSources[0]; } @@ -164,7 +173,7 @@ function filterOtherVolumesInStudy( } // Layers a DICOM PET on a CT if found -function loadLayers( +function autoLayerDicoms( primaryDataSource: LoadableVolumeResult, succeeded: Array ) { @@ -190,6 +199,26 @@ function loadLayers( layersStore.addLayer(primarySelection, layerSelection); } +function autoLayerByName( + primaryDataSource: LoadableVolumeResult, + succeeded: Array, + layerExtension: string +) { + if (isDicomImage(primaryDataSource.dataID)) return; + const matchingLayers = filterMatchingNames( + primaryDataSource, + succeeded, + layerExtension + ).filter(isVolumeResult); + + const primarySelection = toDataSelection(primaryDataSource); + const layersStore = useLayersStore(); + matchingLayers.forEach((ds) => { + const layerSelection = toDataSelection(ds); + layersStore.addLayer(primarySelection, layerSelection); + }); +} + // Loads other DataSources as Segment Groups: // - DICOM SEG modalities with matching StudyUIDs. // - DataSources that have a name like foo.segmentation.bar and the primary DataSource is named foo.baz @@ -254,19 +283,25 @@ function loadDataSources(sources: DataSource[]) { if (succeeded.length && shouldShowData) { const primaryDataSource = findBaseDataSource( succeeded, - loadDataStore.segmentGroupExtension + loadDataStore.segmentGroupExtension, + loadDataStore.layerExtension ); if (isVolumeResult(primaryDataSource)) { const selection = toDataSelection(primaryDataSource); viewStore.setDataForAllViews(selection); - loadLayers(primaryDataSource, succeeded); + autoLayerDicoms(primaryDataSource, succeeded); + autoLayerByName( + primaryDataSource, + succeeded, + loadDataStore.layerExtension + ); loadSegmentations( primaryDataSource, succeeded, loadDataStore.segmentGroupExtension ); - } // then must be primaryDataSource.type === 'model' + } // else must be primaryDataSource.type === 'model', which are not dealt with here yet } if (errored.length) { diff --git a/src/io/import/configJson.ts b/src/io/import/configJson.ts index e2c896d03..a7484f9fc 100644 --- a/src/io/import/configJson.ts +++ b/src/io/import/configJson.ts @@ -59,6 +59,7 @@ const io = z .object({ segmentGroupSaveFormat: z.string().optional(), segmentGroupExtension: z.string().default(''), + layerExtension: z.string().default(''), }) .optional(); @@ -145,7 +146,9 @@ const applyIo = (manifest: Config) => { if (manifest.io.segmentGroupSaveFormat) useSegmentGroupStore().saveFormat = manifest.io.segmentGroupSaveFormat; - useLoadDataStore().segmentGroupExtension = manifest.io.segmentGroupExtension; + const loadDataStore = useLoadDataStore(); + loadDataStore.segmentGroupExtension = manifest.io.segmentGroupExtension; + loadDataStore.layerExtension = manifest.io.layerExtension; }; const applyWindowing = (manifest: Config) => { diff --git a/src/store/load-data.ts b/src/store/load-data.ts index 946627bba..44794068a 100644 --- a/src/store/load-data.ts +++ b/src/store/load-data.ts @@ -100,9 +100,11 @@ const useLoadDataStore = defineStore('loadData', () => { useLoadingNotifications(); const segmentGroupExtension = ref(''); + const layerExtension = ref(''); return { segmentGroupExtension, + layerExtension, isLoading, startLoading, stopLoading, diff --git a/tests/specs/automatic-layering.e2e.ts b/tests/specs/automatic-layering.e2e.ts new file mode 100644 index 000000000..470635287 --- /dev/null +++ b/tests/specs/automatic-layering.e2e.ts @@ -0,0 +1,36 @@ +import { DOWNLOAD_TIMEOUT } from '@/wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { FETUS_DATASET } from './configTestUtils'; +import { writeManifestToFile } from './utils'; + +describe('Automatic Layering by File Name', () => { + it('should automatically layer files matching the layer extension pattern', async () => { + const config = { + io: { + layerExtension: 'layer', + }, + }; + + const configFileName = 'automatic-layering-config.json'; + await writeManifestToFile(config, configFileName); + + await volViewPage.open( + `?config=[tmp/${configFileName}]&urls=[${FETUS_DATASET.url},${FETUS_DATASET.url}]&names=[base-image.mha,base-image.layer.mha]` + ); + await volViewPage.waitForViews(); + + const renderTab = await volViewPage.renderingModuleTab; + await renderTab.click(); + + await browser.waitUntil( + async function layerSlidersExist() { + const layerOpacitySliders = await volViewPage.layerOpacitySliders; + return (await layerOpacitySliders.length) > 0; + }, + { + timeout: DOWNLOAD_TIMEOUT, + timeoutMsg: `Expected at least one layer opacity slider to verify automatic layering`, + } + ); + }); +}); diff --git a/tests/specs/configTestUtils.ts b/tests/specs/configTestUtils.ts index 5ab53701a..9bb2759fa 100644 --- a/tests/specs/configTestUtils.ts +++ b/tests/specs/configTestUtils.ts @@ -26,6 +26,11 @@ export const MRA_HEAD_NECK_DATASET = { name: 'MRA-Head_and_Neck.zip', } as const; +export const FETUS_DATASET = { + url: 'https://data.kitware.com/api/v1/item/635679c311dab8142820a4f4/download', + name: 'fetus.zip', +} as const; + export type DatasetResource = { url: string; name?: string; From fa73f8feaa7bda5d031501987a948202c32ce7ca Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 10 Nov 2025 14:26:46 -0500 Subject: [PATCH 3/3] feat: add alphabetical sorting for auto-layered files Sort matching layers and segment groups alphabetically by filename to provide deterministic ordering. Users can control stacking order using numeric prefixes in filenames (e.g., file.layer.1.nii, file.layer.2.nii). Updated documentation to clarify naming patterns and show that file formats don't need to match between base image and associated files. --- docs/configuration_file.md | 19 ++++++++++++++++--- src/actions/loadUserFiles.ts | 18 ++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/configuration_file.md b/docs/configuration_file.md index 0f094c738..5b0eb5225 100644 --- a/docs/configuration_file.md +++ b/docs/configuration_file.md @@ -184,12 +184,24 @@ hdf5, iwi.cbor, mha, nii, nii.gz, nrrd, vtk ## Automatic Layers and Segment Groups by File Name When loading multiple files, VolView can automatically associate related images based on file naming patterns. -Files matching `base.[extension].format` will be associated with a base image named `base.format`. +Example: `base.[extension].nrrd` will match `base.nii`. + +The extension must appear anywhere in the filename after splitting by dots, and the filename must start with the same prefix as the base image (everything before the first dot). Files matching `base.[extension]...` will be associated with a base image named `base.*`. + +**Ordering:** When multiple layers/segment groups match a base image, they are sorted alphabetically by filename and added to the stack in that order. To control the stacking order explicitly, you could use numeric prefixes in your filenames. + +For example, with a base image `patient001.nrrd`: + +- Layers (sorted alphabetically): `patient001.layer.1.pet.nii`, `patient001.layer.2.ct.mha`, `patient001.layer.3.overlay.vtk` +- Segment groups: `patient001.seg.1.tumor.nii.gz`, `patient001.seg.2.lesion.mha` + Both features default to `''` which disables them. ### Segment Groups -Use `segmentGroupExtension` to automatically convert matching non-DICOM images to segment groups. For example, `myFile.seg.nrrd` becomes a segment group for `myFile.nii`: +Use `segmentGroupExtension` to automatically convert matching non-DICOM images to segment groups. +For example, `myFile.seg.nrrd` becomes a segment group for `myFile.nii`. +Defaults to `''` which disables matching. ```json { @@ -201,7 +213,8 @@ Use `segmentGroupExtension` to automatically convert matching non-DICOM images t ### Layering -Use `layerExtension` to automatically layer matching non-DICOM images on top of the base image. For example, `myImage.layer.nii` is layered on top of `myImage.nii`: +Use `layerExtension` to automatically layer matching non-DICOM images on top of the base image. For example, `myImage.layer.nii` is layered on top of `myImage.nii`. +Defaults to `''` which disables matching. ```json { diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index 254889184..a68dd61b2 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -80,6 +80,12 @@ function isSegmentation(extension: string, name: string) { return extensions.includes(extension); } +function sortByDataSourceName(a: LoadableResult, b: LoadableResult) { + const nameA = getDataSourceName(a.dataSource) ?? ''; + const nameB = getDataSourceName(b.dataSource) ?? ''; + return nameA.localeCompare(nameB); +} + // does not pick segmentation or layer images function findBaseImage( loadableDataSources: Array, @@ -209,7 +215,9 @@ function autoLayerByName( primaryDataSource, succeeded, layerExtension - ).filter(isVolumeResult); + ) + .filter(isVolumeResult) + .sort(sortByDataSourceName); const primarySelection = toDataSelection(primaryDataSource); const layersStore = useLayersStore(); @@ -231,9 +239,11 @@ function loadSegmentations( primaryDataSource, succeeded, segmentGroupExtension - ).filter( - isVolumeResult // filter out models - ); + ) + .filter( + isVolumeResult // filter out models + ) + .sort(sortByDataSourceName); const dicomStore = useDICOMStore(); const otherSegVolumesInStudy = filterOtherVolumesInStudy(