feat: Add support for labelmap seg images in any supported tsuid (also for compressed bitmap) (#5806)

* feat: Update to use pnpm (#6031) [simulated squash-merge]

* feat: load DICOM SEG images via imageLoader

* feat: load DICOM SEG images via imageLoader

* PR review fixes

* Test fragment of compressed multiframes

* fix: Removed dependencies incorrectly

* Update frame as a targetted change to avoid stripping remaining params

* lock

* Update test to agree with the missing representation branch

* test(contour): contour color change coverage (#6042)

* test(contour): contour interactions delete segment (#6069)

---------

Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>

* chore(version): Update package versions to 3.13.0-beta.98 [skip ci]

* feat(testing): OHIF Test Agent Skills (#5993)

* chore(version): Update package versions to 3.13.0-beta.99 [skip ci]

* test(contour): Add the ContourSegmentToggleLock.spec.ts test file to test contour locking (#6072)

* chore(version): Update package versions to 3.13.0-beta.100 [skip ci]

* chore(testing): Flock lock for playwright tests; Pin node version for OHIF; add more workers (#6099)

* update Cypress apt deps for Ubuntu Noble (drop libgconf-2-4, libasound2→libasound2t64)

* chore(version): Update package versions to 3.13.0-beta.101 [skip ci]

* Debug fixes

* perf(seg): pass explicit frame decode concurrency (16) to SEG loader

Pass an explicit concurrency value (SEG_FRAME_DECODE_CONCURRENCY = 16) into
createFromDicomSegImageId rather than relying on the adapter default, so the
SEG frame fetch/decode parallelism is set at the call site and is ready to
become configurable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(behaviours): add behaviours section + multiframe Part 10 prefetch proposal

Start a "Behaviours" docs section for documenting how the system and UI behave
end-to-end (observed behaviours, design proposals, and failure modes), with an
index README and a Docusaurus category.

First entry is the proposal for loading a multiframe SEG as a single Part 10
instance: prefetch the whole instance (gated by loadMultiframeAsPart10RaceTimeMs),
parse it with dcmjs (handling multipart/related vs raw DICOM), and register the
per-frame compressed pixels into the Cornerstone3D core image cache (the single
uniform frame registry) so the per-frame load path is served locally while the
standard decode path is unchanged. Best-effort: falls back to per-frame fetches
on any failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Pre-cache the image data using a full part10 file for performance

* Add customizations to specify type of segmentation save

* Add clearcache of the cacheData

* Bump @cornerstonejs/* pins 5.1.3 -> 5.4.10 to match libs/@cornerstonejs base

libs/@cornerstonejs (fix/use-imageLoader-for-seg) is based on the released
cs3d 5.4.10 (merge-base with origin/main is the 5.4.10 version bump), so pin
OHIF to that release. The local branch changes still reach the app via the
cs3d:link symlinks; these pins keep the lockfile/npm fallback aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix seg OOM

* fix: file meta garbage element, bogus NumberOfFrames, unguarded source-map-loader

- dicomWriter: drop the naturalized meta.TransferSyntaxUID assignment that
  dcmjs wrote as a garbage (0000,0000) element into every saved file's meta
  group (download / clipboard / local wadouri blob / local store); keep the
  hex 00020010 assignment, which is required for string-form _meta fallbacks.
- registerNaturalizedDatasetForLocalWadouri: keep the computed frame count
  local so single-frame IODs (SR, RTSTRUCT) no longer gain a bogus
  NumberOfFrames element in their serialized form.
- rsbuild.config: resolve source-map-loader opportunistically (it is not a
  project dependency; the rule only serves the gitignored cs3d-link
  workflow) so fresh clones no longer crash at config load.
- Regression tests for both writer fixes (mutation-checked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PR review comment fixes

* Update versions

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: diattamo <mmddiatta@gmail.com>
Co-authored-by: ohif-bot <danny.ri.brown+ohif-bot@gmail.com>
Co-authored-by: Joe Boccanfuso <109477394+jbocce@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-07-09 20:54:23 -04:00
1 parent b996070583
commit dbb8b1526e
59 files changed
+2501 -322

No files matched your search

@@ -41,8 +41,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.1.3",
"@cornerstonejs/core": "5.1.3",
"@cornerstonejs/adapters": "5.4.12",
"@cornerstonejs/core": "5.4.12",
"@kitware/vtk.js": "35.5.3"
},
"devDependencies": {
@@ -41,8 +41,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.1.3",
"@cornerstonejs/core": "5.1.3",
"@cornerstonejs/adapters": "5.4.12",
"@cornerstonejs/core": "5.4.12",
"@kitware/vtk.js": "35.5.3"
},
"devDependencies": {
@@ -6,6 +6,7 @@ import { adaptersRT, adaptersSEG } from '@cornerstonejs/adapters';
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
import { getSegmentationSaveOptions } from './utils/segmentationConfig';
const getTargetViewport = ({ viewportId, viewportGridService }) => {
const { viewports, activeViewportId } = viewportGridService.getState();
@@ -34,7 +35,7 @@ const commandsModule = ({
extensionManager,
commandsManager,
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
const { segmentationService, displaySetService, viewportGridService } =
const { segmentationService, displaySetService, viewportGridService, customizationService } =
servicesManager.services as AppTypes.Services;
const actions = {
@@ -88,13 +89,37 @@ const commandsModule = ({
* @returns Returns the generated segmentation data.
*/
generateSegmentation: ({ segmentationId, options = {} }) => {
// `dataSource` (a data source name) is consumed here to resolve the store
// overrides; it must not be forwarded to the adapter's generateSegmentation.
const { dataSource: dataSourceName, ...generateOptions } = options;
const segmentation = cornerstoneToolsSegmentation.state.getSegmentation(segmentationId);
const predecessorImageId = options.predecessorImageId ?? segmentation.predecessorImageId;
const predecessorImageId =
generateOptions.predecessorImageId ?? segmentation.predecessorImageId;
// A data source may override the app-wide `segmentation.store.*` defaults
// via `configuration.segmentation.store` (different back ends support
// different SEG encodings). Use the named target data source when storing,
// otherwise the active one (e.g. download).
const dataSourceDefinition = dataSourceName
? extensionManager.getDataSourceDefinition(dataSourceName)
: extensionManager.getActiveDataSourceDefinition();
const dataSourceStoreOverride = dataSourceDefinition?.configuration?.segmentation?.store;
const { imageIds } = segmentation.representationData.Labelmap;
const segImages = imageIds.map(imageId => cache.getImage(imageId));
const referencedImages = segImages.map(image => cache.getImage(image.referencedImageId));
const referencedImages = segImages.map((segImage, sliceIndex) => {
const referencedImage = cache.getImage(segImage.referencedImageId);
if (!referencedImage) {
throw new Error(
`Referenced source image not in cache for segmentation slice ${sliceIndex} ` +
`(referencedImageId: ${segImage.referencedImageId}). Ensure the referenced series is fully loaded before storing.`
);
}
return referencedImage;
});
const labelmaps2D = [];
@@ -173,7 +198,8 @@ const commandsModule = ({
const generatedSegmentation = generateSegmentation(referencedImages, labelmap3D, metaData, {
predecessorImageId,
...options,
...getSegmentationSaveOptions(customizationService, dataSourceStoreOverride),
...generateOptions,
});
return generatedSegmentation;
@@ -257,6 +283,8 @@ const commandsModule = ({
const args = {
segmentationId,
options: {
// Resolve store overrides against the data source we are storing into.
dataSource: dataSourceName,
SeriesDescription: series ? undefined : reportName || label || 'Contour Series',
SeriesNumber: series ? undefined : 1 + priorSeriesNumber,
predecessorImageId: series,
@@ -289,6 +317,8 @@ const commandsModule = ({
generateContour: async args => {
const { segmentationId, options } = args;
// `dataSource` is only used by the SEG store path; keep it out of the RTSS options.
const { dataSource: _dataSource, ...contourOptions } = options ?? {};
const segmentations = segmentationService.getSegmentation(segmentationId);
// inject colors to the segmentIndex
@@ -301,10 +331,11 @@ const commandsModule = ({
Number(segmentIndex)
);
});
const predecessorImageId = options?.predecessorImageId ?? segmentations.predecessorImageId;
const predecessorImageId =
contourOptions.predecessorImageId ?? segmentations.predecessorImageId;
const dataset = await generateRTSSFromRepresentation(segmentations, {
predecessorImageId,
...options,
...contourOptions,
});
return { dataset };
},
@@ -0,0 +1,29 @@
import {
DEFAULT_SEG_STORE_MODE,
DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID,
type SegmentationMode,
} from '../utils/segmentationConfig';
export type SegmentLabelCustomization = {
enabledByDefault?: boolean;
labelColor?: number[];
hoverTimeout?: number;
background?: string;
};
export type SegmentationStoreCustomization = {
defaultMode?: SegmentationMode;
transferSyntaxUID?: string;
};
/** Extension-registered defaults: Label Map SEG + RLE Lossless. */
const segmentationCustomization = {
'segmentation.store.defaultMode': DEFAULT_SEG_STORE_MODE,
'segmentation.store.transferSyntaxUID': DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID,
'segmentation.segmentLabel': {
enabledByDefault: false,
hoverTimeout: 1,
} satisfies SegmentLabelCustomization,
};
export default segmentationCustomization;
@@ -0,0 +1,10 @@
import segmentationCustomization from './customizations/segmentationCustomization';
export default function getCustomizationModule() {
return [
{
name: 'default',
value: segmentationCustomization,
},
];
}
@@ -1,16 +1,237 @@
import { utils, Types as OhifTypes } from '@ohif/core';
import { utils, Types as OhifTypes, DicomMetadataStore, classes, log } from '@ohif/core';
import i18n from '@ohif/i18n';
import { metaData, eventTarget } from '@cornerstonejs/core';
import { metaData, eventTarget, utilities as csUtils } from '@cornerstonejs/core';
import { CONSTANTS, segmentation as cstSegmentation } from '@cornerstonejs/tools';
import { adaptersSEG, Enums } from '@cornerstonejs/adapters';
import { SOPClassHandlerId } from './id';
import { dicomlabToRGB } from './utils/dicomlabToRGB';
import { getSegmentationParserType } from './utils/segmentationConfig';
import {
getFrameIndexFromImageId,
isLocalSchemeImageId,
stripFrameFromImageId,
} from './utils/segLocalImageIds';
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4', '1.2.840.10008.5.1.4.1.1.66.7'];
const LABELMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.7';
const loadPromises = {};
const SEG_LOAD_LOG_PREFIX = '[SEG load]';
// Max number of SEG frames fetched/decoded concurrently by the segmentation
// loader. Hard-coded to 16 for now; intended to become configurable (and to
// pair with the full-instance prefetch capability) in a follow-up.
const SEG_FRAME_DECODE_CONCURRENCY = 16;
function _normalizeImageId(imageId: string | string[] | undefined): string | undefined {
if (imageId == null) {
return undefined;
}
return Array.isArray(imageId) ? imageId[0] : imageId;
}
/**
* Expands a WADO-RS frame imageId (…/frames/1) into one imageId per frame.
* Multiframe SEG is stored as separate /frames/N resources on the server.
*/
function getFrameImageIds(segImageId: string, numberOfFrames: number): string[] {
const frameMatch = segImageId.match(/(.*\/frames\/)(\d+)(.*)$/);
if (!frameMatch || numberOfFrames <= 1) {
return [segImageId];
}
const prefix = frameMatch[1];
const suffix = frameMatch[3] || '';
const frameImageIds: string[] = [];
for (let frameNumber = 1; frameNumber <= numberOfFrames; frameNumber++) {
frameImageIds.push(`${prefix}${frameNumber}${suffix}`);
}
return frameImageIds;
}
function _getSegNumberOfFrames(instance: Record<string, unknown>): number {
const fromTag = Number(instance.NumberOfFrames);
if (fromTag > 0) {
return fromTag;
}
const perFrame = instance.PerFrameFunctionalGroupsSequence;
if (Array.isArray(perFrame) && perFrame.length > 0) {
return perFrame.length;
}
return 1;
}
function _ensureSegImageIdMetadataRegistered(
imageId: string | undefined,
instance: Record<string, unknown>
): void {
if (!imageId) {
return;
}
const metadataProvider = classes.MetadataProvider;
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
| string
| undefined;
if (!StudyInstanceUID || !SeriesInstanceUID || !SOPInstanceUID) {
return;
}
metadataProvider.addImageIdToUIDs(imageId, {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
frameNumber: getFrameIndexFromImageId(imageId),
});
}
/** Ensures metadataProvider.get('instance', imageId) resolves for frame-qualified local SEG ids. */
function _ensureSegInstanceMetadataAvailable(
imageId: string | undefined,
instance: Record<string, unknown>
): void {
if (!imageId) {
return;
}
_ensureSegImageIdMetadataRegistered(imageId, instance);
if (metaData.get('instance', imageId)) {
return;
}
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
| string
| undefined;
const storedInstance =
StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID
? DicomMetadataStore.getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID)
: undefined;
classes.MetadataProvider.addCustomMetadata(
imageId,
'instance',
storedInstance || instance
);
}
function _getSegDataSource(extensionManager, instance: Record<string, unknown>) {
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
| string
| undefined;
let localUrl: string | undefined;
if (StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID) {
const storedInstance = DicomMetadataStore.getInstance(
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID
);
localUrl = storedInstance?.url as string | undefined;
}
localUrl = localUrl || (instance.url as string | undefined);
if (localUrl && isLocalSchemeImageId(localUrl)) {
const dicomLocal = extensionManager.getDataSources('dicomlocal');
if (dicomLocal?.[0]) {
return dicomLocal[0];
}
}
return extensionManager.getActiveDataSource()[0];
}
function _getSegImageIdFromInstance(
instance: Record<string, unknown>,
dataSource: { getImageIdsForInstance?: (args: { instance: unknown; frame?: number }) => unknown }
): string | undefined {
const numberOfFrames = _getSegNumberOfFrames(instance);
const frame = numberOfFrames > 1 ? 1 : undefined;
return _normalizeImageId(
dataSource.getImageIdsForInstance?.({ instance, frame }) as string | string[] | undefined
);
}
function _resolveFrameImageIds(
segImageIdStr: string,
instance: Record<string, unknown>,
dataSource: { getImageIdsForInstance?: (args: { instance: unknown; frame?: number }) => unknown }
): string[] {
const numberOfFrames = _getSegNumberOfFrames(instance);
const fromFrameUrl = getFrameImageIds(segImageIdStr, numberOfFrames);
if (fromFrameUrl.length > 1) {
return fromFrameUrl;
}
if (numberOfFrames <= 1) {
return [segImageIdStr];
}
const frameImageIds: string[] = [];
for (let frame = 1; frame <= numberOfFrames; frame++) {
const frameImageId = _normalizeImageId(
dataSource.getImageIdsForInstance?.({ instance, frame }) as string | string[] | undefined
);
if (frameImageId) {
frameImageIds.push(frameImageId);
}
}
return frameImageIds.length ? frameImageIds : [segImageIdStr];
}
function _logSegImageIds({
segDisplaySet,
segImageIdStr,
frameImageIds,
referencedImageIds,
}: {
segDisplaySet: AppTypes.DisplaySet;
segImageIdStr: string;
frameImageIds: string[];
referencedImageIds: string[];
}) {
const instance = segDisplaySet.instance as Record<string, unknown>;
const numberOfFrames = Number(instance?.NumberOfFrames) || 1;
log.debug(SEG_LOAD_LOG_PREFIX, 'Loading SEG pixel data', {
SOPInstanceUID: segDisplaySet.SOPInstanceUID,
SeriesInstanceUID: segDisplaySet.SeriesInstanceUID,
SOPClassUID: segDisplaySet.SOPClassUID,
NumberOfFrames: numberOfFrames,
segmentCount: Object.keys(segDisplaySet.segments || {}).length,
referencedDisplaySetInstanceUID: segDisplaySet.referencedDisplaySetInstanceUID,
referencedImageIdCount: referencedImageIds.length,
referencedImageIds,
segImageIdForMetadata: segImageIdStr,
frameImageIds,
loadSegFramesIndividually: frameImageIds.length > 1,
});
}
function _getDisplaySetsFromSeries(
instances,
servicesManager: AppTypes.ServicesManager,
@@ -171,6 +392,11 @@ function _load(
});
});
// Expose the in-flight load promise so observers (e.g. the viewport service
// waiting to attach the representation) can react to a load failure without
// re-invoking load().
segDisplaySet.loadingPromise = loadPromises[SOPInstanceUID];
return loadPromises[SOPInstanceUID];
}
@@ -178,16 +404,18 @@ async function _loadSegments({
extensionManager,
servicesManager,
segDisplaySet,
headers,
}: withAppTypes) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
}: withAppTypes<{ segDisplaySet: AppTypes.DisplaySet }>) {
const { segmentationService, uiNotificationService, customizationService } =
servicesManager.services;
const instance = segDisplaySet.instance as Record<string, unknown>;
const dataSource = _getSegDataSource(extensionManager, instance);
const segImageIdStr = _getSegImageIdFromInstance(instance, dataSource);
const { segmentationService, uiNotificationService } = servicesManager.services;
const { dicomLoaderService } = utilityModule.exports;
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(segDisplaySet, null, headers);
if (!segImageIdStr) {
throw new Error(
'Could not get imageId for SEG instance (no local wadouri url and getImageIdsForInstance returned nothing).'
);
}
const referencedDisplaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
segDisplaySet.referencedDisplaySetInstanceUID
@@ -197,31 +425,114 @@ async function _loadSegments({
throw new Error('referencedDisplaySet is missing for SEG');
}
// Prefer cached stack imageIds (multiframe SEG fix #4890), then data source expansion.
let { imageIds } = referencedDisplaySet;
if (!imageIds) {
// try images
const { images } = referencedDisplaySet;
imageIds = images.map(image => image.imageId);
if (!imageIds?.length) {
imageIds = dataSource.getImageIdsForDisplaySet?.(referencedDisplaySet);
}
// Todo: what should be defaults here
if (!imageIds?.length) {
imageIds = (referencedDisplaySet as { images?: { imageId: string }[] }).images?.map(
(img: { imageId: string }) => img.imageId
);
}
if (!imageIds?.length) {
throw new Error('referencedDisplaySet has no imageIds');
}
(segDisplaySet as AppTypes.DisplaySet & { referencedImageIds?: string[] }).referencedImageIds =
imageIds;
if (!referencedDisplaySet.imageIds?.length) {
referencedDisplaySet.imageIds = imageIds;
}
const frameImageIds = _resolveFrameImageIds(
segImageIdStr,
segDisplaySet.instance as Record<string, unknown>,
dataSource
);
const segImageIdForMetadata = isLocalSchemeImageId(segImageIdStr)
? stripFrameFromImageId(segImageIdStr)
: segImageIdStr;
_logSegImageIds({
segDisplaySet,
segImageIdStr: segImageIdForMetadata,
frameImageIds,
referencedImageIds: imageIds,
});
_ensureSegInstanceMetadataAvailable(segImageIdForMetadata, instance);
frameImageIds.forEach(id => _ensureSegInstanceMetadataAvailable(id, instance));
const tolerance = 0.001;
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, evt => {
const onProgress = evt => {
const { percentComplete } = evt.detail;
segmentationService._broadcastEvent(segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, {
percentComplete,
});
});
};
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, onProgress);
const results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDICOMSegBuffer(
imageIds,
arrayBuffer,
{ metadataProvider: metaData, tolerance }
);
// Fetch the whole SEG instance as a single Part 10 object and register its
// per-frame compressed pixels into the Cornerstone3D frame registry, so the
// per-frame loads below are served locally instead of one network request
// per frame: SEG frames are so small and numerous that one bulk fetch beats
// hundreds of tiny requests. Enabled by default; per-frame loading is the
// exception (loadMultiframeAsPart10: false in the data source config, or the
// cornerstone.segmentation.loadMultiframeAsPart10 customization). The
// prefetch is awaited until it completes OR fails — deliberately no timeout:
// a failed/unsupported instance fetch resolves quickly and falls back to
// per-frame, while a slow large fetch is still the fastest way to all frames.
const loadMultiframeAsPart10 =
(dataSource?.getConfig?.()?.loadMultiframeAsPart10 as boolean | undefined) ??
(customizationService?.getCustomization?.(
'cornerstone.segmentation.loadMultiframeAsPart10'
) as boolean | undefined) ??
true;
let prefetch;
if (loadMultiframeAsPart10) {
prefetch = dataSource.retrieve?.prefetchInstanceFrames?.({
instance,
imageId: segImageIdForMetadata,
});
if (prefetch?.done) {
await prefetch.done;
}
}
let results;
try {
results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDicomSegImageId(
imageIds,
segImageIdForMetadata,
{
metadataProvider: metaData,
tolerance,
parserType: getSegmentationParserType(
segDisplaySet.SOPClassUID,
customizationService
),
frameImageIds,
concurrency: SEG_FRAME_DECODE_CONCURRENCY,
}
);
} finally {
eventTarget.removeEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, onProgress);
prefetch?.cancel?.();
}
let usedRecommendedDisplayCIELabValue = true;
results.segMetadata.data.forEach((data, i) => {
const resultsTyped = results as {
segMetadata: { data: { rgba?: number[]; RecommendedDisplayCIELabValue?: number[] }[] };
};
resultsTyped.segMetadata.data.forEach((data, i) => {
if (i > 0) {
data.rgba = data.RecommendedDisplayCIELabValue;
@@ -246,6 +557,17 @@ async function _loadSegments({
}
Object.assign(segDisplaySet, results);
const labelMapImageIds = (results as { labelMapImages?: { imageId: string }[][] })
.labelMapImages?.flat()
.map(image => image.imageId);
log.debug(SEG_LOAD_LOG_PREFIX, 'SEG parse complete', {
SOPInstanceUID: segDisplaySet.SOPInstanceUID,
labelMapImageCount: labelMapImageIds?.length ?? 0,
labelMapImageIds,
segmentIndices: Object.keys(segDisplaySet.segments || {}),
});
}
function _segmentationExists(segDisplaySet) {
@@ -4,6 +4,7 @@ import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getCommandsModule from './commandsModule';
import getCustomizationModule from './getCustomizationModule';
import { getToolbarModule } from './getToolbarModule';
const Component = React.lazy(() => {
@@ -28,6 +29,7 @@ const extension = {
*/
id,
getCommandsModule,
getCustomizationModule,
getToolbarModule,
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
const ExtendedOHIFCornerstoneSEGViewport = props => {
@@ -0,0 +1,32 @@
export function isLocalSchemeImageId(imageId: string): boolean {
return /^(wadouri:|dicomfile:|dicomweb:)/.test(imageId);
}
export function stripFrameFromImageId(imageId: string): string {
const queryIndex = imageId.indexOf('?');
if (queryIndex === -1) {
return imageId;
}
const basePath = imageId.slice(0, queryIndex);
const rebuiltQuery = imageId
.slice(queryIndex + 1)
.split('&')
.filter(param => param.split('=')[0] !== 'frame')
.join('&');
return rebuiltQuery ? `${basePath}?${rebuiltQuery}` : basePath;
}
export function appendFrameToImageId(baseImageId: string, frame: number): string {
const withoutFrame = stripFrameFromImageId(baseImageId);
const separator = withoutFrame.includes('?') ? '&' : '?';
return `${withoutFrame}${separator}frame=${frame}`;
}
export function getFrameIndexFromImageId(imageId: string): number {
const frameMatch = imageId.match(/(?:&|\?)frame=(\d+)/);
return frameMatch ? Number(frameMatch[1]) : 1;
}
@@ -0,0 +1,100 @@
export const LABELMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.7';
export const BITMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.4';
/** RLE Lossless — OHIF default SEG store transfer syntax. */
export const DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.5';
/** OHIF default SEG store mode (Label Map Segmentation SOP Class). */
export const DEFAULT_SEG_STORE_MODE = 'labelmap' as const;
export type SegmentationMode = 'labelmap' | 'bitmap';
/**
* Per-data-source overrides for the SEG store defaults. A data source may set
* these under `configuration.segmentation.store` to override the values coming
* from the `segmentation.store.*` customizations. Different back ends support
* different SEG encodings, so the data source is allowed to win over the
* app-wide customization default.
*/
export type SegmentationStoreOverride = {
defaultMode?: SegmentationMode;
transferSyntaxUID?: string;
};
type SegmentationCustomizationReader = {
getCustomization: (customizationId: string) => unknown;
};
function getStoreDefaultMode(
customizationService?: SegmentationCustomizationReader,
override?: SegmentationStoreOverride
): SegmentationMode {
const mode =
override?.defaultMode ??
(customizationService?.getCustomization('segmentation.store.defaultMode') as
| SegmentationMode
| undefined) ??
DEFAULT_SEG_STORE_MODE;
return mode === 'bitmap' ? 'bitmap' : 'labelmap';
}
function getStoreTransferSyntaxUID(
customizationService?: SegmentationCustomizationReader,
override?: SegmentationStoreOverride
): string {
return (
override?.transferSyntaxUID ??
(customizationService?.getCustomization(
'segmentation.store.transferSyntaxUID'
) as string | undefined) ??
DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID
);
}
/**
* Resolves the parser type for loading a DICOM SEG instance.
* Uses the instance SOP Class UID when it is a known SEG class; otherwise falls back to store defaultMode.
*/
export function getSegmentationParserType(
sopClassUID: string | undefined,
customizationService?: SegmentationCustomizationReader
): SegmentationMode {
if (sopClassUID === LABELMAP_SEG_SOP_CLASS_UID) {
return 'labelmap';
}
if (sopClassUID === BITMAP_SEG_SOP_CLASS_UID) {
return 'bitmap';
}
return getStoreDefaultMode(customizationService);
}
/**
* Options passed to @cornerstonejs/adapters generateSegmentation when exporting or storing SEG.
*
* Defaults to **Label Map + RLE Lossless**. Customizations (or per-data-source
* `configuration.segmentation.store`) are only needed to opt into bitmap and/or
* uncompressed Explicit VR Little Endian.
*/
export function getSegmentationSaveOptions(
customizationService?: SegmentationCustomizationReader,
override?: SegmentationStoreOverride
): {
sopClassUID: string;
transferSyntaxUID: string;
transferSyntaxUid: string;
} {
const defaultMode = getStoreDefaultMode(customizationService, override);
const sopClassUID =
defaultMode === 'bitmap' ? BITMAP_SEG_SOP_CLASS_UID : LABELMAP_SEG_SOP_CLASS_UID;
const transferSyntaxUID = getStoreTransferSyntaxUID(
customizationService,
override
);
return {
sopClassUID,
transferSyntaxUID,
transferSyntaxUid: transferSyntaxUID,
};
}
@@ -95,10 +95,13 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
};
const getCornerstoneViewport = useCallback(() => {
// Stack uses the referenced series (data[0]); SEG is applied as an overlay (data[1]).
// Passing only the SEG display set leaves the stack with derived labelmap imageIds,
// which are not displayable without the underlying grayscale series.
return (
<OHIFCornerstoneViewport
{...props}
displaySets={[segDisplaySet]}
displaySets={[referencedDisplaySet, segDisplaySet]}
viewportOptions={{
viewportType: viewportOptions.viewportType,
toolGroupId: toolGroupId,
@@ -111,7 +114,14 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
}}
/>
);
}, [viewportId, segDisplaySet, toolGroupId, props, viewportOptions]);
}, [
viewportId,
segDisplaySet,
referencedDisplaySet,
toolGroupId,
props,
viewportOptions,
]);
useEffect(() => {
if (segIsLoading) {
+4 -4
View File
@@ -32,7 +32,7 @@
"@ohif/core": "workspace:*",
"@ohif/extension-cornerstone": "workspace:*",
"@ohif/ui": "workspace:*",
"dcmjs": "0.49.4",
"dcmjs": "0.52.0",
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
"prop-types": "15.8.1",
@@ -40,9 +40,9 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.1.3",
"@cornerstonejs/core": "5.1.3",
"@cornerstonejs/tools": "5.1.3",
"@cornerstonejs/adapters": "5.4.12",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"classnames": "2.5.1"
},
"devDependencies": {
@@ -37,7 +37,7 @@
"@ohif/extension-default": "workspace:*",
"@ohif/i18n": "workspace:*",
"@ohif/ui": "workspace:*",
"dcmjs": "0.49.4",
"dcmjs": "0.52.0",
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
"prop-types": "15.8.1",
@@ -45,8 +45,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/core": "5.1.3",
"@cornerstonejs/tools": "5.1.3",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"classnames": "2.5.1"
},
"devDependencies": {
+9 -9
View File
@@ -38,11 +38,11 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2",
"@cornerstonejs/codec-openjpeg": "1.3.0",
"@cornerstonejs/codec-openjph": "2.4.7",
"@cornerstonejs/dicom-image-loader": "5.1.3",
"@cornerstonejs/dicom-image-loader": "5.4.12",
"@ohif/core": "workspace:*",
"@ohif/extension-default": "workspace:*",
"@ohif/ui": "workspace:*",
"dcmjs": "0.49.4",
"dcmjs": "0.52.0",
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
"prop-types": "15.8.1",
@@ -52,13 +52,13 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.1.3",
"@cornerstonejs/ai": "5.1.3",
"@cornerstonejs/core": "5.1.3",
"@cornerstonejs/labelmap-interpolation": "5.1.3",
"@cornerstonejs/metadata": "5.1.3",
"@cornerstonejs/polymorphic-segmentation": "5.1.3",
"@cornerstonejs/tools": "5.1.3",
"@cornerstonejs/adapters": "5.4.12",
"@cornerstonejs/ai": "5.4.12",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/labelmap-interpolation": "5.4.12",
"@cornerstonejs/metadata": "5.4.12",
"@cornerstonejs/polymorphic-segmentation": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"@icr/polyseg-wasm": "0.4.0",
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
"@kitware/vtk.js": "35.5.3",
@@ -350,7 +350,10 @@ function _getInstanceNumberFromVolume(
const imageId = imageIds[imageIndex];
if (!imageId) {
return {};
// No image at this index (e.g. a single-image volume scrolled out of
// range). Return undefined so the overlay falls back to the slice count
// instead of rendering an empty object as "[object Object]".
return;
}
const { instanceNumber } = metaData.get('generalImageModule', imageId) || {};
+9 -2
View File
@@ -1982,8 +1982,15 @@ function commandsModule({
* Use it before initializing the toolGroup with the tools.
*/
initializeSegmentLabelTool: ({ tools }) => {
const appConfig = extensionManager.appConfig;
const segmentLabelConfig = appConfig.segmentation?.segmentLabel;
const { customizationService } = servicesManager.services;
const segmentLabelConfig = customizationService.getCustomization(
'segmentation.segmentLabel'
) as {
enabledByDefault?: boolean;
labelColor?: number[];
hoverTimeout?: number;
background?: string;
};
if (segmentLabelConfig?.enabledByDefault) {
const activeTools = tools?.active ?? [];
+6
View File
@@ -7,6 +7,7 @@ import {
imageRetrievalPoolManager,
} from '@cornerstonejs/core';
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
import { Types } from '@ohif/core';
import Enums from './enums';
@@ -169,6 +170,11 @@ const cornerstoneExtension: Types.Extensions.Extension = {
imageRetrievalPoolManager.clearRequestStack(type);
});
// Release the typed metadata registry (NATURALIZED instances registered via
// prefetchPart10Instance hold full compressed Part 10 buffers that live
// outside the size-capped cornerstone image cache)
csMetadataUtilities.clearCacheData();
cineService.setIsCineEnabled(false);
enabledElementReset();
@@ -1799,6 +1799,10 @@ describe('SegmentationService', () => {
const segmentationId = 'segmentationId';
const segmentationData = {
segmentationId,
representation: {
type: csToolsEnums.SegmentationRepresentations.Labelmap,
data: {},
},
config: {
label: 'Segmentation 1',
},
@@ -302,7 +302,15 @@ class SegmentationService extends PubSubService {
}
): Promise<void> {
const segmentation = this.getSegmentation(segmentationId);
if (segmentation && !segmentation.predecessorImageId && predecessorImageId) {
if (!segmentation) {
console.warn(
`addSegmentationRepresentation: segmentation "${segmentationId}" is not in state yet`
);
return;
}
if (!segmentation.predecessorImageId && predecessorImageId) {
segmentation.predecessorImageId = predecessorImageId;
}
const csViewport = this.getAndValidateViewport(viewportId);
@@ -488,10 +496,22 @@ class SegmentationService extends PubSubService {
throw new Error('No instances were provided for the referenced display set of the SEG');
}
const imageIds = images.map(image => image.imageId);
// Use the same imageIds as SEG parse (_loadSegments stores these on segDisplaySet).
const imageIds =
segDisplaySet.referencedImageIds ||
(referencedDisplaySet.imageIds as string[] | undefined) ||
images.map(image => image.imageId);
if (!imageIds?.length) {
throw new Error('referencedDisplaySet has no imageIds for SEG');
}
const derivedImages = labelMapImages?.flat();
const derivedImageIds = derivedImages.map(image => image.imageId);
// Note: instance runtime props (frameNumber, imageId, url, ...) are
// intentionally non-enumerable, so this spread deliberately does NOT copy
// them — frameNumber must not be carried onto these derived image entries.
// Read such props off the original instance, never off a copy.
segDisplaySet.images = derivedImages.map(image => ({
...image,
...metaData.get('instance', image.referencedImageId),
@@ -572,11 +592,6 @@ class SegmentationService extends PubSubService {
const colorLUTIndex = addColorLUT(colorLUT);
this._segmentationIdToColorLUTIndexMap.set(segmentationId, colorLUTIndex);
this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, {
segmentationId,
segDisplaySet,
});
const seg: cstTypes.SegmentationPublicInput = {
segmentationId,
representation: {
@@ -596,8 +611,18 @@ class SegmentationService extends PubSubService {
segDisplaySet.isLoaded = true;
// Add the segmentation to cornerstone state BEFORE broadcasting that loading is
// complete. Subscribers (e.g. CornerstoneViewportService) react synchronously and
// call addSegmentationRepresentation, which now early-returns when the segmentation
// is not yet in cornerstone state. Broadcasting first would make that guard always
// fire on initial load, silently preventing the representation from being attached.
this.addOrUpdateSegmentation(seg);
this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, {
segmentationId,
segDisplaySet,
});
return segmentationId;
}
@@ -772,9 +797,16 @@ class SegmentationService extends PubSubService {
if (existingSegmentation) {
// Update the existing segmentation
this.updateSegmentationInSource(segmentationId, data as Partial<cstTypes.Segmentation>);
} else {
} else if (
'representation' in data &&
(data as cstTypes.SegmentationPublicInput).representation
) {
// Add a new segmentation
this.addSegmentationToSource(data as cstTypes.SegmentationPublicInput);
} else {
console.warn(
`addOrUpdateSegmentation: skipping add for ${segmentationId} — missing representation`
);
}
}
@@ -1596,7 +1628,13 @@ class SegmentationService extends PubSubService {
private determineViewportAndSegmentationType(csViewport, segmentation) {
const isVolumeViewport = isVolumeViewportType(csViewport);
const isVolumeSegmentation = 'volumeId' in segmentation.representationData[LABELMAP];
const labelmapData = segmentation?.representationData?.[LABELMAP];
if (!labelmapData) {
return { isVolumeViewport, isVolumeSegmentation: false };
}
const isVolumeSegmentation = 'volumeId' in labelmapData;
return { isVolumeViewport, isVolumeSegmentation };
}
@@ -1294,24 +1294,122 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
? csToolsEnums.SegmentationRepresentations.Labelmap
: csToolsEnums.SegmentationRepresentations.Contour;
const { predecessorImageId } = displaySet;
const segmentationRepresentationPromise = segmentationService.addSegmentationRepresentation(
viewport.id,
{
segmentationId,
predecessorImageId,
type: representationType,
config: {
blendMode:
viewport?.getBlendMode?.() === 1
? BlendModes.LABELMAP_EDGE_PROJECTION_BLEND
: undefined,
},
const applyRepresentation = () => {
const { predecessorImageId } = displaySet;
const segmentationRepresentationPromise =
segmentationService.addSegmentationRepresentation(viewport.id, {
segmentationId,
predecessorImageId,
type: representationType,
config: {
blendMode:
viewport?.getBlendMode?.() === 1
? BlendModes.LABELMAP_EDGE_PROJECTION_BLEND
: undefined,
},
});
this.storePresentation({ viewportId: viewport.id });
return segmentationRepresentationPromise;
};
// SEG overlay is registered during stack setup, but cornerstone segmentation state
// is created in displaySet.load() (async). Wait until it exists before adding representation.
if (displaySet.Modality === 'SEG') {
if (segmentationService.getSegmentation(segmentationId)) {
return applyRepresentation();
}
);
// store the segmentation presentation id in the viewport info
this.storePresentation({ viewportId: viewport.id });
return segmentationRepresentationPromise;
// Bound the wait so a failed/aborted SEG load (where SEGMENTATION_LOADING_COMPLETE
// never fires) cannot leave this promise — and the viewport setup awaiting it —
// hanging indefinitely.
const SEG_LOADING_TIMEOUT_MS = 120000;
return new Promise<void>(resolve => {
let settled = false;
let timeoutId;
// Final resolution — extra calls are harmless (resolve is a no-op after
// the first), which lets the timeout stay armed while a representation
// apply is in flight and still bound it.
const finish = () => {
clearTimeout(timeoutId);
resolve();
};
// Give up without applying (load failure / timeout before the event).
const settleWithoutApply = () => {
settled = true;
unsubscribe();
finish();
};
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
async (evt: { segDisplaySet?: OhifTypes.DisplaySet }) => {
if (settled || evt.segDisplaySet?.displaySetInstanceUID !== segmentationId) {
return;
}
// Dedupe immediately, but resolve only after the representation is
// applied: awaiters continue into rotation/flip/render and broadcast
// VIEWPORT_DATA_CHANGED (hanging-protocol callbacks, toolbar, e2e
// readiness), which must not observe a viewport whose overlay
// doesn't exist yet. applyRepresentation can be genuinely async
// (stack→volume viewport conversion). The timeout is deliberately
// NOT cleared here — a hung apply stays bounded.
settled = true;
unsubscribe();
try {
await applyRepresentation();
} catch (error) {
console.warn(
`Failed to apply segmentation representation for "${segmentationId}":`,
error
);
} finally {
finish();
}
}
);
// Stop waiting immediately if the SEG load itself fails — otherwise the
// loading-complete event never fires and we would idle until the timeout.
const loadingPromise = (displaySet as { loadingPromise?: Promise<unknown> })
.loadingPromise;
loadingPromise?.catch(error => {
if (settled) {
return;
}
console.warn(
`Segmentation "${segmentationId}" failed to load; skipping representation setup.`,
error
);
settleWithoutApply();
});
timeoutId = setTimeout(() => {
if (settled) {
// The load completed but applyRepresentation is hung — resolve so
// viewport setup is never blocked past the bound (best-effort
// semantics; the apply may still land later).
console.warn(
`Timed out applying segmentation representation for "${segmentationId}"; resolving viewport readiness anyway.`
);
resolve();
return;
}
console.warn(
`Timed out waiting for segmentation "${segmentationId}" to load; skipping representation setup.`
);
settleWithoutApply();
}, SEG_LOADING_TIMEOUT_MS);
});
}
return applyRepresentation();
}
private async _addOverlayRepresentations(
@@ -53,10 +53,14 @@ export function setupSegmentationDataModifiedHandler({
});
if (!isUnsubscribed && updatedSegmentation) {
segmentationService.addOrUpdateSegmentation({
segmentationId,
segments: updatedSegmentation.segments,
});
const existingSegmentation = segmentationService.getSegmentation(segmentationId);
if (existingSegmentation) {
segmentationService.addOrUpdateSegmentation({
segmentationId,
segments: updatedSegmentation.segments,
});
}
}
},
1000
+2 -1
View File
@@ -31,7 +31,7 @@
"peerDependencies": {
"@ohif/core": "workspace:*",
"@ohif/i18n": "workspace:*",
"dcmjs": "0.49.4",
"dcmjs": "0.52.0",
"dicomweb-client": "0.10.4",
"prop-types": "15.8.1",
"react": "18.3.1",
@@ -48,6 +48,7 @@
"react-color": "2.19.3"
},
"devDependencies": {
"cross-env": "7.0.3",
"webpack-merge": "5.10.0"
},
"keywords": [
@@ -1,6 +1,11 @@
import { DicomMetadataStore, IWebApiDataSource, utils } from '@ohif/core';
import OHIF from '@ohif/core';
import dcmjs from 'dcmjs';
import {
datasetToDicomBlob,
makeExistingPropertiesNonEnumerable,
setNonEnumerableInstanceProperty,
} from '../utils/dicomWriter';
import { appendFrameQueryToImageId } from '../utils/appendFrameQueryToImageId';
const metadataProvider = OHIF.classes.MetadataProvider;
const { EVENTS } = DicomMetadataStore;
@@ -152,14 +157,15 @@ function createDicomLocalApi(dicomLocalConfig) {
SOPInstanceUID,
} = instance;
instance.imageId = imageId;
setNonEnumerableInstanceProperty(instance, 'imageId', imageId);
makeExistingPropertiesNonEnumerable(instance);
// Add imageId specific mapping to this data as the URL isn't necessarily WADO-URI.
metadataProvider.addImageIdToUIDs(imageId, {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
frameIndex: isMultiframe ? index : 1,
frameNumber: isMultiframe ? index + 1 : 1,
});
});
@@ -174,7 +180,7 @@ function createDicomLocalApi(dicomLocalConfig) {
},
store: {
dicom: naturalizedReport => {
const reportBlob = dcmjs.data.datasetToBlob(naturalizedReport);
const reportBlob = datasetToDicomBlob(naturalizedReport);
//Create a URL for the binary.
var objectUrl = URL.createObjectURL(reportBlob);
@@ -211,9 +217,6 @@ function createDicomLocalApi(dicomLocalConfig) {
getImageIdsForInstance({ instance, frame }) {
// Important: Never use instance.imageId because it might be multiframe,
// which would make it an invalid imageId.
// if (instance.imageId) {
// return instance.imageId;
// }
const { StudyInstanceUID, SeriesInstanceUID } = instance;
const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID;
@@ -223,13 +226,20 @@ function createDicomLocalApi(dicomLocalConfig) {
SOPInstanceUID
);
let imageId = storedInstance.url;
const baseImageId = storedInstance?.url || instance.url;
if (frame !== undefined) {
imageId += `&frame=${frame}`;
if (!baseImageId) {
return;
}
return imageId;
const numberOfFrames = Number(storedInstance?.NumberOfFrames || instance.NumberOfFrames) || 1;
if (numberOfFrames > 1) {
const frameNumber = frame !== undefined ? frame : 1;
return appendFrameQueryToImageId(baseImageId, frameNumber);
}
return baseImageId;
},
deleteStudyMetadataPromise() {
console.log('deleteStudyMetadataPromise not implemented');
@@ -12,11 +12,17 @@ import dcm4cheeReject from './dcm4cheeReject.js';
import getImageId from './utils/getImageId.js';
import dcmjs from 'dcmjs';
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import { retrieveStudyMetadata, deleteStudyMetadataPromise } from './retrieveStudyMetadata.js';
import StaticWadoClient from './utils/StaticWadoClient';
import getDirectURL from '../utils/getDirectURL';
import { fixBulkDataURI } from './utils/fixBulkDataURI';
import { HeadersInterface } from '@ohif/core/src/types/RequestHeaders';
import {
getDatasetTransferSyntaxUID,
setNonEnumerableInstanceProperty,
writeDicomDictToPart10Buffer,
} from '../utils/dicomWriter';
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';
import { getRenderedURL } from './retrieveRendered';
@@ -26,7 +32,6 @@ const { naturalizeDataset, denaturalizeDataset } = DicomMetaDictionary;
const ImplementationClassUID = '2.25.270695996825855179949881587723571202391.2.0.0';
const ImplementationVersionName = 'OHIF-3.11.0';
const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
const metadataProvider = classes.MetadataProvider;
@@ -308,6 +313,86 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
*/
getWadoDicomWebClient: () => wadoDicomWebClient,
/**
* Best-effort prefetch of a whole multiframe instance as a single Part 10
* object, registered into the Cornerstone3D NATURALIZED frame registry so
* subsequent per-frame image loads are served locally instead of issuing
* one network request per frame (see the "Behaviours" doc
* segmentation-multiframe-part10-prefetch).
*
* Whether to use it at all is the caller's policy (the SEG handler
* resolves the `loadMultiframeAsPart10` config/customization, defaulting
* it on — per-frame loading is the explicit opt-out there).
* Never throws into the caller — on any failure it resolves `done` to
* `false` and the normal per-frame load path is used.
*
* @returns `{ done: Promise<boolean>, cancel: () => void }`.
*/
prefetchInstanceFrames: ({ instance, imageId }) => {
const noop = { done: Promise.resolve(false), cancel: () => {} };
if (!instance || !imageId) {
return noop;
}
const StudyInstanceUID = instance.StudyInstanceUID;
const SeriesInstanceUID = instance.SeriesInstanceUID;
const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID;
if (!StudyInstanceUID || !SeriesInstanceUID || !SOPInstanceUID) {
return noop;
}
let cancelled = false;
// Lazy resolver: dicomweb-client.retrieveInstance returns the Part 10
// instance as an ArrayBuffer, unwrapping multipart/related transparently
// (and returning the raw object for single-part responses).
const resolvePart10 = async () => {
wadoDicomWebClient.headers = getAuthorizationHeader();
const result = await wadoDicomWebClient.retrieveInstance({
studyInstanceUID: StudyInstanceUID,
seriesInstanceUID: SeriesInstanceUID,
sopInstanceUID: SOPInstanceUID,
});
if (cancelled) {
throw new Error('prefetchInstanceFrames cancelled');
}
if (result instanceof ArrayBuffer) {
return result;
}
if (Array.isArray(result) && result[0] instanceof ArrayBuffer) {
return result[0];
}
if (result && (result as { buffer?: ArrayBuffer }).buffer instanceof ArrayBuffer) {
return (result as ArrayBufferView).buffer as ArrayBuffer;
}
throw new Error('Unexpected retrieveInstance result for instance prefetch');
};
const done = (async () => {
try {
await dicomImageLoader.prefetchPart10Instance(imageId, resolvePart10);
return !cancelled;
} catch (error) {
console.warn(
'[prefetchInstanceFrames] full-instance prefetch failed; falling back to per-frame loads',
error
);
return false;
}
})();
return {
done,
cancel: () => {
cancelled = true;
},
};
},
bulkDataURI: async ({ StudyInstanceUID, BulkDataURI }) => {
qidoDicomWebClient.headers = getAuthorizationHeader();
const options = {
@@ -372,7 +457,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
FileMetaInformationVersion: dataset._meta?.FileMetaInformationVersion?.Value,
MediaStorageSOPClassUID: dataset.SOPClassUID,
MediaStorageSOPInstanceUID: dataset.SOPInstanceUID,
TransferSyntaxUID: EXPLICIT_VR_LITTLE_ENDIAN,
TransferSyntaxUID: getDatasetTransferSyntaxUID(dataset),
ImplementationClassUID,
ImplementationVersionName,
};
@@ -384,7 +469,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
effectiveDicomDict = defaultDicomDict;
}
const part10Buffer = effectiveDicomDict.write();
const part10Buffer = writeDicomDictToPart10Buffer(effectiveDicomDict);
const options = {
datasets: [part10Buffer],
@@ -445,9 +530,9 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
instance,
});
instance.imageId = imageId;
instance.wadoRoot = dicomWebConfig.wadoRoot;
instance.wadoUri = dicomWebConfig.wadoUri;
setNonEnumerableInstanceProperty(instance, 'imageId', imageId);
setNonEnumerableInstanceProperty(instance, 'wadoRoot', dicomWebConfig.wadoRoot);
setNonEnumerableInstanceProperty(instance, 'wadoUri', dicomWebConfig.wadoUri);
metadataProvider.addImageIdToUIDs(imageId, {
StudyInstanceUID,
@@ -544,8 +629,8 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
// Adding instanceMetadata to OHIF MetadataProvider
naturalizedInstances.forEach(instance => {
instance.wadoRoot = dicomWebConfig.wadoRoot;
instance.wadoUri = dicomWebConfig.wadoUri;
setNonEnumerableInstanceProperty(instance, 'wadoRoot', dicomWebConfig.wadoRoot);
setNonEnumerableInstanceProperty(instance, 'wadoUri', dicomWebConfig.wadoUri);
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
const numberOfFrames = instance.NumberOfFrames || 1;
@@ -571,7 +656,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
const imageId = implementation.getImageIdsForInstance({
instance,
});
instance.imageId = imageId;
setNonEnumerableInstanceProperty(instance, 'imageId', imageId);
});
DicomMetadataStore.addInstances(naturalizedInstances, madeInClient);
+18 -9
View File
@@ -1,5 +1,6 @@
import { Types, DicomMetadataStore, utils } from '@ohif/core';
import dcmjs from 'dcmjs';
import { datasetToDicomBlob, setNonEnumerableInstanceProperty } from './utils/dicomWriter';
import { registerNaturalizedDatasetsForLocalWadouri } from './utils/registerNaturalizedDatasetForLocalWadouri';
const { downloadBlob } = utils;
@@ -784,11 +785,12 @@ const commandsModule = ({
if (dataSource === 'download') {
return async dicom => {
const instances = Array.isArray(dicom) ? dicom : [dicom];
registerNaturalizedDatasetsForLocalWadouri(instances);
DicomMetadataStore.addInstances(instances, true);
if (instances.length !== 1) {
throw new Error('Download only supports a single DICOM instance');
}
const reportBlob = dcmjs.data.datasetToBlob(instances[0]);
const reportBlob = datasetToDicomBlob(instances[0]);
downloadBlob(reportBlob, { filename: defaultFileName || 'dicom.dcm' });
};
}
@@ -796,14 +798,15 @@ const commandsModule = ({
if (dataSource === 'copyToClipboard') {
return async dicom => {
const instances = Array.isArray(dicom) ? dicom : [dicom];
registerNaturalizedDatasetsForLocalWadouri(instances);
DicomMetadataStore.addInstances(instances, true);
if (instances.length !== 1) {
throw new Error('Copy to clipboard only supports a single DICOM instance');
}
const reportBlob = dcmjs.data.datasetToBlob(instances[0]);
const reportBlob = datasetToDicomBlob(instances[0]);
const type = defaultContentType || 'application/dicom';
await navigator.clipboard.write([
new ClipboardItem({ [type]: new Blob([reportBlob], { type }) }),
new ClipboardItem({ [type]: reportBlob }),
]);
};
}
@@ -817,12 +820,18 @@ const commandsModule = ({
return async (dicom, { dicomDict } = {}) => {
const instances = Array.isArray(dicom) ? dicom : [dicom];
const config = resolvedDataSource.getConfig?.();
if (config?.wadoRoot) {
instances.forEach(instance => {
instance.wadoRoot = config.wadoRoot;
});
// Always keep an in-memory wadouri copy so DICOM can be read without re-fetching.
registerNaturalizedDatasetsForLocalWadouri(instances);
if (dataSource !== 'dicomlocal') {
const config = resolvedDataSource.getConfig?.();
if (config?.wadoRoot) {
instances.forEach(instance => {
setNonEnumerableInstanceProperty(instance, 'wadoRoot', config.wadoRoot);
});
}
}
DicomMetadataStore.addInstances(instances, true);
for (const instance of instances) {
await resolvedDataSource.store.dicom(instance, null, dicomDict);
@@ -10,6 +10,18 @@ type DataSource = {
placeHolder: string;
};
/** Radix Select item value for "Create new series" (state remains null). */
const NEW_SERIES_SELECT_VALUE = '__new_series_id__';
type SeriesOption = {
optionKey: string;
selectValue: string;
value: string | null;
seriesNumber: number;
description: string | null;
label: string;
};
type ReportDialogProps = {
dataSources: DataSource[];
modality?: string;
@@ -46,20 +58,32 @@ function ReportDialog({
const [selectedSeries, setSelectedSeries] = useState<string | null>(predecessorImageId || null);
const [reportName, setReportName] = useState('');
const seriesOptions = useMemo(() => {
const seriesOptions = useMemo((): SeriesOption[] => {
const displaySetsMap = displaySetService.getDisplaySetCache();
const displaySets = Array.from(displaySetsMap.values());
const options = displaySets
.filter(ds => ds.Modality === modality)
.map(ds => ({
value: ds.predecessorImageId || ds.SeriesInstanceUID,
seriesNumber: isFinite(ds.SeriesNumber) ? ds.SeriesNumber : minSeriesNumber,
description: ds.SeriesDescription,
label: `${ds.SeriesDescription} ${ds.SeriesDate}/${ds.SeriesTime} ${ds.SeriesNumber}`,
}));
.map(ds => {
const value = ds.predecessorImageId || ds.SeriesInstanceUID;
const selectValue = value || ds.displaySetInstanceUID;
return {
optionKey: `series-${ds.displaySetInstanceUID}`,
selectValue,
value: value || null,
seriesNumber: isFinite(ds.SeriesNumber) ? ds.SeriesNumber : minSeriesNumber,
description: ds.SeriesDescription,
label: `${ds.SeriesDescription} ${ds.SeriesDate}/${ds.SeriesTime} ${ds.SeriesNumber}`,
};
})
.filter(
option =>
option.selectValue && option.selectValue !== NEW_SERIES_SELECT_VALUE
);
return [
{
optionKey: NEW_SERIES_SELECT_VALUE,
selectValue: NEW_SERIES_SELECT_VALUE,
value: null,
description: null,
seriesNumber: minSeriesNumber,
@@ -67,7 +91,17 @@ function ReportDialog({
},
...options,
];
}, [displaySetService, modality]);
}, [displaySetService, modality, minSeriesNumber]);
const handleSeriesChange = useCallback(
(selectValue: string) => {
const option = seriesOptions.find(o => o.selectValue === selectValue);
setSelectedSeries(
selectValue === NEW_SERIES_SELECT_VALUE ? null : (option?.value ?? selectValue)
);
},
[seriesOptions]
);
useEffect(() => {
const seriesOption = seriesOptions.find(s => s.value === selectedSeries);
@@ -115,6 +149,11 @@ function ReportDialog({
const showDataSourceSelect = dataSources?.length > 1;
const showDownloadButton = enableDownload;
const selectedSeriesSelectValue =
selectedSeries == null
? NEW_SERIES_SELECT_VALUE
: (seriesOptions.find(o => o.value === selectedSeries)?.selectValue ??
selectedSeries);
return (
<div className="text-foreground flex min-w-[400px] max-w-md flex-col">
@@ -146,8 +185,8 @@ function ReportDialog({
<div className={showDataSourceSelect ? 'mt-1 w-1/2' : 'mt-1 w-full'}>
<div className="mb-1 pl-1 text-base">Series</div>
<Select
value={selectedSeries}
onValueChange={setSelectedSeries}
value={selectedSeriesSelectValue}
onValueChange={handleSeriesChange}
>
<SelectTrigger>
<SelectValue placeholder="Select a series" />
@@ -155,8 +194,8 @@ function ReportDialog({
<SelectContent>
{seriesOptions.map(series => (
<SelectItem
key={series.value}
value={series.value}
key={series.optionKey}
value={series.selectValue}
>
{series.label}
</SelectItem>
@@ -172,8 +211,8 @@ function ReportDialog({
<div className="w-1/3">
<div className="mb-1 pl-1 text-base">Series</div>
<Select
value={selectedSeries}
onValueChange={setSelectedSeries}
value={selectedSeriesSelectValue}
onValueChange={handleSeriesChange}
>
<SelectTrigger>
<SelectValue placeholder="Select a series" />
@@ -181,8 +220,8 @@ function ReportDialog({
<SelectContent>
{seriesOptions.map(series => (
<SelectItem
key={series.value}
value={series.value}
key={series.optionKey}
value={series.selectValue}
>
{series.label}
</SelectItem>
+4
View File
@@ -27,6 +27,7 @@ import { useHangingProtocolStageIndexStore } from './stores/useHangingProtocolSt
import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocolStore';
import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore';
import { callInputDialogAutoComplete, callInputDialog } from './utils/callInputDialog';
import { releaseLocalWadouriRegistrations } from './utils/registerNaturalizedDatasetForLocalWadouri';
import colorPickerDialog from './utils/colorPickerDialog';
import promptSaveReport from './utils/promptSaveReport';
@@ -52,6 +53,9 @@ const defaultExtension: Types.Extensions.Extension = {
useHangingProtocolStageIndexStore.getState().clearHangingProtocolStageIndexMap();
useToggleHangingProtocolStore.getState().clearToggleHangingProtocol();
useViewportsByPositionStore.getState().clearViewportsByPosition();
// Free the Part 10 Blobs (and any parsed copies) retained for locally
// generated instances (SEG etc.) registered with the wadouri loader.
releaseLocalWadouriRegistrations();
},
getDataSourcesModule,
getViewportModule,
@@ -0,0 +1,9 @@
/**
* Appends ?frame= or &frame= to a local/wadouri imageId.
*/
export function appendFrameQueryToImageId(baseImageId, frame) {
const withoutFrame = baseImageId.split('&frame=')[0].split('?frame=')[0];
const separator = withoutFrame.includes('?') ? '&' : '?';
return `${withoutFrame}${separator}frame=${frame}`;
}
@@ -0,0 +1,228 @@
import { TextEncoder, TextDecoder } from 'util';
import dcmjs from 'dcmjs';
import {
DICOM_WRITE_OPTIONS,
datasetToDicomPart10Buffer,
writeDicomDictToPart10Buffer,
} from './dicomWriter';
// jsdom does not expose TextEncoder/TextDecoder, which dcmjs's buffer streams need.
if (typeof (global as { TextEncoder?: unknown }).TextEncoder === 'undefined') {
(global as { TextEncoder?: unknown }).TextEncoder = TextEncoder;
}
if (typeof (global as { TextDecoder?: unknown }).TextDecoder === 'undefined') {
(global as { TextDecoder?: unknown }).TextDecoder = TextDecoder;
}
const RLE_LOSSLESS = '1.2.840.10008.1.2.5';
const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
const IMPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2';
const PIXEL_DATA_TAG = '7FE00010';
// Use frames larger than dcmjs's 20KB fragment size so that, if a frame were
// ever split, we'd see more than one fragment per frame.
const FRAME_BYTES = 30000;
function makeDicomDict(transferSyntaxUID, vr, pixelDataValue) {
const dict = new dcmjs.data.DicomDict({
'00020010': { vr: 'UI', Value: [transferSyntaxUID] },
'00020002': { vr: 'UI', Value: ['1.2.840.10008.5.1.4.1.1.66.4'] },
'00020003': { vr: 'UI', Value: ['1.2.3.4.5'] },
});
dict.upsertTag(PIXEL_DATA_TAG, vr, pixelDataValue);
return dict;
}
/**
* Locates the PixelData element in a Part-10 buffer and, when it is encapsulated,
* walks the encapsulated items (Basic Offset Table + fragments).
*/
function inspectPixelData(arrayBuffer) {
const dv = new DataView(arrayBuffer);
// Skip the 128-byte preamble + "DICM", then read the file-meta group length
// from (0002,0000) UL to find where the dataset begins.
const metaGroupLength = dv.getUint32(140, true);
let offset = 144 + metaGroupLength;
// PixelData (7FE0,0010), explicit VR (OB/OW) — long form: VR(2) + reserved(2) + length(4).
const group = dv.getUint16(offset, true);
const element = dv.getUint16(offset + 2, true);
if (group !== 0x7fe0 || element !== 0x0010) {
throw new Error(
`Expected PixelData at offset ${offset}, found ${group.toString(16)},${element.toString(16)}`
);
}
const length = dv.getUint32(offset + 8, true);
if (length !== 0xffffffff) {
return { encapsulated: false, length };
}
// Encapsulated: first item is the Basic Offset Table, then one item per fragment,
// terminated by the Sequence Delimitation Item (FFFE,E0DD).
let p = offset + 12;
const itemSizes: number[] = [];
while (p + 8 <= dv.byteLength) {
const itemGroup = dv.getUint16(p, true);
const itemElement = dv.getUint16(p + 2, true);
const itemLength = dv.getUint32(p + 4, true);
p += 8;
if (itemGroup === 0xfffe && itemElement === 0xe0dd) {
break; // sequence delimiter
}
if (itemGroup !== 0xfffe || itemElement !== 0xe000) {
throw new Error(`Unexpected item tag at ${p - 8}`);
}
itemSizes.push(itemLength);
p += itemLength;
}
// itemSizes[0] is the Basic Offset Table; the rest are frame fragments.
return {
encapsulated: true,
totalItems: itemSizes.length,
basicOffsetTableSize: itemSizes[0],
fragmentSizes: itemSizes.slice(1),
};
}
describe('dicomWriter DICOM_WRITE_OPTIONS fragmentation', () => {
it('writes exactly one fragment per frame (plus the Basic Offset Table) for compressed pixel data', () => {
const frames = [new Uint8Array(FRAME_BYTES).buffer, new Uint8Array(FRAME_BYTES).buffer];
const dict = makeDicomDict(RLE_LOSSLESS, 'OB', frames);
const buffer = writeDicomDictToPart10Buffer(dict);
const result = inspectPixelData(buffer);
expect(result.encapsulated).toBe(true);
// 1 Basic Offset Table item + 1 fragment per frame.
expect(result.totalItems).toBe(frames.length + 1);
expect(result.fragmentSizes).toHaveLength(frames.length);
// Each frame stayed in a single fragment despite exceeding the 20KB fragment size.
result.fragmentSizes.forEach(size => expect(size).toBe(FRAME_BYTES));
});
it('does not fragment uncompressed pixel data', () => {
const pixelData = new Uint16Array(FRAME_BYTES).buffer;
const dict = makeDicomDict(EXPLICIT_VR_LITTLE_ENDIAN, 'OW', [pixelData]);
const buffer = writeDicomDictToPart10Buffer(dict);
const result = inspectPixelData(buffer);
expect(result.encapsulated).toBe(false);
expect(result.length).toBe(pixelData.byteLength);
});
it('confirms fragmentMultiframe:false is what prevents a single large frame from splitting', () => {
// Control: with fragmentMultiframe enabled, a >20KB frame splits into multiple
// fragments — proving DICOM_WRITE_OPTIONS.fragmentMultiframe:false is meaningful.
const frames = [new Uint8Array(FRAME_BYTES).buffer];
const dict = makeDicomDict(RLE_LOSSLESS, 'OB', frames);
const buffer = dict.write({ ...DICOM_WRITE_OPTIONS, fragmentMultiframe: true });
const result = inspectPixelData(buffer);
expect(result.encapsulated).toBe(true);
expect(result.fragmentSizes.length).toBeGreaterThan(1);
});
});
/**
* Byte-level walk of the file meta group. Returns the meta element tags in file
* order and asserts that FileMetaInformationGroupLength spans them exactly.
*/
function readFileMetaTags(arrayBuffer: ArrayBuffer) {
const dv = new DataView(arrayBuffer);
// Preamble(128) + 'DICM'(4), then (0002,0000) UL <groupLength> must come first.
expect(dv.getUint16(132, true)).toBe(0x0002);
expect(dv.getUint16(134, true)).toBe(0x0000);
const groupLength = dv.getUint32(140, true);
const end = 144 + groupLength;
const tags: string[] = [];
let p = 144;
while (p < end) {
const group = dv.getUint16(p, true);
const element = dv.getUint16(p + 2, true);
const vr = String.fromCharCode(dv.getUint8(p + 4), dv.getUint8(p + 5));
// File meta is always Explicit VR Little Endian.
let length: number;
if (['OB', 'OW', 'OF', 'SQ', 'UT', 'UN'].includes(vr)) {
length = dv.getUint32(p + 8, true);
p += 12 + length;
} else {
length = dv.getUint16(p + 6, true);
p += 8 + length;
}
tags.push(
`${group.toString(16).padStart(4, '0')}${element.toString(16).padStart(4, '0')}`
);
}
// The declared group length covers the meta elements exactly — a garbage
// element counted into it (or omitted from it) would break this.
expect(p).toBe(end);
return tags;
}
function makeNaturalizedDataset(meta) {
return {
_meta: meta,
SOPClassUID: '1.2.840.10008.5.1.4.1.1.88.33',
SOPInstanceUID: '1.2.3.4.5',
StudyInstanceUID: '1.2.3.4',
SeriesInstanceUID: '1.2.3.4.1',
Modality: 'SR',
PatientID: 'TEST-PATIENT',
};
}
describe('datasetToDicomPart10Buffer file meta', () => {
it('writes only group-0002 elements (no garbage (0000,0000) tag) into the file meta', () => {
const dataset = makeNaturalizedDataset({
TransferSyntaxUID: { vr: 'UI', Value: [EXPLICIT_VR_LITTLE_ENDIAN] },
});
const buffer = datasetToDicomPart10Buffer(dataset);
const tags = readFileMetaTags(buffer);
expect(tags.length).toBeGreaterThan(0);
tags.forEach(tag => expect(tag.startsWith('0002')).toBe(true));
// Elements must appear in ascending tag order within group 2.
expect(tags).toEqual([...tags].sort());
const reread = dcmjs.data.DicomMessage.readFile(buffer);
const metaKeys = Object.keys(reread.meta);
expect(metaKeys).not.toContain('00000000');
metaKeys.forEach(key => expect(key.startsWith('0002')).toBe(true));
expect(reread.meta['00020010'].Value).toEqual([EXPLICIT_VR_LITTLE_ENDIAN]);
});
it('honors a plain-string _meta.TransferSyntaxUID that dcmjs alone would default away', () => {
// dcmjs datasetToDict only reads the object form (_meta.TransferSyntaxUID.Value[0])
// and silently falls back to Explicit VR LE for a plain string — the hex-key
// assignment in applyTransferSyntaxToFileMeta is what preserves it.
const dataset = makeNaturalizedDataset({
TransferSyntaxUID: IMPLICIT_VR_LITTLE_ENDIAN,
});
const buffer = datasetToDicomPart10Buffer(dataset);
const tags = readFileMetaTags(buffer);
tags.forEach(tag => expect(tag.startsWith('0002')).toBe(true));
const reread = dcmjs.data.DicomMessage.readFile(buffer);
expect(Object.keys(reread.meta)).not.toContain('00000000');
expect(reread.meta['00020010'].Value).toEqual([IMPLICIT_VR_LITTLE_ENDIAN]);
// The dataset body survives a round trip under the preserved syntax.
expect(reread.dict['00080018'].Value).toEqual(['1.2.3.4.5']);
});
});
+110
View File
@@ -0,0 +1,110 @@
import dcmjs from 'dcmjs';
export const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
export const DICOM_WRITE_OPTIONS = {
allowInvalidVRLength: false,
// `fragmentMultiframe` only governs whether a SINGLE frame is split across
// multiple fragments (dcmjs splits frames larger than its 20KB fragment size).
// It does NOT merge frames: in an encapsulated (compressed) transfer syntax
// every frame is always written as its own fragment, preceded by the Basic
// Offset Table; in an uncompressed syntax pixel data is never fragmented at
// all. Keeping this `false` therefore yields exactly one fragment per frame
// for compressed SEG — the conformant layout — without splitting large frames.
fragmentMultiframe: false,
};
/** OHIF runtime fields — not DICOM tags; must not be enumerable for dcmjs datasetToDict. */
export const RUNTIME_INSTANCE_PROPERTY_KEYS = [
'url',
'wadorsuri',
'wadouri',
'wadoRoot',
'wadoUri',
'wadoUriRoot',
'imageRendering',
'imageId',
'_parentInstance',
'frameNumber',
] as const;
/**
* Attaches OHIF runtime data on an instance without enumerable keys (safe for dcmjs datasetToDict).
*/
export function setNonEnumerableInstanceProperty(
instance: Record<string, unknown>,
key: string,
value: unknown
) {
Object.defineProperty(instance, key, {
value,
enumerable: false,
writable: true,
configurable: true,
});
}
/**
* Re-defines any existing enumerable runtime properties as non-enumerable (keeps values).
*/
export function makeExistingPropertiesNonEnumerable(instance: Record<string, unknown>) {
for (const key of RUNTIME_INSTANCE_PROPERTY_KEYS) {
if (!Object.prototype.hasOwnProperty.call(instance, key)) {
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
if (!descriptor || descriptor.enumerable === false) {
continue;
}
setNonEnumerableInstanceProperty(instance, key, descriptor.value);
}
}
export function getDatasetTransferSyntaxUID(dataset) {
const fromMeta = dataset?._meta?.TransferSyntaxUID;
if (typeof fromMeta === 'string') {
return fromMeta;
}
if (Array.isArray(fromMeta?.Value)) {
return fromMeta.Value[0];
}
if (typeof dataset?.TransferSyntaxUID === 'string') {
return dataset.TransferSyntaxUID;
}
return EXPLICIT_VR_LITTLE_ENDIAN;
}
function applyTransferSyntaxToFileMeta(dicomDict, transferSyntaxUID) {
if (!transferSyntaxUID || !dicomDict?.meta) {
return;
}
// Hex key only: DicomMessage.write treats every meta key as a tag string, so a
// naturalized key like 'TransferSyntaxUID' would be parsed via parseInt(..., 16)
// into tag (0000,0000) and written as a garbage element in group 2.
dicomDict.meta['00020010'] = { vr: 'UI', Value: [transferSyntaxUID] };
}
export function datasetToDicomPart10Buffer(dataset) {
makeExistingPropertiesNonEnumerable(dataset);
const transferSyntaxUID = getDatasetTransferSyntaxUID(dataset);
const dicomDict = dcmjs.data.datasetToDict(dataset);
applyTransferSyntaxToFileMeta(dicomDict, transferSyntaxUID);
return dicomDict.write(DICOM_WRITE_OPTIONS);
}
export function datasetToDicomBlob(dataset) {
const part10Buffer = datasetToDicomPart10Buffer(dataset);
return new Blob([part10Buffer], { type: 'application/dicom' });
}
export function writeDicomDictToPart10Buffer(dicomDict) {
return dicomDict.write(DICOM_WRITE_OPTIONS);
}
@@ -0,0 +1,146 @@
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import OHIF from '@ohif/core';
import {
datasetToDicomBlob,
makeExistingPropertiesNonEnumerable,
setNonEnumerableInstanceProperty,
} from './dicomWriter';
import { appendFrameQueryToImageId } from './appendFrameQueryToImageId';
const metadataProvider = OHIF.classes.MetadataProvider;
const SEG_REGISTER_LOG_PREFIX = '[SEG register]';
// Every registration pins a full Part 10 Blob in the fileManager (and, once
// loaded, a parsed copy in the dataSetCacheManager) with no other owner, so
// track the imageIds and release them on mode exit.
const registeredImageIds = new Set();
/**
* Releases everything previously registered via
* {@link registerNaturalizedDatasetForLocalWadouri}: the Part 10 Blob held by the
* wadouri fileManager and any parsed dataset the dataSetCacheManager retained.
* Call on mode exit.
*/
export function releaseLocalWadouriRegistrations() {
const { fileManager, dataSetCacheManager } = dicomImageLoader.wadouri;
for (const imageId of registeredImageIds) {
// 'dicomfile:<index>' — the index doubles as the fileManager slot and the
// dataSetCacheManager uri (the scheme is stripped for cache keys).
const uri = imageId.substring(imageId.indexOf(':') + 1);
const fileIndex = Number(uri);
if (Number.isInteger(fileIndex) && fileIndex >= 0) {
fileManager.remove(fileIndex);
}
// unload() decrements a refcount shared with cornerstone's image decache;
// nothing may outlive mode exit, so drive it to zero.
for (let guard = 0; guard < 100000 && dataSetCacheManager.isLoaded(uri); guard++) {
dataSetCacheManager.unload(uri);
}
}
registeredImageIds.clear();
}
/**
* Registers a naturalized DICOM dataset with the wadouri file manager so it can be
* loaded like other locally stored instances (blob URL), instead of remote wadors URLs.
*
* Datasets without pixel data (SR, RTSTRUCT, ...) are skipped: they have no frames
* to serve through the image loader, so registering them would only pin their
* Part 10 Blob in memory for the rest of the session.
*
* @param {object} dataset - Naturalized DICOM instance (e.g. generated SEG).
* @param {object} [options]
* @param {string[]} [options.referencedImageIds] - Used for logging / frame count hints.
* @returns {string|undefined} wadouri imageId assigned to dataset.url, or undefined when skipped
*/
export function registerNaturalizedDatasetForLocalWadouri(dataset, options = {}) {
const { referencedImageIds = [] } = options;
if (
dataset.PixelData === undefined &&
!Array.isArray(dataset.PerFrameFunctionalGroupsSequence)
) {
OHIF.log.debug(
SEG_REGISTER_LOG_PREFIX,
'Skipping local wadouri registration (no pixel data)',
{ SOPClassUID: dataset.SOPClassUID, SOPInstanceUID: dataset.SOPInstanceUID }
);
return undefined;
}
const blob = datasetToDicomBlob(dataset);
const imageId = dicomImageLoader.wadouri.fileManager.add(blob);
registeredImageIds.add(imageId);
setNonEnumerableInstanceProperty(dataset, 'url', imageId);
const { StudyInstanceUID, SeriesInstanceUID } = dataset;
const SOPInstanceUID = dataset.SOPInstanceUID || dataset.SopInstanceUID;
const perFrameGroups = dataset.PerFrameFunctionalGroupsSequence;
if (Array.isArray(perFrameGroups) && !dataset.NumberOfFrames) {
dataset.NumberOfFrames = perFrameGroups.length;
}
// Local only — never written back to the dataset: single-frame IODs (SR,
// RTSTRUCT) must not gain a NumberOfFrames element in their serialized form.
const numberOfFrames = Math.max(
Number(dataset.NumberOfFrames) || 0,
Array.isArray(perFrameGroups) ? perFrameGroups.length : 0,
1
);
const frameImageIds = [];
if (StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID) {
const registerMapping = (id, frameNumber = 1) => {
metadataProvider.addImageIdToUIDs(id, {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
frameNumber,
});
};
for (let frame = 1; frame <= numberOfFrames; frame++) {
const frameImageId =
numberOfFrames > 1 ? appendFrameQueryToImageId(imageId, frame) : imageId;
frameImageIds.push(frameImageId);
registerMapping(frameImageId, frame);
}
}
makeExistingPropertiesNonEnumerable(dataset);
OHIF.log.debug(SEG_REGISTER_LOG_PREFIX, 'Registered local wadouri instance', {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
numberOfFrames,
baseImageId: imageId,
frameImageIds,
blobSizeBytes: blob.size,
referencedImageIdCount: referencedImageIds.length,
SegmentationType: dataset.SegmentationType,
SOPClassUID: dataset.SOPClassUID,
});
return imageId;
}
export function registerNaturalizedDatasetsForLocalWadouri(instances, options = {}) {
const list = Array.isArray(instances) ? instances : [instances];
const { referencedImageIds = [] } = options;
list.forEach(instance =>
registerNaturalizedDatasetForLocalWadouri(instance, { referencedImageIds })
);
return list;
}
@@ -0,0 +1,177 @@
import { TextEncoder, TextDecoder } from 'util';
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
import OHIF from '@ohif/core';
import {
registerNaturalizedDatasetForLocalWadouri,
releaseLocalWadouriRegistrations,
} from './registerNaturalizedDatasetForLocalWadouri';
// jsdom does not expose TextEncoder/TextDecoder, which dcmjs's buffer streams need.
if (typeof global.TextEncoder === 'undefined') {
global.TextEncoder = TextEncoder;
}
if (typeof global.TextDecoder === 'undefined') {
global.TextDecoder = TextDecoder;
}
jest.mock('@cornerstonejs/dicom-image-loader', () => ({
__esModule: true,
default: {
wadouri: {
fileManager: {
add: jest.fn(() => 'dicomfile:0'),
remove: jest.fn(),
},
dataSetCacheManager: {
isLoaded: jest.fn(() => false),
unload: jest.fn(),
},
},
},
}));
jest.mock('@ohif/core', () => ({
__esModule: true,
default: {
classes: {
MetadataProvider: {
addImageIdToUIDs: jest.fn(),
},
},
log: {
debug: jest.fn(),
},
},
}));
const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
const addBlob = dicomImageLoader.wadouri.fileManager.add;
const addImageIdToUIDs = OHIF.classes.MetadataProvider.addImageIdToUIDs;
function makeDataset(extra = {}) {
return {
_meta: { TransferSyntaxUID: { vr: 'UI', Value: [EXPLICIT_VR_LITTLE_ENDIAN] } },
SOPInstanceUID: '1.2.3.4.5',
StudyInstanceUID: '1.2.3.4',
SeriesInstanceUID: '1.2.3.4.1',
PatientID: 'TEST-PATIENT',
...extra,
};
}
describe('registerNaturalizedDatasetForLocalWadouri', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
// Drain the module-level registration tracking between tests.
releaseLocalWadouriRegistrations();
});
it('skips datasets without pixel data (SR/RTSTRUCT) and does not mutate them', () => {
const dataset = makeDataset({
SOPClassUID: '1.2.840.10008.5.1.4.1.1.88.33',
Modality: 'SR',
});
const imageId = registerNaturalizedDatasetForLocalWadouri(dataset);
// No frames to serve through the image loader: nothing registered, no Blob pinned.
expect(imageId).toBeUndefined();
expect(addBlob).not.toHaveBeenCalled();
expect(addImageIdToUIDs).not.toHaveBeenCalled();
// The serialized form of an SR must not gain a (0028,0008) element or a url.
expect(Object.prototype.hasOwnProperty.call(dataset, 'NumberOfFrames')).toBe(false);
expect(Object.prototype.hasOwnProperty.call(dataset, 'url')).toBe(false);
});
it('registers a single-frame pixel-bearing dataset without stamping NumberOfFrames', () => {
const dataset = makeDataset({
SOPClassUID: '1.2.840.10008.5.1.4.1.1.7',
Modality: 'OT',
PixelData: new Uint8Array(4).buffer,
});
const imageId = registerNaturalizedDatasetForLocalWadouri(dataset);
expect(Object.prototype.hasOwnProperty.call(dataset, 'NumberOfFrames')).toBe(false);
expect(imageId).toBe('dicomfile:0');
expect(addBlob).toHaveBeenCalledTimes(1);
// Single frame: the bare imageId is mapped, no ?frame= query.
expect(addImageIdToUIDs).toHaveBeenCalledTimes(1);
expect(addImageIdToUIDs).toHaveBeenCalledWith('dicomfile:0', {
StudyInstanceUID: '1.2.3.4',
SeriesInstanceUID: '1.2.3.4.1',
SOPInstanceUID: '1.2.3.4.5',
frameNumber: 1,
});
});
it('still registers per-frame imageIds (and NumberOfFrames) for a multiframe SEG', () => {
const dataset = makeDataset({
SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4',
Modality: 'SEG',
PerFrameFunctionalGroupsSequence: [{}, {}, {}],
});
registerNaturalizedDatasetForLocalWadouri(dataset);
// Legitimately multiframe: the count is derived from the per-frame groups
// and must be serialized (enumerable).
expect(dataset.NumberOfFrames).toBe(3);
expect(Object.getOwnPropertyDescriptor(dataset, 'NumberOfFrames').enumerable).toBe(true);
expect(addImageIdToUIDs).toHaveBeenCalledTimes(3);
for (let frame = 1; frame <= 3; frame++) {
expect(addImageIdToUIDs).toHaveBeenNthCalledWith(frame, `dicomfile:0?frame=${frame}`, {
StudyInstanceUID: '1.2.3.4',
SeriesInstanceUID: '1.2.3.4.1',
SOPInstanceUID: '1.2.3.4.5',
frameNumber: frame,
});
}
});
it('keeps the wadouri imageId on the dataset without making it serializable', () => {
const dataset = makeDataset({
SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4',
Modality: 'SEG',
PixelData: new Uint8Array(4).buffer,
});
registerNaturalizedDatasetForLocalWadouri(dataset);
expect(dataset.url).toBe('dicomfile:0');
expect(Object.getOwnPropertyDescriptor(dataset, 'url').enumerable).toBe(false);
});
it('releases the retained Blob and cached dataset on releaseLocalWadouriRegistrations', () => {
const dataset = makeDataset({
SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4',
Modality: 'SEG',
PixelData: new Uint8Array(4).buffer,
});
dicomImageLoader.wadouri.fileManager.add.mockReturnValueOnce('dicomfile:7');
// Simulate a parsed dataset held with a refcount of 2 (e.g. two frame loads).
dicomImageLoader.wadouri.dataSetCacheManager.isLoaded
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValue(false);
registerNaturalizedDatasetForLocalWadouri(dataset);
releaseLocalWadouriRegistrations();
expect(dicomImageLoader.wadouri.fileManager.remove).toHaveBeenCalledWith(7);
expect(dicomImageLoader.wadouri.dataSetCacheManager.unload).toHaveBeenCalledTimes(2);
expect(dicomImageLoader.wadouri.dataSetCacheManager.unload).toHaveBeenCalledWith('7');
// Idempotent: the tracking set was drained.
dicomImageLoader.wadouri.fileManager.remove.mockClear();
releaseLocalWadouriRegistrations();
expect(dicomImageLoader.wadouri.fileManager.remove).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -30,7 +30,7 @@
"peerDependencies": {
"@ohif/core": "workspace:*",
"@ohif/ui": "workspace:*",
"dcmjs": "0.49.4",
"dcmjs": "0.52.0",
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
"prop-types": "15.8.1",
+1 -1
View File
@@ -30,7 +30,7 @@
"peerDependencies": {
"@ohif/core": "workspace:*",
"@ohif/ui": "workspace:*",
"dcmjs": "0.49.4",
"dcmjs": "0.52.0",
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
"prop-types": "15.8.1",
+4 -3
View File
@@ -27,14 +27,14 @@
"start": "pnpm run dev"
},
"peerDependencies": {
"@cornerstonejs/core": "5.1.3",
"@cornerstonejs/tools": "5.1.3",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"@ohif/core": "workspace:*",
"@ohif/extension-cornerstone-dicom-sr": "workspace:*",
"@ohif/extension-default": "workspace:*",
"@ohif/ui": "workspace:*",
"classnames": "2.5.1",
"dcmjs": "0.49.4",
"dcmjs": "0.52.0",
"lodash.debounce": "4.0.8",
"prop-types": "15.8.1",
"react": "18.3.1",
@@ -48,6 +48,7 @@
"xstate": "4.38.3"
},
"devDependencies": {
"cross-env": "7.0.3",
"webpack-merge": "5.10.0"
},
"keywords": [
+1 -1
View File
@@ -30,7 +30,7 @@
"peerDependencies": {
"@ohif/core": "workspace:*",
"@ohif/ui": "workspace:*",
"dcmjs": "0.49.4",
"dcmjs": "0.52.0",
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
"prop-types": "15.8.1",
+1 -1
View File
@@ -30,7 +30,7 @@
"peerDependencies": {
"@ohif/core": "workspace:*",
"@ohif/ui": "workspace:*",
"dcmjs": "0.49.4",
"dcmjs": "0.52.0",
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
"prop-types": "15.8.1",
+2 -2
View File
@@ -36,8 +36,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/core": "5.1.3",
"@cornerstonejs/tools": "5.1.3",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"@ohif/core": "workspace:*",
"@ohif/extension-cornerstone": "workspace:*",
"@ohif/extension-default": "workspace:*",