Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions docs/configuration_file.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
{
Expand All @@ -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.
Expand Down Expand Up @@ -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"
}
}
```
99 changes: 76 additions & 23 deletions src/actions/loadUserFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoadableResult>,
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];
Expand Down Expand Up @@ -138,13 +148,18 @@ function getStudyUID(volumeID: string) {

function findBaseDataSource(
succeeded: Array<ImportResult>,
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];
}
Expand All @@ -164,7 +179,7 @@ function filterOtherVolumesInStudy(
}

// Layers a DICOM PET on a CT if found
function loadLayers(
function autoLayerDicoms(
primaryDataSource: LoadableVolumeResult,
succeeded: Array<ImportResult>
) {
Expand All @@ -190,6 +205,28 @@ function loadLayers(
layersStore.addLayer(primarySelection, layerSelection);
}

function autoLayerByName(
primaryDataSource: LoadableVolumeResult,
succeeded: Array<ImportResult>,
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
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
4 changes: 0 additions & 4 deletions src/components/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,6 @@ export default defineComponent({
const urlParams = vtkURLExtract.extractURLParameters() as UrlParams;

onMounted(() => {
if (!urlParams.urls) {
return;
}

loadUrls(urlParams);
});

Expand Down
5 changes: 4 additions & 1 deletion src/io/import/configJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const io = z
.object({
segmentGroupSaveFormat: z.string().optional(),
segmentGroupExtension: z.string().default(''),
layerExtension: z.string().default(''),
})
.optional();

Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions src/store/load-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ const useLoadDataStore = defineStore('loadData', () => {
useLoadingNotifications();

const segmentGroupExtension = ref('');
const layerExtension = ref('');

return {
segmentGroupExtension,
layerExtension,
isLoading,
startLoading,
stopLoading,
Expand Down
36 changes: 36 additions & 0 deletions tests/specs/automatic-layering.e2e.ts
Original file line number Diff line number Diff line change
@@ -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`,
}
);
});
});
32 changes: 10 additions & 22 deletions tests/specs/configTestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Loading
Loading