Skip to content

Commit 1a0c286

Browse files
authored
Merge pull request #818 from Kitware/config-race
Add config URL parameter and automatic layering by file name
2 parents 533c4bb + fa73f8f commit 1a0c286

File tree

9 files changed

+179
-66
lines changed

9 files changed

+179
-66
lines changed

docs/configuration_file.md

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ By loading a JSON file, you can set VolView's configuration:
88
- Visibility of Sample Data section
99
- Keyboard shortcuts
1010

11+
## Loading Configuration Files
12+
13+
Use the `config` URL parameter to load configuration before data files:
14+
15+
```
16+
https://volview.kitware.com/?config=https://example.com/config.json&urls=https://example.com/data.nrrd
17+
```
18+
1119
## View Layouts
1220

1321
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:
173181

174182
hdf5, iwi.cbor, mha, nii, nii.gz, nrrd, vtk
175183

176-
## Automatic Segment Groups by File Name
184+
## Automatic Layers and Segment Groups by File Name
185+
186+
When loading multiple files, VolView can automatically associate related images based on file naming patterns.
187+
Example: `base.[extension].nrrd` will match `base.nii`.
188+
189+
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.*`.
190+
191+
**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.
192+
193+
For example, with a base image `patient001.nrrd`:
194+
195+
- Layers (sorted alphabetically): `patient001.layer.1.pet.nii`, `patient001.layer.2.ct.mha`, `patient001.layer.3.overlay.vtk`
196+
- Segment groups: `patient001.seg.1.tumor.nii.gz`, `patient001.seg.2.lesion.mha`
177197

178-
When loading files, VolView can automatically convert images to segment groups
179-
if they follow a naming convention. For example, an image with name like `foo.segmentation.bar`
180-
will be converted to a segment group for a base image named like `foo.baz`.
181-
The `segmentation` extension is defined by the `io.segmentGroupExtension` key, which takes a
182-
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.
198+
Both features default to `''` which disables them.
183199

184-
This will define `myFile.seg.nrrd` as a segment group for a `myFile.nii` base file.
200+
### Segment Groups
201+
202+
Use `segmentGroupExtension` to automatically convert matching non-DICOM images to segment groups.
203+
For example, `myFile.seg.nrrd` becomes a segment group for `myFile.nii`.
204+
Defaults to `''` which disables matching.
185205

186206
```json
187207
{
@@ -191,6 +211,19 @@ This will define `myFile.seg.nrrd` as a segment group for a `myFile.nii` base fi
191211
}
192212
```
193213

214+
### Layering
215+
216+
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`.
217+
Defaults to `''` which disables matching.
218+
219+
```json
220+
{
221+
"io": {
222+
"layerExtension": "layer"
223+
}
224+
}
225+
```
226+
194227
## Keyboard Shortcuts
195228

196229
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 `
277310
"showKeyboardShortcuts": "t"
278311
},
279312
"io": {
280-
"segmentGroupSaveFormat": "nrrd"
313+
"segmentGroupSaveFormat": "nrrd",
314+
"segmentGroupExtension": "seg",
315+
"layerExtension": "layer"
281316
}
282317
}
283318
```

src/actions/loadUserFiles.ts

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -80,17 +80,27 @@ function isSegmentation(extension: string, name: string) {
8080
return extensions.includes(extension);
8181
}
8282

