feat: Merge Data Source (#3788)

Add the ability to merge two different series queries to generate a complete study query result.  Provides basic support for other types of merges, but those aren't yet added as full features.
This commit is contained in:
Igor Octaviano 2023-11-24 10:46:54 -03:00 committed by GitHub
parent 1be5613946
commit c4ff2c2f09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 604 additions and 20 deletions

View File

@ -45,6 +45,7 @@ module.exports = {
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',
'@babel/transform-destructuring',
'@babel/plugin-transform-runtime',
'@babel/plugin-transform-typescript',
],

View File

@ -0,0 +1 @@
module.exports = require('../../babel.config.js');

View File

@ -0,0 +1,17 @@
const base = require('../../jest.config.base.js');
const pkg = require('./package');
module.exports = {
...base,
name: pkg.name,
displayName: pkg.name,
moduleNameMapper: {
...base.moduleNameMapper,
'@ohif/(.*)': '<rootDir>/../../platform/$1/src',
},
// rootDir: "../.."
// testMatch: [
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
// "<rootDir>/platform/app/**/*.test.js"
// ]
};

View File

@ -1,14 +1,14 @@
import ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
const menus = [
{
id: 'one',
selector: ({ value }) => value === 'one',
selector: ({ value } = {}) => value === 'one',
items: [],
},
{
id: 'two',
selector: ({ value }) => value === 'two',
selector: ({ value } = {}) => value === 'two',
items: [],
},
{
@ -17,13 +17,13 @@ const menus = [
},
];
const menuBuilder = new ContextMenuItemsBuilder();
describe('ContextMenuItemsBuilder', () => {
test('findMenuDefault', () => {
expect(menuBuilder.findMenuDefault(menus, {})).toBe(menus[2]);
expect(menuBuilder.findMenuDefault(menus, { value: 'two' })).toBe(menus[1]);
expect(menuBuilder.findMenuDefault([], {})).toBeUndefined();
expect(menuBuilder.findMenuDefault(undefined, undefined)).toBeNull();
expect(ContextMenuItemsBuilder.findMenuDefault(menus, {})).toBe(menus[2]);
expect(
ContextMenuItemsBuilder.findMenuDefault(menus, { selectorProps: { value: 'two' } })
).toBe(menus[1]);
expect(ContextMenuItemsBuilder.findMenuDefault([], {})).toBeUndefined();
expect(ContextMenuItemsBuilder.findMenuDefault(undefined, undefined)).toBeNull();
});
});

View File

@ -140,7 +140,7 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
instances: {
search: (studyInstanceUid, queryParameters) => {
qidoDicomWebClient.headers = getAuthrorizationHeader();
qidoSearch.call(undefined, qidoDicomWebClient, studyInstanceUid, null, queryParameters);
return qidoSearch.call(undefined, qidoDicomWebClient, studyInstanceUid, null, queryParameters);
},
},
},
@ -262,7 +262,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction
sortFunction,
dicomWebConfig
);
// first naturalize the data
@ -314,6 +315,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
Object.keys(instancesPerSeries).forEach(seriesInstanceUID =>
DicomMetadataStore.addInstances(instancesPerSeries[seriesInstanceUID], madeInClient)
);
return seriesSummaryMetadata;
},
_retrieveSeriesMetadataAsync: async (
@ -333,7 +336,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction
sortFunction,
dicomWebConfig
);
/**
@ -444,6 +448,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
);
await Promise.all(seriesDeliveredPromises);
setSuccessFlag();
return seriesSummaryMetadata;
},
deleteStudyMetadataPromise,
getImageIdsForDisplaySet(displaySet) {

View File

@ -25,7 +25,8 @@ export function retrieveStudyMetadata(
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction
sortFunction,
dicomWebConfig = {}
) {
// @TODO: Whenever a study metadata request has failed, its related promise will be rejected once and for all
// and further requests for that metadata will always fail. On failure, we probably need to remove the
@ -38,9 +39,11 @@ export function retrieveStudyMetadata(
throw new Error(`${moduleName}: Required 'StudyInstanceUID' parameter not provided.`);
}
const promiseId = `${dicomWebConfig.name}:${StudyInstanceUID}`;
// Already waiting on result? Return cached promise
if (StudyMetaDataPromises.has(StudyInstanceUID)) {
return StudyMetaDataPromises.get(StudyInstanceUID);
if (StudyMetaDataPromises.has(promiseId)) {
return StudyMetaDataPromises.get(promiseId);
}
let promise;
@ -71,7 +74,7 @@ export function retrieveStudyMetadata(
}
// Store the promise in cache
StudyMetaDataPromises.set(StudyInstanceUID, promise);
StudyMetaDataPromises.set(promiseId, promise);
return promise;
}

View File

@ -0,0 +1,203 @@
import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core';
import {
mergeMap,
callForAllDataSourcesAsync,
callForAllDataSources,
callForDefaultDataSource,
callByRetrieveAETitle,
createMergeDataSourceApi,
} from './index';
jest.mock('@ohif/core');
describe('MergeDataSource', () => {
let path,
sourceName,
mergeConfig,
extensionManager,
series1,
series2,
series3,
series4,
mergeKey,
tagFunc,
dataSourceAndSeriesMap,
dataSourceAndUIDsMap,
dataSourceAndDSMap,
pathSync;
beforeAll(() => {
path = 'query.series.search';
pathSync = 'getImageIdsForInstance';
tagFunc = jest.fn((data, sourceName) =>
data.map(item => ({ ...item, RetrieveAETitle: sourceName }))
);
sourceName = 'dicomweb1';
mergeKey = 'seriesInstanceUid';
series1 = { [mergeKey]: '123' };
series2 = { [mergeKey]: '234' };
series3 = { [mergeKey]: '345' };
series4 = { [mergeKey]: '456' };
mergeConfig = {
seriesMerge: {
dataSourceNames: ['dicomweb1', 'dicomweb2'],
defaultDataSourceName: 'dicomweb1',
},
};
dataSourceAndSeriesMap = {
dataSource1: series1,
dataSource2: series2,
dataSource3: series3,
};
dataSourceAndUIDsMap = {
dataSource1: ['123'],
dataSource2: ['234'],
dataSource3: ['345'],
};
dataSourceAndDSMap = {
dataSource1: {
displaySet: {
StudyInstanceUID: '123',
SeriesInstanceUID: '123',
},
},
dataSource2: {
displaySet: {
StudyInstanceUID: '234',
SeriesInstanceUID: '234',
},
},
dataSource3: {
displaySet: {
StudyInstanceUID: '345',
SeriesInstanceUID: '345',
},
},
};
extensionManager = {
dataSourceDefs: {
dataSource1: {
sourceName: 'dataSource1',
configuration: {},
},
dataSource2: {
sourceName: 'dataSource2',
configuration: {},
},
dataSource3: {
sourceName: 'dataSource3',
configuration: {},
},
},
getDataSources: jest.fn(dataSourceName => [
{
[path]: jest.fn().mockResolvedValue([dataSourceAndSeriesMap[dataSourceName]]),
},
]),
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('callForAllDataSourcesAsync', () => {
it('should call the correct functions and return the merged data', async () => {
/** Arrange */
extensionManager.getDataSources = jest.fn(dataSourceName => [
{
[path]: jest.fn().mockResolvedValue([dataSourceAndSeriesMap[dataSourceName]]),
},
]);
/** Act */
const data = await callForAllDataSourcesAsync({
mergeMap,
path,
args: [],
extensionManager,
dataSourceNames: ['dataSource1', 'dataSource2'],
});
/** Assert */
expect(extensionManager.getDataSources).toHaveBeenCalledTimes(2);
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource1');
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2');
expect(data).toEqual([series1, series2]);
});
});
describe('callForAllDataSources', () => {
it('should call the correct functions and return the merged data', () => {
/** Arrange */
extensionManager.getDataSources = jest.fn(dataSourceName => [
{
[pathSync]: () => dataSourceAndUIDsMap[dataSourceName],
},
]);
/** Act */
const data = callForAllDataSources({
path: pathSync,
args: [],
extensionManager,
dataSourceNames: ['dataSource2', 'dataSource3'],
});
/** Assert */
expect(extensionManager.getDataSources).toHaveBeenCalledTimes(2);
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2');
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource3');
expect(data).toEqual(['234', '345']);
});
});
describe('callForDefaultDataSource', () => {
it('should call the correct function and return the data', () => {
/** Arrange */
extensionManager.getDataSources = jest.fn(dataSourceName => [
{
[pathSync]: () => dataSourceAndUIDsMap[dataSourceName],
},
]);
/** Act */
const data = callForDefaultDataSource({
path: pathSync,
args: [],
extensionManager,
defaultDataSourceName: 'dataSource2',
});
/** Assert */
expect(extensionManager.getDataSources).toHaveBeenCalledTimes(1);
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2');
expect(data).toEqual(['234']);
});
});
describe('callByRetrieveAETitle', () => {
it('should call the correct function and return the data', () => {
/** Arrange */
DicomMetadataStore.getSeries.mockImplementationOnce(() => [series2]);
extensionManager.getDataSources = jest.fn(dataSourceName => [
{
[pathSync]: () => dataSourceAndUIDsMap[dataSourceName],
},
]);
/** Act */
const data = callByRetrieveAETitle({
path: pathSync,
args: [dataSourceAndDSMap['dataSource2']],
extensionManager,
defaultDataSourceName: 'dataSource2',
});
/** Assert */
expect(extensionManager.getDataSources).toHaveBeenCalledTimes(1);
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2');
expect(data).toEqual(['234']);
});
});
});

