Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions packages/core/src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ICachedGeometry,
EventTypes,
IImageVolume,
CompressionProvider,
} from '../types';
import triggerEvent from '../utilities/triggerEvent';
import imageIdToURI from '../utilities/imageIdToURI';
Expand Down Expand Up @@ -46,6 +47,8 @@ class Cache {
private _maxCacheSize = 3 * ONE_GB;
private _geometryCacheSize = 0;

private _compressionProvider: CompressionProvider | null = null;

/**
* Generates a deterministic volume ID from a list of image IDs
* @param imageIds - Array of image IDs
Expand Down Expand Up @@ -94,6 +97,35 @@ class Cache {
this._maxCacheSize = newMaxCacheSize;
};

/**
* Set a compression provider for the cache
*
* The compression provider handles compressing images after they are loaded
* and decompressing them on-demand when accessed. This allows for pluggable
* compression strategies (WebP, JPEG, PNG, or custom formats).
*
* @example
* ```typescript
* import { createWebPCompressionProvider } from './utils/createWebPCompressionProvider';
*
* const provider = createWebPCompressionProvider({ quality: 0.8 });
* cache.setCompressionProvider(provider);
* ```
*
* @param provider - The compression provider implementation, or null to disable
*/
public setCompressionProvider(provider: CompressionProvider | null): void {
this._compressionProvider = provider;
}

/**
* Get the current compression provider
* @returns The active compression provider, or null if none is set
*/
public getCompressionProvider(): CompressionProvider | null {
return this._compressionProvider;
}

/**
* Determines if the cache can accommodate the requested byte size.
*
Expand Down Expand Up @@ -183,6 +215,11 @@ class Cache {
imageLoadObject.decache();
}

// Clean up compressed blob if it exists
if (cachedImage.compressedBlob) {
cachedImage.compressedBlob = undefined;
}

this._imageCache.delete(imageId);
};

Expand Down Expand Up @@ -439,6 +476,63 @@ class Cache {
triggerEvent(eventTarget, Events.IMAGE_CACHE_IMAGE_ADDED, eventDetails);

cachedImage.sharedCacheKey = image.sharedCacheKey;

// Compress image asynchronously if a compression provider is configured
// This happens after the image is loaded and cached, so it doesn't block
// the initial display
if (this._compressionProvider) {
this._compressAndStoreImage(imageId, image, cachedImage).catch(
(error) => {
console.warn('Failed to compress image:', error);
}
);
}
}

/**
* Compress and store image asynchronously using the configured compression provider
*
* This method uses the active compression provider to compress an image, then
* replaces the uncompressed image data with the compressed blob to save memory.
*
* @param imageId - Image identifier
* @param image - Cornerstone image object
* @param cachedImage - Cached image entry
*/
private async _compressAndStoreImage(
imageId: string,
image: IImage,
cachedImage: ICachedImage
): Promise<void> {
if (!this._compressionProvider) {
return;
}

try {
const blob = await this._compressionProvider.compress(image);

// Check if image is still in cache (may have been evicted during compression)
if (!this._imageCache.has(imageId)) {
return;
}

cachedImage.compressedBlob = blob;
cachedImage.isCompressed = true;

// Update cache size tracking to reflect the compressed size
const oldSize = cachedImage.sizeInBytes;
const newSize = blob.size;
const sizeDifference = newSize - oldSize;

cachedImage.sizeInBytes = newSize;
this.incrementImageCacheSize(sizeDifference);

// Clear uncompressed image data to free memory.
// The image will be decompressed on-demand when accessed.
cachedImage.image = undefined;
} catch (error) {
console.warn(`Failed to compress image ${imageId}:`, error);
}
}

/**
Expand Down Expand Up @@ -583,6 +677,51 @@ class Cache {
// Bump time stamp for cached image
cachedImage.timeStamp = Date.now();

// If compressed and image not in memory, decompress on-demand using the provider
if (
cachedImage.isCompressed &&
cachedImage.compressedBlob &&
!cachedImage.image &&
this._compressionProvider
) {
// Check if we already have a decompression in progress.
// If imageLoadObject.promise exists and the image is still undefined,
// it means we're already decompressing, so return the existing promise
// to avoid creating duplicate decompression operations.
if (cachedImage.imageLoadObject?.promise) {
return cachedImage.imageLoadObject;
}

// Create a new decompression promise using the compression provider
// Note: We don't save the decompressed image back to the cache to maximize
// memory efficiency. This allows prefetching many compressed images (e.g., 700+
// ultrasound frames). Images are decompressed on-demand each time they're accessed.
const decompressionLoadObject = {
promise: this._compressionProvider
.decompress(cachedImage.compressedBlob, imageId)
.then((decompressedImage) => {
// Clear the promise so next access will decompress again
// Preserve cancelFn and decache from the original image load
cachedImage.imageLoadObject = {
promise: undefined,
cancelFn: cachedImage.imageLoadObject?.cancelFn,
decache: cachedImage.imageLoadObject?.decache,
};
return decompressedImage;
}),
cancelFn: cachedImage.imageLoadObject?.cancelFn,
decache: cachedImage.imageLoadObject?.decache,
};

// Store the decompression promise in the cache to prevent race conditions.
// This ensures that if the same image is requested multiple times while
// decompression is in progress, all requests will use the same promise
// instead of triggering multiple decompressions or server fetches.
cachedImage.imageLoadObject = decompressionLoadObject;

return decompressionLoadObject;
}

return cachedImage.imageLoadObject;
}

Expand Down
45 changes: 44 additions & 1 deletion packages/core/src/types/ICache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,43 @@
import type { IImageLoadObject, IVolumeLoadObject } from './ILoadObject';
import type IImage from './IImage';

/**
* Compression provider interface for pluggable cache compression
*
* Implementations can provide custom compression strategies (e.g., WebP, JPEG,
* PNG, or specialized medical image compression formats like JPEG-LS).
*
* @example
* ```typescript
* const webpProvider: CompressionProvider = {
* compress: async (image) => {
* // Convert image to WebP blob
* return blob;
* },
* decompress: async (blob, imageId) => {
* // Convert blob back to IImage
* return image;
* }
* };
* cache.setCompressionProvider(webpProvider);
* ```
*/
interface CompressionProvider {
/**
* Compress a Cornerstone image to a blob
* @param image - The image to compress
* @returns Promise resolving to compressed blob
*/
compress: (image: IImage) => Promise<Blob>;

/**
* Decompress a blob back to a Cornerstone image
* @param blob - The compressed blob
* @param imageId - The image identifier
* @returns Promise resolving to decompressed image
*/
decompress: (blob: Blob, imageId: string) => Promise<IImage>;
}

interface ICache {
/** Set the maximum cache size */
Expand All @@ -24,6 +63,10 @@ interface ICache {
getVolumeLoadObject: (volumeId: string) => IVolumeLoadObject | void;
/** Purge cache both image and volume */
purgeCache: () => void;
/** Set a compression provider for the cache */
setCompressionProvider: (provider: CompressionProvider | null) => void;
/** Get the current compression provider */
getCompressionProvider: () => CompressionProvider | null;
}

export type { ICache as default };
export type { ICache as default, CompressionProvider };
12 changes: 12 additions & 0 deletions packages/core/src/types/ICachedImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ interface ICachedImage {
sharedCacheKey?: string;
timeStamp: number;
sizeInBytes: number;
/**
* Compressed blob storage for memory optimization.
* When a compression provider is configured, images will be stored
* as compressed blobs to reduce memory usage.
*/
compressedBlob?: Blob;
/**
* Flag indicating if this image is stored in compressed format.
* When true, the image will be decompressed on-demand using the
* configured compression provider.
*/
isCompressed?: boolean;
}

export type { ICachedImage as default };
2 changes: 2 additions & 0 deletions packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type Cornerstone3DConfig from './Cornerstone3DConfig';
import type ICamera from './ICamera';
import type IEnabledElement from './IEnabledElement';
import type ICache from './ICache';
import type { CompressionProvider } from './ICache';
import type { IVolume } from './IVolume';
import type { VOI, VOIRange } from './voi';
import type DisplayArea from './displayArea';
Expand Down Expand Up @@ -177,6 +178,7 @@ export type {
IVolumeViewport,
IEnabledElement,
ICache,
CompressionProvider,
IVolume,
IViewportId,
IImageVolume,
Expand Down