83-
// does not pick segmentation images
83+
function sortByDataSourceName(a: LoadableResult, b: LoadableResult) {
84+
const nameA = getDataSourceName(a.dataSource) ?? '';
85+
const nameB = getDataSourceName(b.dataSource) ?? '';
86+
return nameA.localeCompare(nameB);
87+
}
88+
89+
// does not pick segmentation or layer images
8490
function findBaseImage(
8591
loadableDataSources: Array<LoadableResult>,
86-
segmentGroupExtension: string
92+
segmentGroupExtension: string,
93+
layerExtension: string
8794
) {
8895
const baseImages = loadableDataSources
8996
.filter(({ dataType }) => dataType === 'image')
9097
.filter((importResult) => {
9198
const name = getDataSourceName(importResult.dataSource);
9299
if (!name) return false;
93-
return !isSegmentation(segmentGroupExtension, name);
100+
return (
101+
!isSegmentation(segmentGroupExtension, name) &&
102+
!isSegmentation(layerExtension, name)
103+
);
94104
});
95105

96106
if (baseImages.length) return baseImages[0];
@@ -138,13 +148,18 @@ function getStudyUID(volumeID: string) {
138148

139149
function findBaseDataSource(
140150
succeeded: Array<ImportResult>,
141-
segmentGroupExtension: string
151+
segmentGroupExtension: string,
152+
layerExtension: string
142153
) {
143154
const loadableDataSources = filterLoadableDataSources(succeeded);
144155
const baseDicom = findBaseDicom(loadableDataSources);
145156
if (baseDicom) return baseDicom;
146157

147-
const baseImage = findBaseImage(loadableDataSources, segmentGroupExtension);
158+
const baseImage = findBaseImage(
159+
loadableDataSources,
160+
segmentGroupExtension,
161+
layerExtension
162+
);
148163
if (baseImage) return baseImage;
149164
return loadableDataSources[0];
150165
}
@@ -164,7 +179,7 @@ function filterOtherVolumesInStudy(
164179
}
165180

166181
// Layers a DICOM PET on a CT if found
167-
function loadLayers(
182+
function autoLayerDicoms(
168183
primaryDataSource: LoadableVolumeResult,
169184
succeeded: Array<ImportResult>
170185
) {
@@ -190,6 +205,28 @@ function loadLayers(
190205
layersStore.addLayer(primarySelection, layerSelection);
191206
}
192207

208+
function autoLayerByName(
209+
primaryDataSource: LoadableVolumeResult,
210+
succeeded: Array<ImportResult>,
211+
layerExtension: string
212+
) {
213+
if (isDicomImage(primaryDataSource.dataID)) return;
214+
const matchingLayers = filterMatchingNames(
215+
primaryDataSource,
216+
succeeded,
217+
layerExtension
218+
)
219+
.filter(isVolumeResult)
220+
.sort(sortByDataSourceName);
221+
222+
const primarySelection = toDataSelection(primaryDataSource);
223+
const layersStore = useLayersStore();
224+
matchingLayers.forEach((ds) => {
225+
const layerSelection = toDataSelection(ds);
226+
layersStore.addLayer(primarySelection, layerSelection);
227+
});
228+
}
229+
193230
// Loads other DataSources as Segment Groups:
194231
// - DICOM SEG modalities with matching StudyUIDs.
195232
// - DataSources that have a name like foo.segmentation.bar and the primary DataSource is named foo.baz
@@ -202,9 +239,11 @@ function loadSegmentations(
202239
primaryDataSource,
203240
succeeded,
204241
segmentGroupExtension
205-
).filter(
206-
isVolumeResult // filter out models
207-
);
242+
)
243+
.filter(
244+
isVolumeResult // filter out models
245+
)
246+
.sort(sortByDataSourceName);
208247

209248
const dicomStore = useDICOMStore();
210249
const otherSegVolumesInStudy = filterOtherVolumesInStudy(
@@ -254,19 +293,25 @@ function loadDataSources(sources: DataSource[]) {
254293
if (succeeded.length && shouldShowData) {
255294
const primaryDataSource = findBaseDataSource(
256295
succeeded,
257-
loadDataStore.segmentGroupExtension
296+
loadDataStore.segmentGroupExtension,
297+
loadDataStore.layerExtension
258298
);
259299

260300
if (isVolumeResult(primaryDataSource)) {
261301
const selection = toDataSelection(primaryDataSource);
262302
viewStore.setDataForAllViews(selection);
263-
loadLayers(primaryDataSource, succeeded);
303+
autoLayerDicoms(primaryDataSource, succeeded);
304+
autoLayerByName(
305+
primaryDataSource,
306+
succeeded,
307+
loadDataStore.layerExtension
308+
);
264309
loadSegmentations(
265310
primaryDataSource,
266311
succeeded,
267312
loadDataStore.segmentGroupExtension
268313
);
269-
} // then must be primaryDataSource.type === 'model'
314+
} // else must be primaryDataSource.type === 'model', which are not dealt with here yet
270315
}
271316

272317
if (errored.length) {
@@ -323,17 +368,25 @@ export async function loadUserPromptedFiles() {
323368
return loadFiles(files);
324369
}
325370

371+
function urlsToDataSources(urls: string[], names: string[] = []): DataSource[] {
372+
return urls.map((url, idx) => {
373+
const defaultName =
374+
basename(parseUrl(url, window.location.href).pathname) || url;
375+
return uriToDataSource(url, names[idx] || defaultName);
376+
});
377+
}
378+
326379
export async function loadUrls(params: UrlParams) {
327-
const urls = wrapInArray(params.urls);
328-
const names = wrapInArray(params.names ?? []); // optional names should resolve to [] if params.names === undefined
329-
const sources = urls.map((url, idx) =>
330-
uriToDataSource(
331-
url,
332-
names[idx] ||
333-
basename(parseUrl(url, window.location.href).pathname) ||
334-
url
335-
)
336-
);
380+
if (params.config) {
381+
const configUrls = wrapInArray(params.config);
382+
const configSources = urlsToDataSources(configUrls);
383+
await loadDataSources(configSources);
384+
}
337385

338-
return loadDataSources(sources);
386+
if (params.urls) {
387+
const urls = wrapInArray(params.urls);
388+
const names = wrapInArray(params.names ?? []);
389+
const sources = urlsToDataSources(urls, names);
390+
await loadDataSources(sources);
391+
}
339392
}

src/components/App.vue

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,6 @@ export default defineComponent({
149149
const urlParams = vtkURLExtract.extractURLParameters() as UrlParams;
150150
151151
onMounted(() => {
152-
if (!urlParams.urls) {
153-
return;
154-
}
155-
156152
loadUrls(urlParams);
157153
});
158154

src/io/import/configJson.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const io = z
5959
.object({
6060
segmentGroupSaveFormat: z.string().optional(),
6161
segmentGroupExtension: z.string().default(''),
62+
layerExtension: z.string().default(''),
6263
})
6364
.optional();
6465

@@ -145,7 +146,9 @@ const applyIo = (manifest: Config) => {
145146

146147
if (manifest.io.segmentGroupSaveFormat)
147148
useSegmentGroupStore().saveFormat = manifest.io.segmentGroupSaveFormat;
148-
useLoadDataStore().segmentGroupExtension = manifest.io.segmentGroupExtension;
149+
const loadDataStore = useLoadDataStore();
150+
loadDataStore.segmentGroupExtension = manifest.io.segmentGroupExtension;
151+
loadDataStore.layerExtension = manifest.io.layerExtension;
149152
};
150153

151154
const applyWindowing = (manifest: Config) => {

src/store/load-data.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,11 @@ const useLoadDataStore = defineStore('loadData', () => {
100100
useLoadingNotifications();
101101

102102
const segmentGroupExtension = ref('');
103+
const layerExtension = ref('');
103104

104105
return {
105106
segmentGroupExtension,
107+
layerExtension,
106108
isLoading,
107109
startLoading,
108110
stopLoading,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { DOWNLOAD_TIMEOUT } from '@/wdio.shared.conf';
2+
import { volViewPage } from '../pageobjects/volview.page';
3+
import { FETUS_DATASET } from './configTestUtils';
4+
import { writeManifestToFile } from './utils';
5+
6+
describe('Automatic Layering by File Name', () => {
7+
it('should automatically layer files matching the layer extension pattern', async () => {
8+
const config = {
9+
io: {
10+
layerExtension: 'layer',
11+
},
12+
};
13+
14+
const configFileName = 'automatic-layering-config.json';
15+
await writeManifestToFile(config, configFileName);
16+
17+
await volViewPage.open(
18+
`?config=[tmp/${configFileName}]&urls=[${FETUS_DATASET.url},${FETUS_DATASET.url}]&names=[base-image.mha,base-image.layer.mha]`
19+
);
20+
await volViewPage.waitForViews();
21+
22+
const renderTab = await volViewPage.renderingModuleTab;
23+
await renderTab.click();
24+
25+
await browser.waitUntil(
26+
async function layerSlidersExist() {
27+
const layerOpacitySliders = await volViewPage.layerOpacitySliders;
28+
return (await layerOpacitySliders.length) > 0;
29+
},
30+
{
31+
timeout: DOWNLOAD_TIMEOUT,
32+
timeoutMsg: `Expected at least one layer opacity slider to verify automatic layering`,
33+
}
34+
);
35+
});
36+
});

tests/specs/configTestUtils.ts

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,40 +26,28 @@ export const MRA_HEAD_NECK_DATASET = {
2626
name: 'MRA-Head_and_Neck.zip',
2727
} as const;
2828

29+
export const FETUS_DATASET = {
30+
url: 'https://data.kitware.com/api/v1/item/635679c311dab8142820a4f4/download',
31+
name: 'fetus.zip',
32+
} as const;
33+
2934
export type DatasetResource = {
3035
url: string;
3136
name?: string;
3237
};
3338

34-
export const createConfigManifest = async (
39+
export const openConfigAndDataset = async (
3540
config: unknown,
3641
name: string,
3742
dataset: DatasetResource = ONE_CT_SLICE_DICOM
3843
) => {
3944
const configFileName = `${name}-config.json`;
40-
const manifestFileName = `${name}-manifest.json`;
41-
4245
await writeManifestToFile(config, configFileName);
4346

44-
const manifest = {
45-
resources: [{ url: `/tmp/${configFileName}` }, dataset],
46-
};
47-
48-
await writeManifestToFile(manifest, manifestFileName);
49-
return manifestFileName;
50-
};
51-
52-
export const openConfigAndWait = async (
53-
config: unknown,
54-
name: string,
55-
dataset: DatasetResource = ONE_CT_SLICE_DICOM
56-
) => {
57-
const manifestFileNameOnDisk = await createConfigManifest(
58-
config,
59-
name,
60-
dataset
47+
await volViewPage.open(
48+
`?config=[tmp/${configFileName}]&urls=${dataset.url}&names=${
49+
dataset.name ?? ''
50+
}`
6151
);
62-
63-
await volViewPage.open(`?urls=[tmp/${manifestFileNameOnDisk}]`);
6452
await volViewPage.waitForViews();
6553
};

0 commit comments

Comments
 (0)