diff --git a/documentation/content/doc/configuration_file.md b/documentation/content/doc/configuration_file.md index af0e78a5..73debcc2 100644 --- a/documentation/content/doc/configuration_file.md +++ b/documentation/content/doc/configuration_file.md @@ -49,10 +49,28 @@ VolView will include in the volview.zip file. } ``` -These are the supported file formats: +Working segment group file formats: hdf5, iwi.cbor, mha, nii, nii.gz, nrrd, vtk +## Automatic 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. + +This will define `myFile.seg.nrrd` as a segment group for a `myFile.nii` base file. + +```json +{ + "io": { + "segmentGroupExtension": "seg" + } +} +``` + ## Keyboard Shortcuts Configure the keys to activate tools, change selected labels, and more. diff --git a/package-lock.json b/package-lock.json index 221882d3..81fc805b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "chai-almost": "^1.0.1", "chai-as-promised": "7.1.1", "chai-subset": "^1.6.0", - "chromedriver": "^121.0.2", + "chromedriver": "^124.0.1", "concurrently": "^8.2.2", "cross-env": "^7.0.3", "eslint": "^7.32.0", @@ -8011,17 +8011,17 @@ } }, "node_modules/chromedriver": { - "version": "121.0.2", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-121.0.2.tgz", - "integrity": "sha512-58MUSCEE3oB3G3Y/Jo3URJ2Oa1VLHcVBufyYt7vNfGrABSJm7ienQLF9IQ8LPDlPVgLUXt2OBfggK3p2/SlEBg==", + "version": "124.0.1", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-124.0.1.tgz", + "integrity": "sha512-hxd1tpAUhgMFBZd1h3W7KyMckxofOYCuKAMtcvBDAU0YKKorZcWuq6zP06+Ph0Z1ynPjtgAj0hP9VphCwesjZw==", "dev": true, "hasInstallScript": true, "dependencies": { "@testim/chrome-version": "^1.1.4", - "axios": "^1.6.5", + "axios": "^1.6.7", "compare-versions": "^6.1.0", "extract-zip": "^2.0.1", - "https-proxy-agent": "^5.0.1", + "proxy-agent": "^6.4.0", "proxy-from-env": "^1.1.0", "tcp-port-used": "^1.0.2" }, @@ -8032,6 +8032,72 @@ "node": ">=18" } }, + "node_modules/chromedriver/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/chromedriver/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/chromedriver/node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/chromedriver/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/chromedriver/node_modules/proxy-agent": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", + "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.3", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/chromium-bidi": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.16.tgz", @@ -16521,9 +16587,9 @@ } }, "node_modules/pac-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.0.tgz", - "integrity": "sha512-t4tRAMx0uphnZrio0S0Jw9zg3oDbz1zVhQ/Vy18FjLfP1XOLNUEjaVxYCYRI6NS+BsMBXKIzV6cTLOkO9AtywA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.1.tgz", + "integrity": "sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==", "dev": true, "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", @@ -16531,9 +16597,9 @@ "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", "pac-resolver": "^7.0.0", - "socks-proxy-agent": "^8.0.1" + "socks-proxy-agent": "^8.0.2" }, "engines": { "node": ">= 14" @@ -16565,9 +16631,9 @@ } }, "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.1.tgz", - "integrity": "sha512-Eun8zV0kcYS1g19r78osiQLEFIRspRUDd9tIfBCTBPBeMieF/EsJNL8VI3xOIdYRDEkjQnqOYPsZ2DsWsVsFwQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "dev": true, "dependencies": { "agent-base": "^7.0.2", @@ -18926,12 +18992,12 @@ } }, "node_modules/socks-proxy-agent": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.1.tgz", - "integrity": "sha512-59EjPbbgg8U3x62hhKOFVAmySQUcfRQ4C7Q/D5sEHnZTQRrQlNKINks44DMR1gwXp0p4LaVIeccX2KHTTcHVqQ==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz", + "integrity": "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==", "dev": true, "dependencies": { - "agent-base": "^7.0.1", + "agent-base": "^7.1.1", "debug": "^4.3.4", "socks": "^2.7.1" }, @@ -18940,9 +19006,9 @@ } }, "node_modules/socks-proxy-agent/node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", "dev": true, "dependencies": { "debug": "^4.3.4" @@ -28278,18 +28344,71 @@ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==" }, "chromedriver": { - "version": "121.0.2", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-121.0.2.tgz", - "integrity": "sha512-58MUSCEE3oB3G3Y/Jo3URJ2Oa1VLHcVBufyYt7vNfGrABSJm7ienQLF9IQ8LPDlPVgLUXt2OBfggK3p2/SlEBg==", + "version": "124.0.1", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-124.0.1.tgz", + "integrity": "sha512-hxd1tpAUhgMFBZd1h3W7KyMckxofOYCuKAMtcvBDAU0YKKorZcWuq6zP06+Ph0Z1ynPjtgAj0hP9VphCwesjZw==", "dev": true, "requires": { "@testim/chrome-version": "^1.1.4", - "axios": "^1.6.5", + "axios": "^1.6.7", "compare-versions": "^6.1.0", "extract-zip": "^2.0.1", - "https-proxy-agent": "^5.0.1", + "proxy-agent": "^6.4.0", "proxy-from-env": "^1.1.0", "tcp-port-used": "^1.0.2" + }, + "dependencies": { + "agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "requires": { + "debug": "^4.3.4" + } + }, + "http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + } + }, + "https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dev": true, + "requires": { + "agent-base": "^7.0.2", + "debug": "4" + } + }, + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true + }, + "proxy-agent": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", + "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "dev": true, + "requires": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.3", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + } + } } }, "chromium-bidi": { @@ -34644,9 +34763,9 @@ "devOptional": true }, "pac-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.0.tgz", - "integrity": "sha512-t4tRAMx0uphnZrio0S0Jw9zg3oDbz1zVhQ/Vy18FjLfP1XOLNUEjaVxYCYRI6NS+BsMBXKIzV6cTLOkO9AtywA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.1.tgz", + "integrity": "sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==", "dev": true, "requires": { "@tootallnate/quickjs-emscripten": "^0.23.0", @@ -34654,9 +34773,9 @@ "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", "pac-resolver": "^7.0.0", - "socks-proxy-agent": "^8.0.1" + "socks-proxy-agent": "^8.0.2" }, "dependencies": { "agent-base": { @@ -34679,9 +34798,9 @@ } }, "https-proxy-agent": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.1.tgz", - "integrity": "sha512-Eun8zV0kcYS1g19r78osiQLEFIRspRUDd9tIfBCTBPBeMieF/EsJNL8VI3xOIdYRDEkjQnqOYPsZ2DsWsVsFwQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "dev": true, "requires": { "agent-base": "^7.0.2", @@ -36466,20 +36585,20 @@ } }, "socks-proxy-agent": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.1.tgz", - "integrity": "sha512-59EjPbbgg8U3x62hhKOFVAmySQUcfRQ4C7Q/D5sEHnZTQRrQlNKINks44DMR1gwXp0p4LaVIeccX2KHTTcHVqQ==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz", + "integrity": "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==", "dev": true, "requires": { - "agent-base": "^7.0.1", + "agent-base": "^7.1.1", "debug": "^4.3.4", "socks": "^2.7.1" }, "dependencies": { "agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", "dev": true, "requires": { "debug": "^4.3.4" diff --git a/package.json b/package.json index 8aaf7b25..d57ef2fa 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "chai-almost": "^1.0.1", "chai-as-promised": "7.1.1", "chai-subset": "^1.6.0", - "chromedriver": "^121.0.2", + "chromedriver": "^124.0.1", "concurrently": "^8.2.2", "cross-env": "^7.0.3", "eslint": "^7.32.0", @@ -126,4 +126,4 @@ "eslint" ] } -} \ No newline at end of file +} diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index 7e38464a..351b1447 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -1,9 +1,296 @@ -import { fileToDataSource, uriToDataSource } from '@/src/io/import/dataSource'; +import { UrlParams } from '@vueuse/core'; +import { + fileToDataSource, + uriToDataSource, + DataSource, + getDataSourceName, +} from '@/src/io/import/dataSource'; import useLoadDataStore from '@/src/store/load-data'; -import { wrapInArray } from '@/src/utils'; +import { useDatasetStore } from '@/src/store/datasets'; +import { useDICOMStore } from '@/src/store/datasets-dicom'; +import { useLayersStore } from '@/src/store/datasets-layers'; +import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { wrapInArray, nonNullable } from '@/src/utils'; import { basename } from '@/src/utils/path'; import { parseUrl } from '@/src/utils/url'; -import { UrlParams } from '@vueuse/core'; +import { logError } from '@/src/utils/loggers'; +import { PipelineResultSuccess, partitionResults } from '@/src/core/pipeline'; +import { + ImportDataSourcesResult, + importDataSources, + toDataSelection, +} from '@/src/io/import/importDataSources'; +import { + ImportResult, + LoadableResult, + VolumeResult, + isLoadableResult, + isVolumeResult, +} from '@/src/io/import/common'; + +// higher value priority is preferred for picking a primary selection +const BASE_MODALITY_TYPES = { + CT: { priority: 3 }, + MR: { priority: 3 }, + US: { priority: 2 }, + DX: { priority: 1 }, +} as const; + +function findBaseDicom(loadableDataSources: Array) { + // find dicom dataset for primary selection if available + const dicoms = loadableDataSources.filter( + ({ dataType }) => dataType === 'dicom' + ); + // prefer some modalities as base + const dicomStore = useDICOMStore(); + const baseDicomVolumes = dicoms + .map((dicomSource) => { + const volumeInfo = dicomStore.volumeInfo[dicomSource.dataID]; + const modality = volumeInfo?.Modality as keyof typeof BASE_MODALITY_TYPES; + if (modality in BASE_MODALITY_TYPES) + return { + dicomSource, + priority: BASE_MODALITY_TYPES[modality]?.priority, + volumeInfo, + }; + return undefined; + }) + .filter(nonNullable) + .sort( + ( + { priority: a, volumeInfo: infoA }, + { priority: b, volumeInfo: infoB } + ) => { + const priorityDiff = a - b; + if (priorityDiff !== 0) return priorityDiff; + // same modality, then more slices preferred + if (!infoA.NumberOfSlices) return 1; + if (!infoB.NumberOfSlices) return -1; + return infoB.NumberOfSlices - infoA.NumberOfSlices; + } + ); + if (baseDicomVolumes.length) return baseDicomVolumes[0].dicomSource; + return undefined; +} + +function isSegmentation(extension: string, name: string) { + if (!extension) return false; // avoid 'foo..bar' if extension is '' + const extensions = name.split('.').slice(1); + return extensions.includes(extension); +} + +// does not pick segmentation images +function findBaseImage( + loadableDataSources: Array, + segmentGroupExtension: string +) { + const baseImages = loadableDataSources + .filter(({ dataType }) => dataType === 'image') + .filter((importResult) => { + const name = getDataSourceName(importResult.dataSource); + if (!name) return false; + return !isSegmentation(segmentGroupExtension, name); + }); + + if (baseImages.length) return baseImages[0]; + return undefined; +} + +// returns image and dicom sources, no config files +function filterLoadableDataSources( + succeeded: Array> +) { + return succeeded.flatMap((result) => { + return result.data.filter(isLoadableResult); + }); +} + +// Returns list of dataSources with file names where the name has the extension argument +// and the start of the file name matches the primary file name. +function filterMatchingNames( + primaryDataSource: VolumeResult, + succeeded: Array>, + extension: string +) { + const primaryName = getDataSourceName(primaryDataSource.dataSource); + if (!primaryName) return []; + const primaryNamePrefix = primaryName.split('.').slice(0, 1).join(); + return filterLoadableDataSources(succeeded) + .filter((ds) => ds !== primaryDataSource) + .map((importResult) => ({ + importResult, + name: getDataSourceName(importResult.dataSource), + })) + .filter(({ name }) => { + if (!name) return false; + const hasExtension = isSegmentation(extension, name); + const nameMatchesPrimary = name.startsWith(primaryNamePrefix); + return hasExtension && nameMatchesPrimary; + }) + .map(({ importResult }) => importResult); +} + +function getStudyUID(volumeID: string) { + const dicomStore = useDICOMStore(); + const studyKey = dicomStore.volumeStudy[volumeID]; + return dicomStore.studyInfo[studyKey]?.StudyInstanceUID; +} + +function findBaseDataSource( + succeeded: Array>, + segmentGroupExtension: string +) { + const loadableDataSources = filterLoadableDataSources(succeeded); + const baseDicom = findBaseDicom(loadableDataSources); + if (baseDicom) return baseDicom; + + const baseImage = findBaseImage(loadableDataSources, segmentGroupExtension); + if (baseImage) return baseImage; + return loadableDataSources[0]; +} + +function filterOtherVolumesInStudy( + volumeID: string, + succeeded: Array> +) { + const targetStudyUID = getStudyUID(volumeID); + const dicomDataSources = filterLoadableDataSources(succeeded).filter( + ({ dataType }) => dataType === 'dicom' + ); + return dicomDataSources.filter((ds) => { + const sourceStudyUID = getStudyUID(ds.dataID); + return sourceStudyUID === targetStudyUID && ds.dataID !== volumeID; + }) as Array; +} + +// Layers a DICOM PET on a CT if found +function loadLayers( + primaryDataSource: VolumeResult, + succeeded: Array> +) { + if (primaryDataSource.dataType !== 'dicom') return; + const otherVolumesInStudy = filterOtherVolumesInStudy( + primaryDataSource.dataID, + succeeded + ); + const dicomStore = useDICOMStore(); + const primaryModality = + dicomStore.volumeInfo[primaryDataSource.dataID].Modality; + if (primaryModality !== 'CT') return; + // Look for one PET volume to layer with CT. Only one as there are often multiple "White Balance" corrected PET volumes. + const toLayer = otherVolumesInStudy.find((ds) => { + const otherModality = dicomStore.volumeInfo[ds.dataID].Modality; + return otherModality === 'PT'; + }); + if (!toLayer) return; + + const primarySelection = toDataSelection(primaryDataSource); + const layersStore = useLayersStore(); + const layerSelection = toDataSelection(toLayer); + 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 +function loadSegmentations( + primaryDataSource: VolumeResult, + succeeded: Array>, + segmentGroupExtension: string +) { + const matchingNames = filterMatchingNames( + primaryDataSource, + succeeded, + segmentGroupExtension + ).filter( + isVolumeResult // filter out models + ); + + const dicomStore = useDICOMStore(); + const otherSegVolumesInStudy = filterOtherVolumesInStudy( + primaryDataSource.dataID, + succeeded + ).filter((ds) => { + const modality = dicomStore.volumeInfo[ds.dataID].Modality; + if (!modality) return false; + return modality.trim() === 'SEG'; + }); + + const segmentGroupStore = useSegmentGroupStore(); + [...otherSegVolumesInStudy, ...matchingNames].forEach((ds) => { + const loadable = toDataSelection(ds); + segmentGroupStore.convertImageToLabelmap( + loadable, + toDataSelection(primaryDataSource) + ); + }); +} + +function loadDataSources(sources: DataSource[]) { + const load = async () => { + const loadDataStore = useLoadDataStore(); + const dataStore = useDatasetStore(); + + let results: ImportDataSourcesResult[]; + try { + results = await importDataSources(sources); + } catch (error) { + loadDataStore.setError(error as Error); + return; + } + + const [succeeded, errored] = partitionResults(results); + + if (!dataStore.primarySelection && succeeded.length) { + const primaryDataSource = findBaseDataSource( + succeeded, + loadDataStore.segmentGroupExtension + ); + + if (isVolumeResult(primaryDataSource)) { + const selection = toDataSelection(primaryDataSource); + dataStore.setPrimarySelection(selection); + loadLayers(primaryDataSource, succeeded); + loadSegmentations( + primaryDataSource, + succeeded, + loadDataStore.segmentGroupExtension + ); + } // then must be primaryDataSource.type === 'model' + } + + if (errored.length) { + const errorMessages = errored.map((errResult) => { + // pick first error + const [firstError] = errResult.errors; + // pick innermost dataset that errored + const name = getDataSourceName(firstError.inputDataStackTrace[0]); + // log error for debugging + logError(firstError.cause); + return `- ${name}: ${firstError.message}`; + }); + const failedError = new Error( + `These files failed to load:\n${errorMessages.join('\n')}` + ); + + loadDataStore.setError(failedError); + } + }; + + const wrapWithLoading = void>(fn: T) => { + const { startLoading, stopLoading } = useLoadDataStore(); + return async function wrapper(...args: any[]) { + try { + startLoading(); + await fn(...args); + } finally { + stopLoading(); + } + }; + }; + + return wrapWithLoading(load)(); +} export function openFileDialog() { return new Promise((resolve) => { @@ -21,7 +308,7 @@ export function openFileDialog() { export async function loadFiles(files: File[]) { const dataSources = files.map(fileToDataSource); - return useLoadDataStore().loadDataSources(dataSources); + return loadDataSources(dataSources); } export async function loadUserPromptedFiles() { @@ -41,5 +328,5 @@ export async function loadUrls(params: UrlParams) { ) ); - return useLoadDataStore().loadDataSources(sources); + return loadDataSources(sources); } diff --git a/src/io/import/configJson.ts b/src/io/import/configJson.ts index 2f5c7e4e..9ac10ee9 100644 --- a/src/io/import/configJson.ts +++ b/src/io/import/configJson.ts @@ -12,6 +12,10 @@ import { useViewStore } from '@/src/store/views'; import { actionToKey } from '@/src/composables/useKeyboardShortcuts'; import { useSegmentGroupStore } from '@/src/store/segmentGroups'; import { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool'; +import useLoadDataStore from '@/src/store/load-data'; + +// -------------------------------------------------------------------------- +// Interface const layout = z .object({ @@ -56,9 +60,13 @@ const labels = z }) .optional(); +// -------------------------------------------------------------------------- +// IO + const io = z .object({ segmentGroupSaveFormat: z.string().optional(), + segmentGroupExtension: z.string().default(''), }) .optional(); @@ -129,6 +137,7 @@ const applyIo = (manifest: Config) => { if (manifest.io.segmentGroupSaveFormat) useSegmentGroupStore().saveFormat = manifest.io.segmentGroupSaveFormat; + useLoadDataStore().segmentGroupExtension = manifest.io.segmentGroupExtension; }; export const applyConfig = (manifest: Config) => { diff --git a/src/store/datasets-layers.ts b/src/store/datasets-layers.ts index e8eeba47..6293cb54 100644 --- a/src/store/datasets-layers.ts +++ b/src/store/datasets-layers.ts @@ -1,9 +1,7 @@ import { ref } from 'vue'; -import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkBoundingBox from '@kitware/vtk.js/Common/DataModel/BoundingBox'; import { defineStore } from 'pinia'; -import { compareImageSpaces } from '@/src/utils/imageSpace'; import { DataSelection, getImage, @@ -11,7 +9,7 @@ import { makeImageSelection, selectionEquals, } from '@/src/utils/dataSelection'; -import { resample } from '../io/resample/resample'; +import { ensureSameSpace } from '@/src/io/resample/resample'; import { useErrorMessage } from '../composables/useErrorMessage'; import { Manifest, StateFile } from '../io/state-file/schema'; @@ -89,16 +87,7 @@ export const useLayersStore = defineStore('layer', () => { ); } - let image: vtkImageData; - if (compareImageSpaces(parentImage, sourceImage)) { - image = sourceImage; - } else { - const itkImage = await resample( - vtkITKHelper.convertVtkToItkImage(parentImage), - vtkITKHelper.convertVtkToItkImage(sourceImage) - ); - image = vtkITKHelper.convertItkToVtkImage(itkImage); - } + const image = await ensureSameSpace(parentImage, sourceImage); this.layerImages[id] = image; } diff --git a/src/store/load-data.ts b/src/store/load-data.ts index 45b860d5..946627bb 100644 --- a/src/store/load-data.ts +++ b/src/store/load-data.ts @@ -1,19 +1,3 @@ -import { PipelineResultSuccess, partitionResults } from '@/src/core/pipeline'; -import { DataSource, getDataSourceName } from '@/src/io/import/dataSource'; -import { - ImportDataSourcesResult, - importDataSources, - toDataSelection, -} from '@/src/io/import/importDataSources'; -import { - ImportResult, - LoadableResult, - VolumeResult, - isLoadableResult, - isVolumeResult, -} from '@/src/io/import/common'; -import { useDICOMStore } from '@/src/store/datasets-dicom'; -import { useDatasetStore } from '@/src/store/datasets'; import { useMessageStore } from '@/src/store/messages'; import { Maybe } from '@/src/types'; import { logError } from '@/src/utils/loggers'; @@ -22,17 +6,6 @@ import { computed, ref, watch } from 'vue'; import { useToast } from '@/src/composables/useToast'; import { TYPE } from 'vue-toastification'; import { ToastID, ToastOptions } from 'vue-toastification/dist/types/types'; -import { useLayersStore } from './datasets-layers'; -import { useSegmentGroupStore } from './segmentGroups'; -import { nonNullable } from '../utils'; - -// higher value priority is preferred for picking a primary selection -const BASE_MODALITY_TYPES = { - CT: { priority: 3 }, - MR: { priority: 3 }, - US: { priority: 2 }, - DX: { priority: 1 }, -} as const; const NotificationMessages = { Loading: 'Loading datasets...', @@ -47,7 +20,7 @@ const LoadingToastOptions = { closeOnClick: false, } satisfies ToastOptions; -function useLoadingNotifications() { +export function useLoadingNotifications() { const messageStore = useMessageStore(); const loadingCount = ref(0); @@ -122,190 +95,18 @@ function useLoadingNotifications() { }; } -function pickBaseDicom(loadableDataSources: Array) { - // pick dicom dataset as primary selection if available - const dicoms = loadableDataSources.filter( - ({ dataType }) => dataType === 'dicom' - ); - // prefer some modalities as base - const dicomStore = useDICOMStore(); - const baseDicomVolumes = dicoms - .map((dicomSource) => { - const volumeInfo = dicomStore.volumeInfo[dicomSource.dataID]; - const modality = volumeInfo?.Modality as keyof typeof BASE_MODALITY_TYPES; - if (modality in BASE_MODALITY_TYPES) - return { - dicomSource, - priority: BASE_MODALITY_TYPES[modality]?.priority, - volumeInfo, - }; - return undefined; - }) - .filter(nonNullable) - .sort( - ( - { priority: a, volumeInfo: infoA }, - { priority: b, volumeInfo: infoB } - ) => { - const priorityDiff = a - b; - if (priorityDiff !== 0) return priorityDiff; - // same modality, then more slices preferred - if (!infoA.NumberOfSlices) return 1; - if (!infoB.NumberOfSlices) return -1; - return infoB.NumberOfSlices - infoA.NumberOfSlices; - } - ); - if (baseDicomVolumes.length) return baseDicomVolumes[0].dicomSource; - return undefined; -} - -function getStudyUID(volumeID: string) { - const dicomStore = useDICOMStore(); - const studyKey = dicomStore.volumeStudy[volumeID]; - return dicomStore.studyInfo[studyKey]?.StudyInstanceUID; -} - -function pickLoadableDataSources( - succeeded: Array> -) { - return succeeded.flatMap((result) => { - return result.data.filter(isLoadableResult); - }); -} - -function pickBaseDataSource( - succeeded: Array> -) { - const loadableDataSources = pickLoadableDataSources(succeeded); - const baseDicom = pickBaseDicom(loadableDataSources); - return baseDicom ?? loadableDataSources[0]; -} - -function pickOtherVolumesInStudy( - volumeID: string, - succeeded: Array> -) { - const targetStudyUID = getStudyUID(volumeID); - const dicomDataSources = pickLoadableDataSources(succeeded).filter( - ({ dataType }) => dataType === 'dicom' - ); - return dicomDataSources.filter((ds) => { - const sourceStudyUID = getStudyUID(ds.dataID); - return sourceStudyUID === targetStudyUID && ds.dataID !== volumeID; - }) as Array; -} - -// Layers a DICOM PET on a CT if found -function loadLayers( - primaryDataSource: VolumeResult, - succeeded: Array> -) { - if (primaryDataSource.dataType !== 'dicom') return; - const otherVolumesInStudy = pickOtherVolumesInStudy( - primaryDataSource.dataID, - succeeded - ); - const dicomStore = useDICOMStore(); - const primaryModality = - dicomStore.volumeInfo[primaryDataSource.dataID].Modality; - if (primaryModality !== 'CT') return; - // Look for one PET volume to layer with CT. Only one as there are often multiple "White Balance" corrected PET volumes. - const toLayer = otherVolumesInStudy.find((ds) => { - const otherModality = dicomStore.volumeInfo[ds.dataID].Modality; - return otherModality === 'PT'; - }); - if (!toLayer) return; - - const primarySelection = toDataSelection(primaryDataSource); - const layersStore = useLayersStore(); - const layerSelection = toDataSelection(toLayer); - layersStore.addLayer(primarySelection, layerSelection); -} - -// Loads DICOM SEG modalities as Segment Groups if found -function loadSegmentations( - primaryDataSource: VolumeResult, - succeeded: Array> -) { - const dicomStore = useDICOMStore(); - const otherSegVolumesInStudy = pickOtherVolumesInStudy( - primaryDataSource.dataID, - succeeded - ).filter((ds) => { - const modality = dicomStore.volumeInfo[ds.dataID].Modality; - if (!modality) return false; - return modality.trim() === 'SEG'; - }); - - const segmentGroupStore = useSegmentGroupStore(); - otherSegVolumesInStudy.forEach((ds) => { - const loadable = toDataSelection(ds); - segmentGroupStore.convertImageToLabelmap( - loadable, - toDataSelection(primaryDataSource) - ); - }); -} - const useLoadDataStore = defineStore('loadData', () => { const { startLoading, stopLoading, setError, isLoading } = useLoadingNotifications(); - const wrapWithLoading = void>(fn: T) => { - return async function wrapper(...args: any[]) { - try { - startLoading(); - await fn(...args); - } finally { - stopLoading(); - } - }; - }; - - const loadDataSources = wrapWithLoading(async (sources: DataSource[]) => { - const dataStore = useDatasetStore(); - - let results: ImportDataSourcesResult[]; - try { - results = await importDataSources(sources); - } catch (error) { - setError(error as Error); - return; - } - - const [succeeded, errored] = partitionResults(results); - - if (!dataStore.primarySelection && succeeded.length) { - const primaryDataSource = pickBaseDataSource(succeeded); - if (isVolumeResult(primaryDataSource)) { - const selection = toDataSelection(primaryDataSource); - dataStore.setPrimarySelection(selection); - loadLayers(primaryDataSource, succeeded); - loadSegmentations(primaryDataSource, succeeded); - } // then must be primaryDataSource.type === 'model' - } - - if (errored.length) { - const errorMessages = errored.map((errResult) => { - // pick first error - const [firstError] = errResult.errors; - // pick innermost dataset that errored - const name = getDataSourceName(firstError.inputDataStackTrace[0]); - // log error for debugging - logError(firstError.cause); - return `- ${name}: ${firstError.message}`; - }); - const failedError = new Error( - `These files failed to load:\n${errorMessages.join('\n')}` - ); - - setError(failedError); - } - }); + const segmentGroupExtension = ref(''); return { + segmentGroupExtension, isLoading, - loadDataSources, + startLoading, + stopLoading, + setError, }; }); diff --git a/src/store/segmentGroups.ts b/src/store/segmentGroups.ts index acd2c532..312d7c7c 100644 --- a/src/store/segmentGroups.ts +++ b/src/store/segmentGroups.ts @@ -396,7 +396,7 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { async function serialize(state: StateFile) { const { zip } = state; - // orderByParent is implicity preserved based on + // orderByParent is implicitly preserved based on // the order of serialized entries. const parents = Object.keys(orderByParent.value);