feat(URL): add param for initial series and sop uids to display (#3265)

* feat: Allow navigating to a specified series and sop instance

This was a feature in OHIF v2, so adding it to v3, albeit with new
parameters.

feat: Allow comma separated as well as repeated args params

* docs

* Test fixes

* feat: Navigate to SOP selected - PR fixes

* Updated docs

* PR fixes
This commit is contained in:
Bill Wallace 2023-04-05 12:59:56 -04:00 committed by GitHub
parent 226244a26c
commit 50ed96ff73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 284 additions and 47 deletions

View File

@ -22,8 +22,8 @@ import {
retrieveStudyMetadata,
deleteStudyMetadataPromise,
} from './retrieveStudyMetadata.js';
import StaticWadoClient from './utils/StaticWadoClient.js';
import getDirectURL from '../utils/getDirectURL.js';
import StaticWadoClient from './utils/StaticWadoClient';
import getDirectURL from '../utils/getDirectURL';
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
@ -90,7 +90,9 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
const implementation = {
initialize: ({ params, query }) => {
const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params;
const queryStudyInstanceUIDs = query.getAll('StudyInstanceUIDs');
const queryStudyInstanceUIDs = utils.splitComma(
query.getAll('StudyInstanceUIDs')
);
const StudyInstanceUIDs =
(queryStudyInstanceUIDs.length && queryStudyInstanceUIDs) ||

View File

@ -9,18 +9,19 @@ import { api } from 'dicomweb-client';
*/
export default class StaticWadoClient extends api.DICOMwebClient {
static studyFilterKeys = {
StudyInstanceUID: '0020000D',
PatientName: '00100010',
studyinstanceuid: '0020000D',
patientname: '00100010',
'00100020': 'mrn',
StudyDescription: '00081030',
StudyDate: '00080020',
ModalitiesInStudy: '00080061',
AccessionNumber: '00080050',
studydescription: '00081030',
studydate: '00080020',
modalitiesinstudy: '00080061',
accessionnumber: '00080050',
};
static seriesFilterKeys = {
SeriesInstanceUID: '0020000E',
SeriesNumber: '00200011',
seriesinstanceuid: '0020000E',
seriesnumber: '00200011',
modality: '00080060',
};
constructor(qidoConfig) {
@ -37,15 +38,18 @@ export default class StaticWadoClient extends api.DICOMwebClient {
async searchForStudies(options) {
if (!this.staticWado) return super.searchForStudies(options);
let searchResult = await super.searchForStudies(options);
const searchResult = await super.searchForStudies(options);
const { queryParams } = options;
if (!queryParams) return searchResult;
const lowerParams = this.toLowerParams(queryParams);
const filtered = searchResult.filter(study => {
for (const key of Object.keys(StaticWadoClient.studyFilterKeys)) {
if (
!this.filterItem(
key,
queryParams,
lowerParams,
study,
StaticWadoClient.studyFilterKeys
)
@ -61,16 +65,18 @@ export default class StaticWadoClient extends api.DICOMwebClient {
async searchForSeries(options) {
if (!this.staticWado) return super.searchForSeries(options);
let searchResult = await super.searchForSeries(options);
const searchResult = await super.searchForSeries(options);
const { queryParams } = options;
if (!queryParams) return searchResult;
const filtered = searchResult.filter(study => {
const lowerParams = this.toLowerParams(queryParams);
const filtered = searchResult.filter(series => {
for (const key of Object.keys(StaticWadoClient.seriesFilterKeys)) {
if (
!this.filterItem(
key,
queryParams,
study,
lowerParams,
series,
StaticWadoClient.seriesFilterKeys
)
) {
@ -136,13 +142,13 @@ export default class StaticWadoClient extends api.DICOMwebClient {
/**
* Filters the return list by the query parameters.
*
* @param {*} key
* @param {*} queryParams
* @param anyCaseKey - a possible search key
* @param queryParams -
* @param {*} study
* @param {*} sourceFilterMap
* @returns
*/
filterItem(key, queryParams, study, sourceFilterMap) {
filterItem(key: string, queryParams, study, sourceFilterMap) {
const altKey = sourceFilterMap[key] || key;
if (!queryParams) return true;
const testValue = queryParams[key] || queryParams[altKey];
@ -153,6 +159,15 @@ export default class StaticWadoClient extends api.DICOMwebClient {
return this.compareDateRange(testValue, valueElem.Value[0]);
}
const value = valueElem.Value;
return this.compareValues(testValue, value) && true;
return this.compareValues(testValue, value);
}
/** Converts the query parameters to lower case query parameters */
toLowerParams(queryParams: Record<string, unknown>): Record<string, unknown> {
const lowerParams = {};
Object.entries(queryParams).forEach(([key, value]) => {
lowerParams[key.toLowerCase()] = value;
});
return lowerParams;
}
}

View File

@ -7,7 +7,7 @@ const defaultProtocol = {
hasUpdatedPriorsInformation: false,
name: 'Default',
createdDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2023-04-01',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
@ -33,8 +33,6 @@ const defaultProtocol = {
},
displaySetSelectors: {
defaultDisplaySetId: {
// Unused currently
imageMatchingRules: [],
// Matches displaysets, NOT series
seriesMatchingRules: [
// Try to match series with images by default, to prevent weird display
@ -45,6 +43,15 @@ const defaultProtocol = {
greaterThan: { value: 0 },
},
},
// This display set will select the specified items by preference
// It has no affect if nothing is specified in the URL.
{
attribute: 'isDisplaySetFromUrl',
weight: 10,
constraint: {
equals: true,
},
},
],
// Can be used to select matching studies
// studyMatchingRules: [],
@ -65,7 +72,13 @@ const defaultProtocol = {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
// initialImageOptions: {
// This will specify the initial image options index if it matches in the URL
// and will otherwise not specify anything.
initialImageOptions: {
custom: 'sopInstanceLocation',
},
// Other options for initialImageOptions, which can be included in the default
// custom attribute, or can be provided directly.
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },

View File

@ -133,7 +133,6 @@ function modeFactory() {
const {
toolGroupService,
syncGroupService,
toolbarService,
segmentationService,
cornerstoneViewportService,
} = servicesManager.services;

View File

@ -30,6 +30,10 @@ class ImageSet {
writable: false,
value: guid(), // Unique ID of the instance
});
this.instances = images;
this.instance = images[0];
this.StudyInstanceUID = this.instance?.StudyInstanceUID;
}
getUID() {
@ -56,7 +60,7 @@ class ImageSet {
}
}
getNumImages = () => this.images.length
getNumImages = () => this.images.length;
getImage(index) {
return this.images[index];

View File

@ -6,6 +6,10 @@ import IDisplaySet from '../DisplaySetService/IDisplaySet';
import { CommandsManager } from '../../classes';
import ServicesManager from '../ServicesManager';
import * as HangingProtocol from '../../types/HangingProtocol';
import {
isDisplaySetFromUrl,
sopInstanceLocation,
} from './isDisplaySetFromUrl';
type Protocol = HangingProtocol.Protocol | HangingProtocol.ProtocolGenerator;
@ -81,6 +85,14 @@ export default class HangingProtocolService extends PubSubService {
// we can add more advanced checking here
callback: displaySet => displaySet.isReconstructable ?? false,
},
isDisplaySetFromUrl: {
name: 'Checks if the display set is as specified in the URL',
callback: isDisplaySetFromUrl,
},
sopInstanceLocation: {
name: 'Gets the position of the specified sop instance',
callback: sopInstanceLocation,
},
};
listeners = {};
registeredImageLoadStrategies = {};
@ -648,6 +660,39 @@ export default class HangingProtocolService extends PubSubService {
return viewportsToUpdate;
}
/**
* Gets a computed options value, or a copy of the options
* This allows computing values such as the initial image index to use
* based on custom attribute functions, the same as the validators.
* Computing individual values is something that can be declared statically
* as long as the named functions are provided ahead of time, which is much
* simpler than recomputing the entire protocol.
*/
public getComputedOptions(
options: Record<string, unknown>,
displaySetUIDs: string[]
) {
const computed = { ...options };
let displaySets;
for (const key in computed) {
const value = computed[key];
if (!value) continue;
if (value.custom) {
if (!displaySets) {
displaySets = this.displaySets.filter(
displaySet =>
displaySetUIDs.indexOf(displaySet.displaySetInstanceUID) !== -1
);
}
computed[key] = this.customAttributeRetrievalCallbacks[
value.custom
].callback.call(computed, displaySets);
if (computed[key] === undefined) computed[key] = computed.defaultValue;
}
}
return computed;
}
/**
* It applied the protocol to the current studies and display sets based on the
* protocolId that is provided.

View File

@ -0,0 +1,46 @@
import { getSplitParam } from '../../utils';
/** Indicates if the given display set is the one specified in the
* displaySet parameter in the URL
* The parameters are:
* initialSeriesInstanceUID
* initialSOPInstanceUID
*/
const isDisplaySetFromUrl = (displaySet): boolean => {
const params = new URLSearchParams(window.location.search);
const initialSeriesInstanceUID = getSplitParam(
'initialseriesinstanceuid',
params
);
const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid', params);
if (!initialSeriesInstanceUID && !initialSOPInstanceUID) return false;
const isSeriesMatch =
!initialSeriesInstanceUID ||
initialSeriesInstanceUID.some(
seriesUID => displaySet.SeriesInstanceUID === seriesUID
);
const isSopMatch =
!initialSOPInstanceUID ||
displaySet.instances?.some?.(instance =>
initialSOPInstanceUID.some(sopUID => sopUID === instance.SOPInstanceUID)
);
return isSeriesMatch && isSopMatch;
};
/** Returns the index location of the requested image, or the defaultValue in this.
* Returns undefined to fallback to the defaultValue
*/
function sopInstanceLocation(displaySets) {
const displaySet = displaySets[0];
if (!displaySet) return;
const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid');
if (!initialSOPInstanceUID) return;
const index = displaySet.instances.findIndex(instance =>
initialSOPInstanceUID.includes(instance.SOPInstanceUID)
);
// Need to return in the initial position specified format.
return index === -1 ? undefined : { index };
}
export { isDisplaySetFromUrl, sopInstanceLocation };

View File

@ -70,8 +70,8 @@ export type ConstraintValue =
| boolean
| []
| {
value: string | number | boolean | [];
};
value: string | number | boolean | [];
};
export type Constraint = {
// This value exactly
@ -135,19 +135,27 @@ export type SyncGroup = {
target?: boolean;
};
/** Declares a custom option, that is a computed type value */
export type CustomOptionAttribute<T> = {
custom: string;
defaultValue?: T;
};
export type CustomOption<T> = CustomOptionAttribute<T> | T;
export type initialImageOptions = {
index?: number;
preset?: string; // todo: type more
};
export type ViewportOptions = {
toolGroupId?: string;
viewportType?: string;
toolGroupId?: CustomOption<string>;
viewportType?: CustomOption<string>;
id?: string;
orientation?: string;
orientation?: CustomOption<string>;
viewportId?: string;
initialImageOptions?: initialImageOptions;
syncGroups?: SyncGroup[];
initialImageOptions?: CustomOption<initialImageOptions>;
syncGroups?: CustomOption<SyncGroup>[];
customViewportProps?: Record<string, unknown>;
// Set to true to allow non-matching drag and drop or options provided
// from options.displaySetSelectorsMap

View File

@ -33,6 +33,7 @@ import {
seriesSortCriteria,
} from './sortStudy';
import { subscribeToNextViewportGridChange } from './subscribeToNextViewportGridChange';
import { splitComma, getSplitParam } from './splitComma';
// Commented out unused functionality.
// Need to implement new mechanism for derived displaySets using the displaySetManager.
@ -71,6 +72,8 @@ const utils = {
roundNumber,
downloadCSVReport,
subscribeToNextViewportGridChange,
splitComma,
getSplitParam,
};
export {
@ -100,6 +103,8 @@ export {
debounce,
roundNumber,
downloadCSVReport,
splitComma,
getSplitParam,
};
export default utils;

View File

@ -13,6 +13,8 @@ describe('Top level exports', () => {
'sortStudyInstances',
'sortStudySeries',
'sortingCriteria',
'splitComma',
'getSplitParam',
'isLowPriorityModality',
'writeScript',
'debounce',

View File

@ -0,0 +1,31 @@
/** Splits a list of stirngs by commas within the strings */
const splitComma = (strings: string[]): string[] => {
if (!strings) return null;
for (let i = 0; i < strings.length; i++) {
const comma = strings[i].indexOf(',');
if (comma !== -1) {
const splits = strings[i].split(/,/);
strings.splice(i, 1, ...splits);
}
}
return strings;
};
/**
* Returns an array of the comma split parameters from the given URL search params
* @param lowerCaseKey - lower case search parameter value
* @param params - URLSearchParams
* @returns Array of comma split items matching, or null
*/
const getSplitParam = (
lowerCaseKey: string,
params = new URLSearchParams(window.location.search)
): string[] => {
return splitComma(
[...params]
.find(([key, value]) => key.toLowerCase() === lowerCaseKey && value)
?.slice?.(1)
);
};
export { splitComma, getSplitParam };

View File

@ -110,17 +110,46 @@ You can open more than one study in the Viewer by adding the `StudyInstanceUIDs`
:::tip
You can ues this feature to open a current and prior study in the Viewer.
Read more in the [Hanging Protocol Module](../platform/extensions/modules/hpModule.md#matching-on-prior-study-with-uid) section
Read more in the [Hanging Protocol Module](../platform/extensions/modules/hpModule.md#matching-on-prior-study-with-uid) section. You can also use commas to separate
values.
:::
### SeriesInstanceUIDs
### SeriesInstanceUID and initialSeriesInstanceUID
Sometimes you need to only open a specific series in a study, you can do that by
Sometimes you need to only retrieve a specific series in a study, you can do
that by providing series level QIDO query parameters in the URL such as
SeriesInstanceUID or SeriesNumber. This does NOT work with instance or study
level parameters. For example:
```js
/viewer?StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125095722.1&SeriesInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125095748.1
http://localhost:3000/viewer?StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1&SeriesInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113545.4
```
This will only open the viewer with one series (one displaySet).
This will only open the viewer with one series (one displaySet) loaded, and no
queries made for any other series.
Alternatively, sometimes you want to just open the study on a specified series
and/or display a particular sop instance, which you can accomplish using:
`initialSeriesInstanceUID` and/or `initialSOPInstanceUID`
to select the series to open on, but allowing other
series to be present in the study browser panel. This is the same behaviour
as in OHIF 2.0, albeit on different URL parameters. For example:
```js
http://localhost:3000/viewer?StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1&initialSeriesInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113545.4
```
Note that you can combine these, if you want to load a specific set of series
plus show an initial one as the first one selected, for example:
```js
http://localhost:3000/viewer?StudyInstanceUIDs=1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1&SeriesInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113545.4&initialSopInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113546.1
```
### hangingProtocolId
You can select the initial hanging protocol to apply by using the
hangingProtocolId parameter. The selected parameter must be available in a
hangingProtocolModule registration, but does not have to be active.

View File

@ -218,7 +218,8 @@ the display set is the same as the other viewports, but the
from the display set selector which isn't already filling a view.
## Custom Attribute
In some situations, you might want to match based on a custom attribute and not the DICOM tags. For instance,
In some situations, you might want to match based on a custom
attribute and not the DICOM tags. For instance,
if you have assigned a `timepointId` to each study, and you want to match based on it.
Good news is that, in `OHIF-v3` you can define you custom attribute and use it for matching.
@ -294,3 +295,18 @@ function modeFactory() {
};
}
```
### Custom Attributes for Viewport Options
The custom attributes can also be used for viewport options. This example,
from the default hanging protocol navigates the image to the image
specified in the URL:
```javascript
viewportOptions: {
initialImageOptions: {
// custom attribute name is selected by 'custom'
custom: 'sopInstanceLocation',
// This is the value returned if the above doesn't return anything
defaultValue: { index: 5 },
```

View File

@ -1,5 +1,5 @@
describe('OHIF HP', () => {
beforeEach(() => {
const beforeSetup = () => {
cy.checkStudyRouteInViewer(
'1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1',
'&hangingProtocolId=@ohif/hp-extension.mn'
@ -7,15 +7,19 @@ describe('OHIF HP', () => {
cy.expectMinimumThumbnails(3);
cy.initCornerstoneToolsAliases();
cy.initCommonElementsAliases();
});
};
it('Should display 3 up', () => {
beforeSetup();
cy.get('[data-cy="viewport-pane"]')
.its('length')
.should('be.eq', 3);
});
it('Should navigate next/previous stage', () => {
beforeSetup();
cy.get('body').type(',');
cy.wait(250);
cy.get('[data-cy="viewport-pane"]')
@ -28,4 +32,19 @@ describe('OHIF HP', () => {
.its('length')
.should('be.eq', 2);
});
it('Should navigate to display set specified', () => {
// This filters by series instance UID, meaning there will only be 1 thumbnail
// It applies the initial SOP instance, navigating to that image
cy.checkStudyRouteInViewer(
'1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1',
'&SeriesInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113545.4&initialSopInstanceUID=1.3.6.1.4.1.25403.345050719074.3824.20170125113546.1'
);
cy.expectMinimumThumbnails(1);
cy.initCornerstoneToolsAliases();
cy.initCommonElementsAliases();
// The specified series/sop UID's are index 101, so ensure that image is displayed
cy.get('@viewportInfoTopRight').should('contains.text', 'I:6');
});
});

View File

@ -99,9 +99,10 @@ function ViewerViewportGrid(props) {
return {
displaySetInstanceUIDs: displaySetUIDsToHang,
displaySetOptions: displaySetUIDsToHangOptions,
viewportOptions: {
...viewportOptions,
},
viewportOptions: hangingProtocolService.getComputedOptions(
viewportOptions,
displaySetUIDsToHang
),
};
};

View File

@ -279,8 +279,10 @@ export default function ModeRoute({
const filters =
Array.from(query.keys()).reduce(
(acc: Record<string, string>, val: string) => {
if (val !== 'StudyInstanceUIDs') {
if (['seriesInstanceUID', 'SeriesInstanceUID'].includes(val)) {
const lowerVal = val.toLowerCase();
if (lowerVal !== 'studyinstanceuids') {
// Not sure why the case matters here - it doesn't in the URL
if (lowerVal === 'seriesinstanceuid') {
return {
...acc,
seriesInstanceUID: query.get(val),