feat(studyPrefetcher): Study Prefetcher (#4206)
This commit is contained in:
1 parent
257cc477cd
commit
2048b19484
24 files changed
+992
-98
No files matched your search
@@ -12,7 +12,14 @@ window.config = {
|
||||
showWarningMessageForCrossOrigin: true,
|
||||
showCPUFallbackMessage: true,
|
||||
showLoadingIndicator: true,
|
||||
experimentalStudyBrowserSort: false,
|
||||
strictZSpacingForVolumeViewport: true,
|
||||
studyPrefetcher: {
|
||||
enabled: true,
|
||||
displaySetsCount: 2,
|
||||
maxNumPrefetchRequests: 10,
|
||||
order: 'closest',
|
||||
},
|
||||
defaultDataSourceName: 'dicomweb',
|
||||
dataSources: [
|
||||
{
|
||||
@@ -47,4 +54,7 @@ window.config = {
|
||||
},
|
||||
},
|
||||
],
|
||||
httpErrorHandler: error => {
|
||||
console.warn(`HTTP Error Handler (status: ${error.status})`, error);
|
||||
},
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CustomizationService,
|
||||
PanelService,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
// utils,
|
||||
} from '@ohif/core';
|
||||
|
||||
@@ -71,6 +72,7 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
|
||||
PanelService.REGISTRATION,
|
||||
WorkflowStepsService.REGISTRATION,
|
||||
StateSyncService.REGISTRATION,
|
||||
[StudyPrefetcherService.REGISTRATION, appConfig.studyPrefetcher],
|
||||
]);
|
||||
|
||||
errorHandler.getHTTPErrorHandler = () => {
|
||||
|
||||
@@ -134,6 +134,12 @@ export default class ExtensionManager extends PubSubService {
|
||||
return [...this.registeredExtensionIds];
|
||||
}
|
||||
|
||||
private getUniqueServicesList(servicesManager: AppTypes.ServicesManager) {
|
||||
// Make sure only one service instance is returned because almost all services are
|
||||
// registered with different keys (eg: StudyPrefetcherService and studyPrefetcherService)
|
||||
return Array.from(new Set(Object.values(servicesManager.services)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls all the services and extension on mode enters.
|
||||
* The service onModeEnter is called first
|
||||
@@ -148,11 +154,12 @@ export default class ExtensionManager extends PubSubService {
|
||||
_hotkeysManager,
|
||||
_extensionLifeCycleHooks,
|
||||
} = this;
|
||||
const services = this.getUniqueServicesList(_servicesManager);
|
||||
|
||||
// The onModeEnter of the service must occur BEFORE the extension
|
||||
// onModeEnter in order to reset the state to a standard state
|
||||
// before the extension restores and cached data.
|
||||
for (const service of Object.values(_servicesManager.services)) {
|
||||
for (const service of services) {
|
||||
service?.onModeEnter?.();
|
||||
}
|
||||
|
||||
@@ -172,6 +179,7 @@ export default class ExtensionManager extends PubSubService {
|
||||
public onModeExit(): void {
|
||||
const { registeredExtensionIds, _servicesManager, _commandsManager, _extensionLifeCycleHooks } =
|
||||
this;
|
||||
const services = this.getUniqueServicesList(_servicesManager);
|
||||
|
||||
registeredExtensionIds.forEach(extensionId => {
|
||||
const onModeExit = _extensionLifeCycleHooks.onModeExit[extensionId];
|
||||
@@ -186,7 +194,7 @@ export default class ExtensionManager extends PubSubService {
|
||||
|
||||
// The service onModeExit calls must occur after the extension ones
|
||||
// so that extension ones can store/restore data.
|
||||
for (const service of Object.values(_servicesManager.services)) {
|
||||
for (const service of services) {
|
||||
try {
|
||||
service?.onModeExit?.();
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as OHIF from './index';
|
||||
|
||||
describe('Top level exports', () => {
|
||||
test.only('have not changed', () => {
|
||||
test('have not changed', () => {
|
||||
const expectedExports = [
|
||||
'MODULE_TYPES',
|
||||
//
|
||||
@@ -47,6 +47,7 @@ describe('Top level exports', () => {
|
||||
'PubSubService',
|
||||
'PanelService',
|
||||
'WorkflowStepsService',
|
||||
'StudyPrefetcherService',
|
||||
'useToolbar',
|
||||
].sort();
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
StateSyncService,
|
||||
PanelService,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
} from './services';
|
||||
|
||||
import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService';
|
||||
@@ -85,6 +86,7 @@ const OHIF = {
|
||||
PanelService,
|
||||
useToolbar,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -130,6 +132,7 @@ export {
|
||||
Types,
|
||||
PanelService,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
useToolbar,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
import { ExtensionManager } from '../../extensions';
|
||||
import ServicesManager from '../ServicesManager';
|
||||
import ViewportGridService from '../ViewportGridService';
|
||||
import { DisplaySet } from '../../types';
|
||||
|
||||
const IMAGE_REQUEST_TYPE = 'prefetch';
|
||||
|
||||
export const EVENTS = {
|
||||
SERVICE_STARTED: 'event::studyPrefetcherService:started',
|
||||
SERVICE_STOPPED: 'event::studyPrefetcherService:stopped',
|
||||
DISPLAYSET_LOAD_PROGRESS: 'event::studyPrefetcherService:displaySetLoadProgress',
|
||||
DISPLAYSET_LOAD_COMPLETE: 'event::studyPrefetcherService:displaySetLoadComplete',
|
||||
};
|
||||
|
||||
/**
|
||||
* Order used for prefetching display set
|
||||
*/
|
||||
enum StudyPrefetchOrder {
|
||||
closest = 'closest',
|
||||
downward = 'downward',
|
||||
upward = 'upward',
|
||||
}
|
||||
|
||||
/**
|
||||
* Study Prefetcher configuration
|
||||
*/
|
||||
type StudyPrefetcherConfig = {
|
||||
/* Enable/disable study prefetching service */
|
||||
enabled: boolean;
|
||||
/* Number of displaysets to be prefetched */
|
||||
displaySetsCount: number;
|
||||
/**
|
||||
* Max number of concurrent prefetch requests
|
||||
* High numbers may impact on the time to load a new dropped series because
|
||||
* the browser will be busy with all prefetching requests. As soon as the
|
||||
* prefetch requests get fulfilled the new ones from the new dropped series
|
||||
* are sent to the server.
|
||||
*
|
||||
* TODO: abort all prefetch requests when a new series is loaded on a viewport.
|
||||
* (need to add support for `AbortController` on Cornerstone)
|
||||
* */
|
||||
maxNumPrefetchRequests: number;
|
||||
/* Display sets prefetching order (closest, downward and upward) */
|
||||
order: StudyPrefetchOrder;
|
||||
};
|
||||
|
||||
type DisplaySetLoadingState = {
|
||||
displaySetInstanceUID: string;
|
||||
numInstances: number;
|
||||
pendingImageIds: Set<string>;
|
||||
loadedImageIds: Set<string>;
|
||||
failedImageIds: Set<string>;
|
||||
loadingProgress: number;
|
||||
};
|
||||
|
||||
type ImageRequest = {
|
||||
displaySetInstanceUID: string;
|
||||
imageId: string;
|
||||
aborted: boolean;
|
||||
};
|
||||
|
||||
type PubSubServiceSubscription = { unsubscribe: () => any };
|
||||
|
||||
interface ICache {
|
||||
isImageCached(imageId: string): boolean;
|
||||
}
|
||||
|
||||
interface IImageLoadPoolManager {
|
||||
addRequest (
|
||||
requestFn: () => Promise<any>,
|
||||
type: string,
|
||||
additionalDetails: Record<string, unknown>,
|
||||
priority?: number
|
||||
);
|
||||
clearRequestStack(type: string): void;
|
||||
}
|
||||
|
||||
interface IImageLoader {
|
||||
loadAndCacheImage(imageId: string, options: any): Promise<any>;
|
||||
}
|
||||
|
||||
type EventSubscription = {
|
||||
unsubscribe: () => void
|
||||
};
|
||||
|
||||
interface IImageLoadEventsManager {
|
||||
addEventListeners(
|
||||
onImageLoaded: (evt: any) => void,
|
||||
onImageLoadFailed: (evt: any) => void
|
||||
): EventSubscription[];
|
||||
}
|
||||
|
||||
class StudyPrefetcherService extends PubSubService {
|
||||
private _extensionManager: ExtensionManager;
|
||||
private _servicesManager: ServicesManager;
|
||||
private _subscriptions: PubSubServiceSubscription[];
|
||||
private _activeDisplaySetsInstanceUIDs: string[] = [];
|
||||
private _pendingRequests: ImageRequest[] = [];
|
||||
private _inflightRequests = new Map<string, ImageRequest>();
|
||||
private _isRunning = false;
|
||||
private _displaySetLoadingStates = new Map<string, DisplaySetLoadingState>();
|
||||
private _imageIdsToDisplaySetsMap = new Map<string, Set<string>>();
|
||||
private config: StudyPrefetcherConfig = {
|
||||
/* Enable/disable study prefetching service */
|
||||
enabled: false,
|
||||
/* Number of displaysets to be prefetched */
|
||||
displaySetsCount: 1,
|
||||
/**
|
||||
* Max number of concurrent prefetch requests
|
||||
* High numbers may impact on the time to load a new dropped series because
|
||||
* the browser will be busy with all prefetching requests. As soon as the
|
||||
* prefetch requests get fulfilled the new ones from the new dropped series
|
||||
* are sent to the server.
|
||||
*
|
||||
* TODO: abort all prefetch requests when a new series is loaded on a viewport.
|
||||
* (need to add support for `AbortController` on Cornerstone)
|
||||
* */
|
||||
maxNumPrefetchRequests: 10,
|
||||
/* Display sets prefetching order (closest, downward and upward) */
|
||||
order: StudyPrefetchOrder.downward,
|
||||
};
|
||||
|
||||
// Properties set by Cornerstone extension (initStudyPrefetcherService)
|
||||
public requestType: string = IMAGE_REQUEST_TYPE;
|
||||
public cache: ICache;
|
||||
public imageLoadPoolManager: IImageLoadPoolManager;
|
||||
public imageLoader: IImageLoader;
|
||||
public imageLoadEventsManager: IImageLoadEventsManager;
|
||||
|
||||
public static REGISTRATION = {
|
||||
name: 'studyPrefetcherService',
|
||||
altName: 'StudyPrefetcherService',
|
||||
create: ({ configuration, servicesManager, extensionManager }): StudyPrefetcherService => {
|
||||
return new StudyPrefetcherService({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
configuration,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
constructor({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
configuration,
|
||||
}: {
|
||||
servicesManager: ServicesManager;
|
||||
extensionManager: ExtensionManager;
|
||||
configuration: StudyPrefetcherConfig;
|
||||
}) {
|
||||
super(EVENTS);
|
||||
|
||||
this._servicesManager = servicesManager;
|
||||
this._extensionManager = extensionManager;
|
||||
this._subscriptions = [];
|
||||
|
||||
Object.assign(this.config, configuration);
|
||||
}
|
||||
|
||||
public onModeEnter(): void {
|
||||
this._addEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* The onModeExit returns the service to the initial state.
|
||||
*/
|
||||
public onModeExit(): void {
|
||||
this._removeEventListeners();
|
||||
this._stopPrefetching();
|
||||
}
|
||||
|
||||
private _addImageLoadingEventsListeners() {
|
||||
const fnOnImageLoadCompleted = (imageId: string) => {
|
||||
// `sendNextRequests` must be called after image loaded/failed events
|
||||
// to make sure prefetch requests shall be sent as soon as the active
|
||||
// displaySets (active viewport) are loaded.
|
||||
//
|
||||
// PS: active display sets are not loaded by this service and that is why
|
||||
// the requests shall not be in the inflight queue.
|
||||
if (!this._inflightRequests.get(imageId)) {
|
||||
this._sendNextRequests();
|
||||
}
|
||||
}
|
||||
|
||||
const fnImageLoadedEventListener = (evt) => {
|
||||
const { image } = evt.detail;
|
||||
const { imageId } = image;
|
||||
|
||||
this._moveImageIdToLoadedSet(imageId);
|
||||
fnOnImageLoadCompleted(imageId);
|
||||
}
|
||||
|
||||
const fnImageLoadFailedEventListener = (evt) => {
|
||||
const { imageId } = evt.detail;
|
||||
|
||||
this._moveImageIdToFailedSet(imageId);
|
||||
fnOnImageLoadCompleted(imageId);
|
||||
}
|
||||
|
||||
return this.imageLoadEventsManager.addEventListeners(
|
||||
fnImageLoadedEventListener,
|
||||
fnImageLoadFailedEventListener
|
||||
);
|
||||
}
|
||||
|
||||
private _addServicesListeners() {
|
||||
const { displaySetService, viewportGridService } = this._servicesManager.services;
|
||||
|
||||
// Restart the prefetcher after any change to the displaySets
|
||||
// (eg: sorting the displaySets on StudyBrowser)
|
||||
const displaySetsChangedSubscription = displaySetService.subscribe(
|
||||
displaySetService.EVENTS.DISPLAY_SETS_CHANGED,
|
||||
() => this._syncWithActiveViewport({ forceRestart: true })
|
||||
);
|
||||
|
||||
// Loads new datasets when making a new viewport active
|
||||
const viewportGridActiveViewportIdSubscription = viewportGridService.subscribe(
|
||||
ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
|
||||
({ viewportId }) => this._syncWithActiveViewport({ activeViewportId: viewportId })
|
||||
);
|
||||
|
||||
// Continue loading datasets after changing the layout (eg: from 1x1 to 2x1)
|
||||
const viewportGridLayoutChangedSubscription = viewportGridService.subscribe(
|
||||
ViewportGridService.EVENTS.LAYOUT_CHANGED,
|
||||
() => this._syncWithActiveViewport()
|
||||
);
|
||||
|
||||
// Loads new datasets after loading a new display set on a viewport
|
||||
const viewportGridStateChangedSubscription = viewportGridService.subscribe(
|
||||
ViewportGridService.EVENTS.GRID_STATE_CHANGED,
|
||||
() => this._syncWithActiveViewport()
|
||||
);
|
||||
|
||||
// Loads the first datasets right after opening the viewer
|
||||
const viewportGridViewportreadySubscription = viewportGridService.subscribe(
|
||||
ViewportGridService.EVENTS.VIEWPORTS_READY,
|
||||
() => {
|
||||
this._syncWithActiveViewport();
|
||||
this._startPrefetching();
|
||||
}
|
||||
);
|
||||
|
||||
return [
|
||||
displaySetsChangedSubscription,
|
||||
viewportGridActiveViewportIdSubscription,
|
||||
viewportGridLayoutChangedSubscription,
|
||||
viewportGridStateChangedSubscription,
|
||||
viewportGridViewportreadySubscription
|
||||
];
|
||||
}
|
||||
|
||||
private _addEventListeners() {
|
||||
const imageLoadingEventsSubscriptions = this._addImageLoadingEventsListeners();
|
||||
const servicesSubscriptions = this._addServicesListeners();
|
||||
|
||||
this._subscriptions.push(...imageLoadingEventsSubscriptions);
|
||||
this._subscriptions.push(...servicesSubscriptions);
|
||||
}
|
||||
|
||||
private _removeEventListeners() {
|
||||
this._subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
this._subscriptions = [];
|
||||
}
|
||||
|
||||
private _syncWithActiveViewport(
|
||||
{
|
||||
activeViewportId,
|
||||
forceRestart
|
||||
}:
|
||||
{
|
||||
activeViewportId?: string,
|
||||
forceRestart?: boolean
|
||||
} = {}) {
|
||||
const { viewportGridService } = this._servicesManager.services;
|
||||
const viewportGridServiceState = viewportGridService.getState();
|
||||
const { viewports } = viewportGridServiceState;
|
||||
|
||||
activeViewportId = activeViewportId ?? viewportGridServiceState.activeViewportId;
|
||||
|
||||
// If may be null when the viewer is loaded
|
||||
if (!activeViewportId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeViewport = viewports.get(activeViewportId);
|
||||
const displaySetUpdated = this._setActiveDisplaySetsUIDs(activeViewport.displaySetInstanceUIDs);
|
||||
|
||||
if (forceRestart || displaySetUpdated) {
|
||||
this._restartPrefetching();
|
||||
}
|
||||
}
|
||||
|
||||
private _setActiveDisplaySetsUIDs(newActiveDisplaySetInstanceUIDs: string[]): boolean {
|
||||
const sameDisplaySets =
|
||||
newActiveDisplaySetInstanceUIDs.length === this._activeDisplaySetsInstanceUIDs.length &&
|
||||
newActiveDisplaySetInstanceUIDs.every(uid =>
|
||||
this._activeDisplaySetsInstanceUIDs.includes(uid)
|
||||
);
|
||||
|
||||
if (sameDisplaySets) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._activeDisplaySetsInstanceUIDs = [...newActiveDisplaySetInstanceUIDs];
|
||||
this._restartPrefetching();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _areActiveDisplaySetsLoaded() {
|
||||
const { _activeDisplaySetsInstanceUIDs: displaySetsInstanceUIDs } = this;
|
||||
|
||||
return displaySetsInstanceUIDs.length && displaySetsInstanceUIDs.every(displaySetsInstanceUID =>
|
||||
this._displaySetLoadingStates.get(displaySetsInstanceUID).loadingProgress >= 1);
|
||||
}
|
||||
|
||||
private _getClosestDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) {
|
||||
const sortedDisplaySets = [];
|
||||
let previousIndex = activeDisplaySetIndex - 1;
|
||||
let nextIndex = activeDisplaySetIndex + 1;
|
||||
|
||||
while (previousIndex >= 0 || nextIndex < displaySets.length) {
|
||||
if (previousIndex >= 0) {
|
||||
sortedDisplaySets.push(displaySets[previousIndex]);
|
||||
previousIndex--;
|
||||
}
|
||||
|
||||
if (nextIndex < displaySets.length) {
|
||||
sortedDisplaySets.push(displaySets[nextIndex]);
|
||||
nextIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return sortedDisplaySets;
|
||||
}
|
||||
|
||||
private _getDownwardDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) {
|
||||
const sortedDisplaySets = [];
|
||||
|
||||
for (let i = activeDisplaySetIndex + 1; i < displaySets.length; i++) {
|
||||
sortedDisplaySets.push(displaySets[i]);
|
||||
}
|
||||
|
||||
return sortedDisplaySets;
|
||||
}
|
||||
|
||||
private _getUpwardDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) {
|
||||
const sortedDisplaySets = [];
|
||||
|
||||
for (let i = activeDisplaySetIndex - 1; i >= 0 && i !== activeDisplaySetIndex; i--) {
|
||||
sortedDisplaySets.push(displaySets[i]);
|
||||
}
|
||||
|
||||
return sortedDisplaySets;
|
||||
}
|
||||
|
||||
private _getSortedDisplaySetsToPrefetch(displaySets: DisplaySet[]): DisplaySet[] {
|
||||
if (!this._activeDisplaySetsInstanceUIDs?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { displaySetsCount } = this.config;
|
||||
const activeDisplaySetsInstanceUIDs = this._activeDisplaySetsInstanceUIDs;
|
||||
const [activeDisplaySetUID] = activeDisplaySetsInstanceUIDs;
|
||||
const activeDisplaySetIndex = displaySets.findIndex(ds => ds.displaySetInstanceUID === activeDisplaySetUID);
|
||||
const getDisplaySetsFunctionsMap = {
|
||||
[StudyPrefetchOrder.closest]: this._getClosestDisplaySets,
|
||||
[StudyPrefetchOrder.downward]: this._getDownwardDisplaySets,
|
||||
[StudyPrefetchOrder.upward]: this._getUpwardDisplaySets,
|
||||
};
|
||||
const { order } = this.config;
|
||||
const fnGetDisplaySets = getDisplaySetsFunctionsMap[order];
|
||||
|
||||
if (!fnGetDisplaySets) {
|
||||
throw new Error(`Invalid order (${order})`);
|
||||
}
|
||||
|
||||
// Creates a `Set` to look for UIDs in O(1) instead of O(n)
|
||||
const uidsSet = new Set(activeDisplaySetsInstanceUIDs);
|
||||
|
||||
// Remove any active displaySet that may still be in the activeDisplaySetsInstanceUIDs.
|
||||
// That may happen when activeDisplaySetsInstanceUIDs has more than one element.
|
||||
return fnGetDisplaySets.call(this, displaySets, activeDisplaySetIndex).filter(
|
||||
ds => !uidsSet.has(ds.displaySetInstanceUID)
|
||||
).slice(0, displaySetsCount);
|
||||
}
|
||||
|
||||
private _getDisplaySets() {
|
||||
const { displaySetService } = this._servicesManager.services;
|
||||
const displaySets = [...displaySetService.getActiveDisplaySets()];
|
||||
let displaySetsToPrefetch = this._getSortedDisplaySetsToPrefetch(displaySets);
|
||||
|
||||
return { displaySets, displaySetsToPrefetch };
|
||||
}
|
||||
|
||||
private _updateImageIdsDisplaySetMap(
|
||||
displaySetInstanceUID: string,
|
||||
imageIds: string[]
|
||||
): void {
|
||||
for (const imageId of imageIds) {
|
||||
let displaySetsInstanceUIDsMap = this._imageIdsToDisplaySetsMap.get(imageId);
|
||||
|
||||
if (!displaySetsInstanceUIDsMap) {
|
||||
displaySetsInstanceUIDsMap = new Set();
|
||||
this._imageIdsToDisplaySetsMap.set(imageId, displaySetsInstanceUIDsMap);
|
||||
}
|
||||
|
||||
displaySetsInstanceUIDsMap.add(displaySetInstanceUID);
|
||||
}
|
||||
}
|
||||
|
||||
private _getImageIdsForDisplaySet(displaySet: DisplaySet): string[] {
|
||||
const dataSource = this._extensionManager.getActiveDataSource()[0];
|
||||
|
||||
return dataSource.getImageIdsForDisplaySet(displaySet);
|
||||
}
|
||||
|
||||
private _updateDisplaySetLoadingProgress(displaySetLoadingState: DisplaySetLoadingState) {
|
||||
const { numInstances, loadedImageIds, failedImageIds } = displaySetLoadingState;
|
||||
const loadingProgress = (loadedImageIds.size + failedImageIds.size) / numInstances;
|
||||
|
||||
displaySetLoadingState.loadingProgress = loadingProgress;
|
||||
}
|
||||
|
||||
private _addDisplaySetLoadingState(displaySet: DisplaySet): void {
|
||||
const { displaySetInstanceUID } = displaySet;
|
||||
const imageIds = this._getImageIdsForDisplaySet(displaySet);
|
||||
let displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
|
||||
|
||||
if (displaySetLoadingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingImageIds = new Set<string>(imageIds);
|
||||
const loadedImageIds = new Set<string>();
|
||||
|
||||
// Needs to check which image is already loaded to update the progress properly
|
||||
// because some images may already be loaded (thumbnails and viewports).
|
||||
for (const imageId of imageIds) {
|
||||
if (this.cache.isImageCached(imageId)) {
|
||||
loadedImageIds.add(imageId);
|
||||
} else {
|
||||
pendingImageIds.add(imageId);
|
||||
}
|
||||
}
|
||||
|
||||
displaySetLoadingState = {
|
||||
displaySetInstanceUID,
|
||||
numInstances: imageIds.length,
|
||||
pendingImageIds,
|
||||
loadedImageIds,
|
||||
failedImageIds: new Set(),
|
||||
loadingProgress: 0
|
||||
};
|
||||
|
||||
this._updateDisplaySetLoadingProgress(displaySetLoadingState);
|
||||
this._displaySetLoadingStates.set(displaySetInstanceUID, displaySetLoadingState);
|
||||
this._updateImageIdsDisplaySetMap(displaySetInstanceUID, imageIds)
|
||||
|
||||
// Notify the UI that something is already loaded (eg: update StudyBrowser)
|
||||
if (loadedImageIds.size) {
|
||||
this._triggerDisplaySetEvents(displaySetInstanceUID);
|
||||
}
|
||||
}
|
||||
|
||||
private _loadDisplaySets() {
|
||||
const { displaySets, displaySetsToPrefetch } = this._getDisplaySets();
|
||||
|
||||
displaySets.forEach(displaySet => this._addDisplaySetLoadingState(displaySet));
|
||||
displaySetsToPrefetch.forEach(displaySet => this._enqueueDisplaySetImagesRequests(displaySet));
|
||||
}
|
||||
|
||||
private _moveImageIdToLoadedSet(imageId: string): boolean {
|
||||
const displaySetsInstanceUIDs = this._imageIdsToDisplaySetsMap.get(imageId);
|
||||
|
||||
if (!displaySetsInstanceUIDs) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const displaySetInstanceUID of Array.from(displaySetsInstanceUIDs.values())) {
|
||||
const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
|
||||
const { pendingImageIds, loadedImageIds } = displaySetLoadingState;
|
||||
|
||||
pendingImageIds.delete(imageId);
|
||||
loadedImageIds.add(imageId);
|
||||
|
||||
this._updateDisplaySetLoadingProgress(displaySetLoadingState);
|
||||
this._triggerDisplaySetEvents(displaySetInstanceUID);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _moveImageIdToFailedSet(imageId: string): boolean {
|
||||
const displaySetsInstanceUIDs = this._imageIdsToDisplaySetsMap.get(imageId);
|
||||
|
||||
if (!displaySetsInstanceUIDs) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const displaySetInstanceUID of Array.from(displaySetsInstanceUIDs.values())) {
|
||||
const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
|
||||
const { pendingImageIds, failedImageIds } = displaySetLoadingState;
|
||||
|
||||
pendingImageIds.delete(imageId);
|
||||
failedImageIds.add(imageId);
|
||||
|
||||
this._updateDisplaySetLoadingProgress(displaySetLoadingState);
|
||||
this._triggerDisplaySetEvents(displaySetInstanceUID);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _triggerDisplaySetEvents(displaySetInstanceUID: string) {
|
||||
const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
|
||||
const { loadingProgress, numInstances } = displaySetLoadingState;
|
||||
|
||||
this._broadcastEvent(this.EVENTS.DISPLAYSET_LOAD_PROGRESS, {
|
||||
displaySetInstanceUID,
|
||||
numInstances,
|
||||
loadingProgress,
|
||||
});
|
||||
|
||||
if (loadingProgress >= 1) {
|
||||
this._broadcastEvent(this.EVENTS.DISPLAYSET_LOAD_COMPLETE, {
|
||||
displaySetInstanceUID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _onImagePrefetchSuccess(imageRequest: ImageRequest) {
|
||||
if (imageRequest.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { imageId } = imageRequest;
|
||||
|
||||
this._inflightRequests.delete(imageId);
|
||||
this._moveImageIdToLoadedSet(imageId);
|
||||
|
||||
// `sendNextRequests` must be called after removing the request from the inflight
|
||||
// queue otherwise it shall not be able to send the request (maxNumPrefetchRequests)
|
||||
this._sendNextRequests();
|
||||
}
|
||||
|
||||
private _onImagePrefetchFailed(imageRequest, error) {
|
||||
if (imageRequest.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`An error ocurred when trying to load "${imageRequest.imageId}"`, error);
|
||||
|
||||
const { imageId } = imageRequest;
|
||||
|
||||
this._inflightRequests.delete(imageId);
|
||||
this._moveImageIdToFailedSet(imageId);
|
||||
|
||||
// `sendNextRequests` must be called after removing the request from the inflight
|
||||
// queue otherwise it shall not be able to send the request (maxNumPrefetchRequests)
|
||||
this._sendNextRequests();
|
||||
}
|
||||
|
||||
private async _sendNextRequests() {
|
||||
// If the service has stopped with async requests in progress this method may
|
||||
// get called again when each of those requests are fulfilled.
|
||||
if (!this._isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Does not send any prefetch request until the active display sets are loaded
|
||||
if (!this._areActiveDisplaySetsLoaded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { _pendingRequests: pendingRequests, _inflightRequests: inflightRequests } = this;
|
||||
const { maxNumPrefetchRequests } = this.config;
|
||||
|
||||
if (!pendingRequests.length || inflightRequests.size >= maxNumPrefetchRequests) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numImageRequests = Math.min(
|
||||
pendingRequests.length,
|
||||
maxNumPrefetchRequests - inflightRequests.size
|
||||
);
|
||||
const imageRequests = this._pendingRequests.splice(0, numImageRequests);
|
||||
|
||||
imageRequests.forEach(imageRequest => {
|
||||
const { imageId } = imageRequest;
|
||||
const options = {
|
||||
priority: -5,
|
||||
requestType: this.requestType,
|
||||
additionalDetails: { imageId },
|
||||
preScale: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
this.imageLoadPoolManager.addRequest(
|
||||
async () =>
|
||||
this.imageLoader.loadAndCacheImage(imageId, options).then(
|
||||
_image => this._onImagePrefetchSuccess(imageRequest),
|
||||
error => this._onImagePrefetchFailed(imageRequest, error)
|
||||
),
|
||||
this.requestType,
|
||||
{ imageId }
|
||||
);
|
||||
|
||||
inflightRequests.set(imageId, imageRequest);
|
||||
});
|
||||
}
|
||||
|
||||
private _enqueueDisplaySetImagesRequests(displaySet: DisplaySet) {
|
||||
const { displaySetInstanceUID } = displaySet;
|
||||
const imageIds = this._getImageIdsForDisplaySet(displaySet);
|
||||
|
||||
imageIds.forEach(imageId => {
|
||||
if (this.cache.isImageCached(imageId)) {
|
||||
this._moveImageIdToLoadedSet(imageId);
|
||||
return;
|
||||
}
|
||||
|
||||
this._pendingRequests.push({
|
||||
displaySetInstanceUID,
|
||||
imageId,
|
||||
aborted: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start prefetching the display sets based on the active viewport and app configuration.
|
||||
*/
|
||||
private _startPrefetching(): void {
|
||||
if (this._isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.enabled) {
|
||||
console.log('StudyPrefetcher is not enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
|
||||
this._loadDisplaySets();
|
||||
this._sendNextRequests();
|
||||
this._broadcastEvent(this.EVENTS.SERVICE_STARTED, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop prefetching the display sets.
|
||||
* All internal variables are cleared but activeDisplaySetsInstanceUIDs otherwise restart would not work.
|
||||
*/
|
||||
private _stopPrefetching(): void {
|
||||
if (!this._isRunning) {
|
||||
return;
|
||||
}
|
||||
this._isRunning = false;
|
||||
|
||||
// Mark all inflight requests as aborted before clearing the map.
|
||||
this._inflightRequests.forEach(inflightRequest => (inflightRequest.aborted = true));
|
||||
|
||||
this._pendingRequests = [];
|
||||
this._displaySetLoadingStates.clear();
|
||||
this._imageIdsToDisplaySetsMap.clear();
|
||||
this._inflightRequests.clear();
|
||||
this.imageLoadPoolManager.clearRequestStack(IMAGE_REQUEST_TYPE);
|
||||
|
||||
this._broadcastEvent(this.EVENTS.SERVICE_STOPPED, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart prefetching in case it is already running.
|
||||
*/
|
||||
private _restartPrefetching(): void {
|
||||
if (this._isRunning) {
|
||||
this._stopPrefetching();
|
||||
this._startPrefetching();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { StudyPrefetcherService as default, StudyPrefetcherService };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { StudyPrefetcherService } from './StudyPrefetcherService';
|
||||
|
||||
export { StudyPrefetcherService as default, StudyPrefetcherService };
|
||||
@@ -105,8 +105,8 @@ class ViewportGridService extends PubSubService {
|
||||
this.setDisplaySetsForViewports([props]);
|
||||
}
|
||||
|
||||
public setDisplaySetsForViewports(props) {
|
||||
this.serviceImplementation._setDisplaySetsForViewports(props);
|
||||
public async setDisplaySetsForViewports(props) {
|
||||
await this.serviceImplementation._setDisplaySetsForViewports(props);
|
||||
const state = this.getState();
|
||||
const viewports = [];
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import CustomizationService from './CustomizationService';
|
||||
import StateSyncService from './StateSyncService';
|
||||
import PanelService from './PanelService';
|
||||
import WorkflowStepsService from './WorkflowStepsService';
|
||||
import StudyPrefetcherService from './StudyPrefetcherService';
|
||||
|
||||
import type Services from '../types/Services';
|
||||
|
||||
@@ -42,4 +43,5 @@ export {
|
||||
UserAuthenticationService,
|
||||
PanelService,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import UserAuthenticationServiceType from '../services/UserAuthenticationService
|
||||
import PanelServiceType from '../services/PanelService';
|
||||
import UIDialogServiceType from '../services/UIDialogService';
|
||||
import UIViewportDialogServiceType from '../services/UIViewportDialogService';
|
||||
import StudyPrefetcherServiceType from '../services/StudyPrefetcherService';
|
||||
|
||||
import ServicesManagerType from '../services/ServicesManager';
|
||||
import CommandsManagerType from '../classes/CommandsManager';
|
||||
@@ -41,6 +42,7 @@ declare global {
|
||||
export type UIDialogService = UIDialogServiceType;
|
||||
export type UIViewportDialogService = UIViewportDialogServiceType;
|
||||
export type PanelService = PanelServiceType;
|
||||
export type StudyPrefetcherService = StudyPrefetcherServiceType;
|
||||
|
||||
export interface Managers {
|
||||
servicesManager?: ServicesManager;
|
||||
@@ -64,6 +66,7 @@ declare global {
|
||||
uiDialogService?: UIDialogServiceType;
|
||||
uiViewportDialogService?: UIViewportDialogServiceType;
|
||||
panelService?: PanelServiceType;
|
||||
studyPrefetcherService?: StudyPrefetcherServiceType;
|
||||
}
|
||||
export interface Config {
|
||||
routerBasename?: string;
|
||||
@@ -119,6 +122,12 @@ declare global {
|
||||
onConfiguration?: (dicomWebConfig: any, options: any) => any;
|
||||
dataSources?: any;
|
||||
oidc?: any;
|
||||
studyPrefetcher: {
|
||||
enabled: boolean;
|
||||
displaySetsCount: number;
|
||||
maxNumPrefetchRequests: number;
|
||||
order: 'closest' | 'downward' | 'upward';
|
||||
}
|
||||
}
|
||||
|
||||
export interface Test {
|
||||
|
||||
@@ -258,6 +258,35 @@ This will result in two panels, one with `dicomSeg.panel` and `tracked.measureme
|
||||
|
||||
:::
|
||||
|
||||
### Study Prefetcher
|
||||
|
||||
You can enable the study prefetcher so that OHIF loads the next/previous series/display sets
|
||||
based on the proximity to the current series/display set. This can be useful to improve the user experience
|
||||
|
||||
|
||||
```js
|
||||
studyPrefetcher: {
|
||||
/* Enable/disable study prefetching service (default: false) */
|
||||
enabled: true,
|
||||
/* Number of displaysets to be prefetched (default: 2)*/
|
||||
displaySetCount: 2,
|
||||
/**
|
||||
* Max number of concurrent prefetch requests (default: 10)
|
||||
* High numbers may impact on the time to load a new dropped series because
|
||||
* the browser will be busy with all prefetching requests. As soon as the
|
||||
* prefetch requests get fulfilled the new ones from the new dropped series
|
||||
* are sent to the server.
|
||||
*
|
||||
* TODO: abort all prefetch requests when a new series is loaded on a viewport.
|
||||
* (need to add support for `AbortController` on Cornerstone)
|
||||
* */
|
||||
maxNumPrefetchRequests: 10,
|
||||
/* Display sets loading order (closest (deafult), downward or upward) */
|
||||
order: 'closest',
|
||||
},
|
||||
|
||||
```
|
||||
|
||||
### More on Accept Header Configuration
|
||||
In the previous section we showed that you can modify the `acceptHeader`
|
||||
configuration to request specific dicom transfer syntax. By default
|
||||
|
||||
@@ -102,7 +102,7 @@ There are various configurations available to customize the viewer. Each configu
|
||||
OHIF v3 has a new configuration structure. The main difference is that the `servers` is renamed to `dataSources` and the configuration is now asynchronous. Datasources are more abstract and
|
||||
far more capable than servers. Read more about dataSources [here](../platform/extensions/modules/data-source.md).
|
||||
|
||||
- `StudyPrefetcher` is not currently supported in OHIF v3.
|
||||
- `StudyPrefetcher` is only available in OHIF v3.9 beta and will be available in the next stable 3.9 release.
|
||||
- The `servers` object has been replaced with a `dataSources` array containing objects representing different data sources.
|
||||
- The cornerstoneExtensionConfig property has been removed, you should use `customizationService` instead (you can read more [here](../platform/services/ui/customization-service.md))
|
||||
- The maxConcurrentMetadataRequests property has been removed in favor of `maxNumRequests`
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@testing-library/react-hooks": "^3.2.1",
|
||||
"@testing-library/react": "^13.1.0",
|
||||
"browser-detect": "^0.2.28",
|
||||
"classnames": "^2.3.2",
|
||||
"d3-array": "3",
|
||||
@@ -56,7 +56,7 @@
|
||||
"react-modal": "3.11.2",
|
||||
"react-outside-click-handler": "^1.3.0",
|
||||
"react-select": "5.7.4",
|
||||
"react-test-renderer": "^16.12.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"react-window": "^1.8.9",
|
||||
"react-with-direction": "^1.3.1",
|
||||
"swiper": "^8.4.2",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="19" height="19" viewBox="0 0 19 19" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" transform="matrix(0.84873874,0,0,0.84873874,0.13586772,0.14249413)">
|
||||
<ellipse cx="11" cy="4" rx="9" ry="3"/>
|
||||
<path d="m 2,4 v 14 a 9,3 0 0 0 18,0 V 4" />
|
||||
<path d="m 2,11 a 9,3 0 0 0 18,0" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -9,6 +9,7 @@ import { ReactComponent as calendar } from './../../assets/icons/calendar.svg';
|
||||
import { ReactComponent as cancel } from './../../assets/icons/cancel.svg';
|
||||
import { ReactComponent as clipboard } from './../../assets/icons/clipboard.svg';
|
||||
import { ReactComponent as close } from './../../assets/icons/closeIcon.svg';
|
||||
import { ReactComponent as database } from './../../assets/icons/database.svg';
|
||||
import { ReactComponent as dottedCircle } from './../../assets/icons/dotted-circle.svg';
|
||||
import { ReactComponent as circledCheckmark } from './../../assets/icons/circled-checkmark.svg';
|
||||
import { ReactComponent as chevronDown } from './../../assets/icons/chevron-down.svg';
|
||||
@@ -214,6 +215,7 @@ const ICONS = {
|
||||
cancel: cancel,
|
||||
clipboard: clipboard,
|
||||
close: close,
|
||||
database: database,
|
||||
'dotted-circle': dottedCircle,
|
||||
'circled-checkmark': circledCheckmark,
|
||||
'chevron-down': chevronDown,
|
||||
|
||||
@@ -17,6 +17,7 @@ const Thumbnail = ({
|
||||
description,
|
||||
seriesNumber,
|
||||
numInstances,
|
||||
loadingProgress,
|
||||
countIcon,
|
||||
messages,
|
||||
dragData = {},
|
||||
@@ -94,6 +95,17 @@ const Thumbnail = ({
|
||||
/>
|
||||
{` ${numInstances}`}
|
||||
</div>
|
||||
<div className="flex mr-2 last:mr-0">
|
||||
{loadingProgress && loadingProgress < 1 && (
|
||||
<>{Math.round(loadingProgress * 100)}%</>
|
||||
)}
|
||||
{loadingProgress && loadingProgress === 1 && (
|
||||
<Icon
|
||||
name={'database'}
|
||||
className="w-3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DisplaySetMessageListTooltip
|
||||
messages={messages}
|
||||
id={`display-set-tooltip-${displaySetInstanceUID}`}
|
||||
@@ -124,6 +136,7 @@ Thumbnail.propTypes = {
|
||||
description: PropTypes.string.isRequired,
|
||||
seriesNumber: StringNumber.isRequired,
|
||||
numInstances: PropTypes.number.isRequired,
|
||||
loadingProgress: PropTypes.number,
|
||||
messages: PropTypes.object,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
|
||||
@@ -25,6 +25,7 @@ const ThumbnailList = ({
|
||||
dragData,
|
||||
seriesNumber,
|
||||
numInstances,
|
||||
loadingProgress,
|
||||
modality,
|
||||
componentType,
|
||||
seriesDate,
|
||||
@@ -66,6 +67,7 @@ const ThumbnailList = ({
|
||||
description={description}
|
||||
seriesNumber={seriesNumber}
|
||||
numInstances={numInstances}
|
||||
loadingProgress={loadingProgress}
|
||||
countIcon={countIcon}
|
||||
imageSrc={imageSrc}
|
||||
imageAltText={imageAltText}
|
||||
|
||||
@@ -18,6 +18,7 @@ function ThumbnailTracked({
|
||||
description,
|
||||
seriesNumber,
|
||||
numInstances,
|
||||
loadingProgress,
|
||||
countIcon,
|
||||
messages,
|
||||
dragData,
|
||||
@@ -87,6 +88,7 @@ function ThumbnailTracked({
|
||||
messages={messages}
|
||||
numInstances={numInstances}
|
||||
countIcon={countIcon}
|
||||
loadingProgress={loadingProgress}
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
@@ -114,6 +116,7 @@ ThumbnailTracked.propTypes = {
|
||||
description: PropTypes.string.isRequired,
|
||||
seriesNumber: StringNumber.isRequired,
|
||||
numInstances: PropTypes.number.isRequired,
|
||||
loadingProgress: PropTypes.number,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
onDoubleClick: PropTypes.func.isRequired,
|
||||
onClickUntrack: PropTypes.func.isRequired,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useSessionStorage from './useSessionStorage';
|
||||
|
||||
const SESSION_STORAGE_KEY = 'test';
|
||||
|
||||
Reference in new issue
Block a user