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:
59 files changed
+2501
-322
No files matched your search
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user