View File

@ -0,0 +1,252 @@
import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core';
import { get, uniqBy } from 'lodash';
import {
MergeConfig,
CallForAllDataSourcesAsyncOptions,
CallForAllDataSourcesOptions,
CallForDefaultDataSourceOptions,
CallByRetrieveAETitleOptions,
MergeMap,
} from './types';
export const mergeMap: MergeMap = {
'query.studies.search': {
mergeKey: 'studyInstanceUid',
tagFunc: x => x,
},
'query.series.search': {
mergeKey: 'seriesInstanceUid',
tagFunc: (series, sourceName) => {
series.forEach(series => {
series.RetrieveAETitle = sourceName;
DicomMetadataStore.updateSeriesMetadata(series);
});
return series;
},
},
};
/**
* Calls all data sources asynchronously and merges the results.
* @param {CallForAllDataSourcesAsyncOptions} options - The options for calling all data sources.
* @param {string} options.path - The path to the function to be called on each data source.
* @param {unknown[]} options.args - The arguments to be passed to the function.
* @param {ExtensionManager} options.extensionManager - The extension manager.
* @param {string[]} options.dataSourceNames - The names of the data sources to be called.
* @returns {Promise<unknown[]>} - A promise that resolves to the merged data from all data sources.
*/
export const callForAllDataSourcesAsync = async ({
mergeMap,
path,
args,
extensionManager,
dataSourceNames,
}: CallForAllDataSourcesAsyncOptions) => {
const { mergeKey, tagFunc } = mergeMap[path] || { tagFunc: x => x };
const dataSourceDefs = Object.values(extensionManager.dataSourceDefs);
const promises = [];
const mergedData = [];
for (const dataSourceDef of dataSourceDefs) {
const { configuration, sourceName } = dataSourceDef;
if (!!configuration && dataSourceNames.includes(sourceName)) {
const [dataSource] = extensionManager.getDataSources(sourceName);
const func = get(dataSource, path);
const promise = func.apply(dataSource, args);
promises.push(promise.then(data => mergedData.push(tagFunc(data, sourceName))));
}
}
await Promise.allSettled(promises);
return uniqBy(mergedData.flat(), obj => obj[mergeKey]);
};
/**
* Calls all data sources that match the provided names and merges their data.
* @param options - The options for calling all data sources.
* @param options.path - The path to the function to be called on each data source.
* @param options.args - The arguments to be passed to the function.
* @param options.extensionManager - The extension manager instance.
* @param options.dataSourceNames - The names of the data sources to be called.
* @returns The merged data from all the matching data sources.
*/
export const callForAllDataSources = ({
path,
args,
extensionManager,
dataSourceNames,
}: CallForAllDataSourcesOptions) => {
const dataSourceDefs = Object.values(extensionManager.dataSourceDefs);
const mergedData = [];
for (const dataSourceDef of dataSourceDefs) {
const { configuration, sourceName } = dataSourceDef;
if (!!configuration && dataSourceNames.includes(sourceName)) {
const [dataSource] = extensionManager.getDataSources(sourceName);
const func = get(dataSource, path);
const data = func.apply(dataSource, args);
mergedData.push(data);
}
}
return mergedData.flat();
};
/**
* Calls the default data source function specified by the given path with the provided arguments.
* @param {CallForDefaultDataSourceOptions} options - The options for calling the default data source.
* @param {string} options.path - The path to the function within the default data source.
* @param {unknown[]} options.args - The arguments to pass to the function.
* @param {string} options.defaultDataSourceName - The name of the default data source.
* @param {ExtensionManager} options.extensionManager - The extension manager instance.
* @returns {unknown} - The result of calling the default data source function.
*/
export const callForDefaultDataSource = ({
path,
args,
defaultDataSourceName,
extensionManager,
}: CallForDefaultDataSourceOptions) => {
const [dataSource] = extensionManager.getDataSources(defaultDataSourceName);
const func = get(dataSource, path);
return func.apply(dataSource, args);
};
/**
* Calls the data source specified by the RetrieveAETitle of the given display set.
* @typedef {Object} CallByRetrieveAETitleOptions
* @property {string} path - The path of the method to call on the data source.
* @property {unknown[]} args - The arguments to pass to the method.
* @property {string} defaultDataSourceName - The name of the default data source.
* @property {ExtensionManager} extensionManager - The extension manager.
*/
export const callByRetrieveAETitle = ({
path,
args,
defaultDataSourceName,
extensionManager,
}: CallByRetrieveAETitleOptions) => {
const [displaySet] = args;
const seriesMetadata = DicomMetadataStore.getSeries(
displaySet.StudyInstanceUID,
displaySet.SeriesInstanceUID
);
const [dataSource] = extensionManager.getDataSources(
seriesMetadata.RetrieveAETitle || defaultDataSourceName
);
return dataSource[path](...args);
};
function createMergeDataSourceApi(
mergeConfig: MergeConfig,
UserAuthenticationService: unknown,
extensionManager
) {
const { seriesMerge } = mergeConfig;
const { dataSourceNames, defaultDataSourceName } = seriesMerge;
const implementation = {
initialize: (...args: unknown[]) =>
callForAllDataSources({ path: 'initialize', args, extensionManager, dataSourceNames }),
query: {
studies: {
search: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,
path: 'query.studies.search',
args,
extensionManager,
dataSourceNames,
}),
},
series: {
search: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,
path: 'query.series.search',
args,
extensionManager,
dataSourceNames,
}),
},
instances: {
search: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,
path: 'query.instances.search',
args,
extensionManager,
dataSourceNames,
}),
},
},
retrieve: {
bulkDataURI: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,
path: 'retrieve.bulkDataURI',
args,
extensionManager,
dataSourceNames,
}),
directURL: (...args: unknown[]) =>
callForDefaultDataSource({
path: 'retrieve.directURL',
args,
defaultDataSourceName,
extensionManager,
}),
series: {
metadata: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,
path: 'retrieve.series.metadata',
args,
extensionManager,
dataSourceNames,
}),
},
},
store: {
dicom: (...args: unknown[]) =>
callForDefaultDataSource({
path: 'store.dicom',
args,
defaultDataSourceName,
extensionManager,
}),
},
deleteStudyMetadataPromise: (...args: unknown[]) =>
callForAllDataSources({
path: 'deleteStudyMetadataPromise',
args,
extensionManager,
dataSourceNames,
}),
getImageIdsForDisplaySet: (...args: unknown[]) =>
callByRetrieveAETitle({
path: 'getImageIdsForDisplaySet',
args,
defaultDataSourceName,
extensionManager,
}),
getImageIdsForInstance: (...args: unknown[]) =>
callByRetrieveAETitle({
path: 'getImageIdsForDisplaySet',
args,
defaultDataSourceName,
extensionManager,
}),
getStudyInstanceUIDs: (...args: unknown[]) =>
callForAllDataSources({
path: 'getStudyInstanceUIDs',
args,
extensionManager,
dataSourceNames,
}),
};
return IWebApiDataSource.create(implementation);
}
export { createMergeDataSourceApi };

