diff --git a/docs/configuration_file.md b/docs/configuration_file.md index 5912ee219..5b0eb5225 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: @@ -173,15 +181,27 @@ 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 multiple files, VolView can automatically associate related images based on file naming patterns. +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` -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. +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`. +Defaults to `''` which disables matching. ```json { @@ -191,6 +211,19 @@ 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`. +Defaults to `''` which disables matching. + +```json +{ + "io": { + "layerExtension": "layer" + } +} +``` + ## Keyboard Shortcuts Configure the keys to activate tools, change selected labels, and more. @@ -277,7 +310,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 1e175c597..a68dd61b2 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -80,17 +80,27 @@ function isSegmentation(extension: string, name: string) { return extensions.includes(extension); } -// does not pick segmentation images +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, - 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 +148,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 +179,7 @@ function filterOtherVolumesInStudy( } // Layers a DICOM PET on a CT if found -function loadLayers( +function autoLayerDicoms( primaryDataSource: LoadableVolumeResult, succeeded: Array ) { @@ -190,6 +205,28 @@ 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) + .sort(sortByDataSourceName); + + 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 @@ -202,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( @@ -254,19 +293,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) { @@ -323,17 +368,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/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 245de7b1f..9bb2759fa 100644 --- a/tests/specs/configTestUtils.ts +++ b/tests/specs/configTestUtils.ts @@ -26,40 +26,28 @@ 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; }; -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"]');