View File

@ -0,0 +1,44 @@
import { ExtensionManager } from '@ohif/core';
export type MergeMap = {
[key: string]: {
mergeKey: string;
tagFunc: (data: unknown[], sourceName: string) => unknown[];
};
};
export type CallForAllDataSourcesAsyncOptions = {
mergeMap: object;
path: string;
args: unknown[];
dataSourceNames: string[];
extensionManager: ExtensionManager;
};
export type CallForAllDataSourcesOptions = {
path: string;
args: unknown[];
dataSourceNames: string[];
extensionManager: ExtensionManager;
};
export type CallForDefaultDataSourceOptions = {
path: string;
args: unknown[];
defaultDataSourceName: string;
extensionManager: ExtensionManager;
};
export type CallByRetrieveAETitleOptions = {
path: string;
args: unknown[];
defaultDataSourceName: string;
extensionManager: ExtensionManager;
};
export type MergeConfig = {
seriesMerge: {
dataSourceNames: string[];
defaultDataSourceName: string;
};
};

View File

@ -6,6 +6,7 @@ import { createDicomWebApi } from './DicomWebDataSource/index.js';
import { createDicomJSONApi } from './DicomJSONDataSource/index.js';
import { createDicomLocalApi } from './DicomLocalDataSource/index.js';
import { createDicomWebProxyApi } from './DicomWebProxyDataSource/index.js';
import { createMergeDataSourceApi } from './MergeDataSource/index';
/**
*
@ -32,6 +33,11 @@ function getDataSourcesModule() {
type: 'localApi',
createDataSource: createDicomLocalApi,
},
{
name: 'merge',
type: 'mergeApi',
createDataSource: createMergeDataSourceApi,
},
];
}

View File

@ -464,7 +464,8 @@ export default class ExtensionManager extends PubSubService {
const { userAuthenticationService } = this._servicesManager.services;
const dataSourceInstance = module.createDataSource(
dataSourceDef.configuration,
userAuthenticationService
userAuthenticationService,
this
);
this.dataSourceMap[dataSourceDef.sourceName] = [dataSourceInstance];

View File

@ -186,6 +186,18 @@ const BaseImplementation = {
madeInClient,
});
},
updateSeriesMetadata(seriesMetadata) {
const { StudyInstanceUID, SeriesInstanceUID } = seriesMetadata;
const series = _getSeries(StudyInstanceUID, SeriesInstanceUID);
if (!series) {
return;
}
const study = _getStudy(StudyInstanceUID);
if (study) {
study.setSeriesMetadata(SeriesInstanceUID, seriesMetadata);
}
},
addSeriesMetadata(seriesSummaryMetadata, madeInClient = false) {
if (!seriesSummaryMetadata || !seriesSummaryMetadata.length || !seriesSummaryMetadata[0]) {
return;

View File

@ -81,7 +81,6 @@ You can take a look at `dicomweb` data source implementation to get an idea
`extensions/default/src/DicomWebDataSource/index.js` but here here are some
important api endpoints that you need to implement:
- `initialize`: This method is called when the data source is first created in the mode.tsx, it is used to initialize the data source and set the configuration. For instance, `dicomwebDatasource` uses this method to grab the StudyInstanceUID from the URL and set it as the active study, as opposed to `dicomJSONDatasource` which uses url in the browser to fetch the data and store it in a cache
- `query.studies.search`: This is used in the study panel on the left to fetch the prior studies for the same MRN which is then used to display on the `All` tab. it is also used in the Worklist to show all the studies from the server.
- `query.series.search`: This is used to fetch the series information for a given study that is expanded in the Worklist.
@ -89,8 +88,6 @@ important api endpoints that you need to implement:
- `retrieve.series.metadata`: It is a crucial end point that is used to fetch series level metadata which for hanging displaySets and displaySet creation.
- `store.dicom`: If you don't need store functionality, you can skip this method. This is used to store the data in the backend.
## Static WADO Client
If the configuration for the data source has the value staticWado set, then it
@ -172,3 +169,44 @@ extensionManager.updateDataSourceConfiguration( "dicomweb",
},
);
```
## Merge Data Source
The built-in merge data source is a useful tool for combining results from multiple data sources.
Currently, this data source only supports merging at the series level. This means that series from data source 'A'
and series from data source 'B' will be retrieved under the same study. If the same series exists in both data sources,
the first series arrived is the one that gets stored, and any other conflicting series will be ignored.
The merge data source is particularly useful when dealing with derived data that is generated and stored in different servers.
For example, it can be used to retrieve annotation series from one data source and input data (images) from another data source.
A default data source can be defined as shown below. This allows defining which of the servers should be the
fallback server in case something goes wrong.
Configuration Example:
```js
window.config = {
...
dataSources: [
{
sourceName: 'merge',
namespace: '@ohif/extension-default.dataSourcesModule.merge',
configuration: {
name: 'merge',
friendlyName: 'Merge dicomweb-1 and dicomweb-2 data at the series level',
seriesMerge: {
dataSourceNames: ['dicomweb-1', 'dicomweb-2'],
defaultDataSourceName: 'dicomweb-1'
},
},
},
{
sourceName: 'dicomweb-1',
...
},
{
sourceName: 'dicomweb-2',
...
},
],
};
```