diff --git a/src/core/cine/DicomCineImage.ts b/src/core/cine/DicomCineImage.ts index 2b8aa9c84..c1a33b89b 100644 --- a/src/core/cine/DicomCineImage.ts +++ b/src/core/cine/DicomCineImage.ts @@ -35,6 +35,12 @@ function pickPixelSpacing(header: CineHeader): [number, number] { if (!dx || !dy || !Number.isFinite(dx) || !Number.isFinite(dy)) continue; return [Math.abs(dx) * sx, Math.abs(dy) * sy]; } + + if (header.pixelSpacing) { + const [rowSpacing, columnSpacing] = header.pixelSpacing; + return [columnSpacing, rowSpacing]; + } + return [1, 1]; } diff --git a/src/core/cine/__tests__/DicomCineImage.spec.ts b/src/core/cine/__tests__/DicomCineImage.spec.ts index 41dffbdad..3c8e3093c 100644 --- a/src/core/cine/__tests__/DicomCineImage.spec.ts +++ b/src/core/cine/__tests__/DicomCineImage.spec.ts @@ -18,6 +18,7 @@ function cineHeader(overrides: Partial = {}): CineHeader { bitsAllocated: 8, planarConfiguration: 0, photometricInterpretation: 'MONOCHROME2', + pixelSpacing: null, frameTimeMs: null, patient: { PatientID: 'patient-1', @@ -136,6 +137,7 @@ describe('DicomCineImage spacing', () => { const image = new DicomCineImage( parseResult( cineHeader({ + pixelSpacing: [1.8, 0.45], regions: [ { physicalDeltaX: 10, @@ -160,4 +162,20 @@ describe('DicomCineImage spacing', () => { image.dispose(); }); + + it('falls back to DICOM PixelSpacing in VTK axis order when no spatial ultrasound region is available', () => { + const image = new DicomCineImage( + parseResult( + cineHeader({ + pixelSpacing: [1.8, 0.45], + }) + ) + ); + + expect(Array.from(image.getVtkImageData().getSpacing())).toEqual([ + 0.45, 1.8, 1, + ]); + + image.dispose(); + }); }); diff --git a/src/core/cine/__tests__/parseCineDicom.spec.ts b/src/core/cine/__tests__/parseCineDicom.spec.ts index a292c853b..b072f70e7 100644 --- a/src/core/cine/__tests__/parseCineDicom.spec.ts +++ b/src/core/cine/__tests__/parseCineDicom.spec.ts @@ -104,6 +104,7 @@ const COMMON_TAGS = ( samplesPerPixel?: number; photometricInterpretation?: string; planarConfiguration?: number; + pixelSpacing?: readonly [number, number]; } = {} ) => { const samplesPerPixel = options.samplesPerPixel ?? 1; @@ -131,6 +132,16 @@ const COMMON_TAGS = ( elementShort(0x0028, 0x0008, 'IS', ascii(String(numberOfFrames))), elementShort(0x0028, 0x0010, 'US', u16Bytes(rows)), elementShort(0x0028, 0x0011, 'US', u16Bytes(cols)), + ...(options.pixelSpacing == null + ? [] + : [ + elementShort( + 0x0028, + 0x0030, + 'DS', + ascii(options.pixelSpacing.map((n) => String(n)).join('\\')) + ), + ]), elementShort(0x0028, 0x0100, 'US', u16Bytes(8)), elementShort(0x0028, 0x0101, 'US', u16Bytes(8)), ]); @@ -144,6 +155,7 @@ function buildNativeDicom(opts: { samplesPerPixel?: number; photometricInterpretation?: string; planarConfiguration?: number; + pixelSpacing?: readonly [number, number]; }): Uint8Array { const { numberOfFrames, @@ -250,11 +262,24 @@ describe('parseCineDicom on synthetic fixtures', () => { expect(header.cols).toBe(3); expect(header.transferSyntaxUID).toBe(TS_EXPLICIT_VR_LE); expect(header.photometricInterpretation).toBe('MONOCHROME2'); + expect(header.pixelSpacing).toBeNull(); expect(frames.length).toBe(5); expect(frames[0].byteLength).toBe(4 * 3); expect(frames[0][0]).toBe(0x7f); }); + it('parses PixelSpacing row and column values', () => { + const bytes = buildNativeDicom({ + numberOfFrames: 2, + rows: 4, + cols: 3, + pixelSpacing: [1.8, 0.45], + }); + const { header } = parseCineDicom(bytes); + + expect(header.pixelSpacing).toEqual([1.8, 0.45]); + }); + it('parses native RGB planar-configuration metadata', () => { const bytes = buildNativeDicom({ numberOfFrames: 2, diff --git a/src/core/cine/parseCineDicom.ts b/src/core/cine/parseCineDicom.ts index 0ddb5a41f..411b4753a 100644 --- a/src/core/cine/parseCineDicom.ts +++ b/src/core/cine/parseCineDicom.ts @@ -22,6 +22,7 @@ const TAG_SERIES_DESCRIPTION = 'x0008103e'; const TAG_NUMBER_OF_FRAMES = 'x00280008'; const TAG_ROWS = 'x00280010'; const TAG_COLUMNS = 'x00280011'; +const TAG_PIXEL_SPACING = 'x00280030'; const TAG_BITS_ALLOCATED = 'x00280100'; const TAG_SAMPLES_PER_PIXEL = 'x00280002'; const TAG_PHOTOMETRIC = 'x00280004'; @@ -79,6 +80,7 @@ export type CineHeader = { bitsAllocated: number; planarConfiguration: number; photometricInterpretation: string; + pixelSpacing: [number, number] | null; frameTimeMs: number | null; patient: CinePatientInfo; study: CineStudyInfo; @@ -116,6 +118,20 @@ const readDecimalString = (ds: DataSet, tag: string): number | null => { return v !== undefined && Number.isFinite(v) ? v : null; }; +const isPositiveFinite = (v: number | undefined): v is number => + v !== undefined && Number.isFinite(v) && v > 0; + +const readPositiveDecimalPair = ( + ds: DataSet, + tag: string +): [number, number] | null => { + const first = ds.floatString(tag, 0); + const second = ds.floatString(tag, 1); + return isPositiveFinite(first) && isPositiveFinite(second) + ? [first, second] + : null; +}; + function buildRegion(item: DataSet): CineUltrasoundRegion { return { physicalDeltaX: readDouble(item, TAG_PHYSICAL_DELTA_X), @@ -223,6 +239,7 @@ export function parseCineDicom( bitsAllocated, planarConfiguration, photometricInterpretation: str(ds, TAG_PHOTOMETRIC), + pixelSpacing: readPositiveDecimalPair(ds, TAG_PIXEL_SPACING), frameTimeMs: readDecimalString(ds, TAG_FRAME_TIME), patient: { PatientID: str(ds, TAG_PATIENT_ID), diff --git a/src/core/dicomTags.ts b/src/core/dicomTags.ts index 0a2b26ce1..fe2e13cc7 100644 --- a/src/core/dicomTags.ts +++ b/src/core/dicomTags.ts @@ -29,6 +29,7 @@ const tags: Tag[] = [ { name: 'ImagePositionPatient', tag: '0020|0032' }, { name: 'ImageOrientationPatient', tag: '0020|0037' }, { name: 'PixelSpacing', tag: '0028|0030' }, + { name: 'SpacingBetweenSlices', tag: '0018|0088' }, { name: 'SamplesPerPixel', tag: '0028|0002' }, { name: 'RescaleIntercept', tag: '0028|1052' }, { name: 'RescaleSlope', tag: '0028|1053' }, diff --git a/src/store/__tests__/datasets-dicom-cine.spec.ts b/src/store/__tests__/datasets-dicom-cine.spec.ts index 3ee060b5a..f43536af8 100644 --- a/src/store/__tests__/datasets-dicom-cine.spec.ts +++ b/src/store/__tests__/datasets-dicom-cine.spec.ts @@ -119,6 +119,7 @@ function cineHeader(overrides: Partial = {}): CineHeader { bitsAllocated: 8, planarConfiguration: 0, photometricInterpretation: 'MONOCHROME1', + pixelSpacing: null, frameTimeMs: null, patient: { PatientID: 'patient-1', diff --git a/src/utils/__tests__/allocateImageFromChunks.spec.ts b/src/utils/__tests__/allocateImageFromChunks.spec.ts index 66a05d3e8..f6c675246 100644 --- a/src/utils/__tests__/allocateImageFromChunks.spec.ts +++ b/src/utils/__tests__/allocateImageFromChunks.spec.ts @@ -1,6 +1,35 @@ -import { getTypedArrayForDataRange } from '@/src/utils/allocateImageFromChunks'; +import type { Chunk } from '@/src/core/streaming/chunk'; +import { Tags } from '@/src/core/dicomTags'; +import { + allocateImageFromChunks, + getTypedArrayForDataRange, +} from '@/src/utils/allocateImageFromChunks'; import { describe, it, expect } from 'vitest'; +function chunk(overrides: Record = {}) { + const metadata = { + [Tags.SOPInstanceUID]: '1.2.3', + [Tags.ImagePositionPatient]: '0\\0\\0', + [Tags.ImageOrientationPatient]: '1\\0\\0\\0\\1\\0', + [Tags.Rows]: '3', + [Tags.Columns]: '4', + [Tags.BitsStored]: '16', + [Tags.PixelRepresentation]: '0', + [Tags.SamplesPerPixel]: '1', + ...overrides, + }; + return { + metadata: Object.entries(metadata), + } as unknown as Chunk; +} + +function positionedChunk(z: number, overrides: Record = {}) { + return chunk({ + [Tags.ImagePositionPatient]: `0\\0\\${z}`, + ...overrides, + }); +} + describe('getTypedArrayForDataRange', () => { it('should handle edge cases', () => { expect(getTypedArrayForDataRange(-(2 ** 7), 2 ** 7 - 1)).toBe(Int8Array); @@ -11,3 +40,29 @@ describe('getTypedArrayForDataRange', () => { expect(getTypedArrayForDataRange(0, 2 ** 32 - 1)).toBe(Uint32Array); }); }); + +describe('allocateImageFromChunks', () => { + it('matches ITK spacing order for single-slice images with SpacingBetweenSlices', () => { + const image = allocateImageFromChunks([ + chunk({ + [Tags.PixelSpacing]: '2.5\\0.75', + [Tags.SpacingBetweenSlices]: '7.25', + }), + ]); + + expect(Array.from(image.getSpacing())).toEqual([0.75, 2.5, 7.25]); + }); + + it('keeps deriving multi-slice Z spacing from ImagePositionPatient distance', () => { + const image = allocateImageFromChunks([ + positionedChunk(0, { + [Tags.PixelSpacing]: '2.5\\0.75', + [Tags.SpacingBetweenSlices]: '19', + }), + positionedChunk(9), + positionedChunk(18), + ]); + + expect(Array.from(image.getSpacing())).toEqual([0.75, 2.5, 9]); + }); +}); diff --git a/src/utils/allocateImageFromChunks.ts b/src/utils/allocateImageFromChunks.ts index c7e2da3fe..d46f59ed7 100644 --- a/src/utils/allocateImageFromChunks.ts +++ b/src/utils/allocateImageFromChunks.ts @@ -9,6 +9,7 @@ import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; const ImagePositionPatientTag = NAME_TO_TAG.get('ImagePositionPatient')!; const ImageOrientationPatientTag = NAME_TO_TAG.get('ImageOrientationPatient')!; const PixelSpacingTag = NAME_TO_TAG.get('PixelSpacing')!; +const SpacingBetweenSlicesTag = NAME_TO_TAG.get('SpacingBetweenSlices')!; const RowsTag = NAME_TO_TAG.get('Rows')!; const ColumnsTag = NAME_TO_TAG.get('Columns')!; const BitsStoredTag = NAME_TO_TAG.get('BitsStored')!; @@ -23,6 +24,10 @@ function toVec(s: Maybe): number[] | null { return s.split('\\').map((a) => Number(a)) as number[]; } +function isPositiveFiniteNumber(value: number) { + return Number.isFinite(value) && value > 0; +} + function getBitStorageSize(num: number, signed: boolean) { const addSignedBit = signed ? 1 : 0; const val = num < 0 ? -num : num + 1; // range shift for log2 @@ -78,6 +83,7 @@ export function allocateImageFromChunks(sortedChunks: Chunk[]) { const imagePositionPatient = toVec(meta.get(ImagePositionPatientTag)); const imageOrientationPatient = toVec(meta.get(ImageOrientationPatientTag)); const pixelSpacing = toVec(meta.get(PixelSpacingTag)); + const spacingBetweenSlices = Number(meta.get(SpacingBetweenSlicesTag)); const rows = Number(meta.get(RowsTag) ?? 0); const columns = Number(meta.get(ColumnsTag) ?? 0); const bitsStored = Number(meta.get(BitsStoredTag) ?? 0); @@ -120,20 +126,30 @@ export function allocateImageFromChunks(sortedChunks: Chunk[]) { image.setOrigin(imagePositionPatient as Vector3); } - image.setSpacing([1, 1, 1]); - if (slices > 1 && imagePositionPatient && pixelSpacing) { + const spacing: Vector3 = [1, 1, 1]; + if ( + pixelSpacing && + pixelSpacing.length >= 2 && + isPositiveFiniteNumber(pixelSpacing[0]) && + isPositiveFiniteNumber(pixelSpacing[1]) + ) { + spacing[0] = pixelSpacing[1]; + spacing[1] = pixelSpacing[0]; + } + + if (imagePositionPatient && sortedChunks.length > 1) { const lastMeta = new Map(sortedChunks[sortedChunks.length - 1].metadata); const lastIPP = toVec(lastMeta.get(ImagePositionPatientTag)); if (lastIPP) { // assumption: uniform Z spacing const zVec = vec3.create(); - const firstIPP = imagePositionPatient; - vec3.sub(zVec, lastIPP as vec3, firstIPP as vec3); - const zSpacing = vec3.len(zVec) / (slices - 1) || 1; - const spacing = [...pixelSpacing, zSpacing]; - image.setSpacing(spacing); + vec3.sub(zVec, lastIPP as vec3, imagePositionPatient as vec3); + spacing[2] = vec3.len(zVec) / (slices - 1) || 1; } + } else if (slices === 1 && isPositiveFiniteNumber(spacingBetweenSlices)) { + spacing[2] = spacingBetweenSlices; } + image.setSpacing(spacing); if (imageOrientationPatient) { const zDir = vec3.create() as Vector3; diff --git a/tests/specs/cine-pixel-spacing.e2e.ts b/tests/specs/cine-pixel-spacing.e2e.ts new file mode 100644 index 000000000..3563bd81e --- /dev/null +++ b/tests/specs/cine-pixel-spacing.e2e.ts @@ -0,0 +1,57 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; + +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { buildSyntheticCineDicom, newUid } from './syntheticDicom'; +import { waitForFirstCachedImageSpacing } from './imageCacheUtils'; +import { writeManifestToFile } from './utils'; + +const ROW_SPACING = 1.8; +const COLUMN_SPACING = 0.45; + +function writeCineDicom() { + const fileName = `cine-pixel-spacing-${Date.now()}.dcm`; + const filePath = path.join(TEMP_DIR, fileName); + + fs.writeFileSync( + filePath, + buildSyntheticCineDicom({ + studyUid: newUid(), + seriesUid: newUid(), + sopUid: newUid(), + numberOfFrames: 4, + rows: 6, + cols: 7, + pixelSpacing: [ROW_SPACING, COLUMN_SPACING], + }) + ); + + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }); + + return fileName; +} + +describe('Cine DICOM PixelSpacing', () => { + it('falls back to PixelSpacing when ultrasound regions are unusable', async () => { + const fileName = writeCineDicom(); + const manifestName = `cine-pixel-spacing-${Date.now()}.json`; + await writeManifestToFile( + { + resources: [{ url: `/tmp/${fileName}`, name: fileName }], + }, + manifestName + ); + + await volViewPage.open(`?urls=[tmp/${manifestName}]`); + + expect(await waitForFirstCachedImageSpacing()).toEqual([ + COLUMN_SPACING, + ROW_SPACING, + 1, + ]); + }); +}); diff --git a/tests/specs/dicom-spacing.e2e.ts b/tests/specs/dicom-spacing.e2e.ts new file mode 100644 index 000000000..1473beb43 --- /dev/null +++ b/tests/specs/dicom-spacing.e2e.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; + +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { buildSyntheticDicom, newUid } from './syntheticDicom'; +import { waitForFirstCachedImageSpacing } from './imageCacheUtils'; +import { writeManifestToFile } from './utils'; + +const ROW_SPACING = 2.5; +const COLUMN_SPACING = 0.75; +const SPACING_BETWEEN_SLICES = 7.25; + +function writeSingleSliceDicom() { + const fileName = `single-slice-spacing-${Date.now()}.dcm`; + const filePath = path.join(TEMP_DIR, fileName); + const studyUid = newUid(); + const seriesUid = newUid(); + + fs.writeFileSync( + filePath, + buildSyntheticDicom({ + studyUid, + seriesUid, + sopUid: newUid(), + instanceNumber: 1, + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + imagePositionPatient: [0, 0, 0], + rows: 5, + cols: 6, + pixelSpacing: [ROW_SPACING, COLUMN_SPACING], + spacingBetweenSlices: SPACING_BETWEEN_SLICES, + sliceThickness: 19, + }) + ); + + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }); + + return fileName; +} + +describe('DICOM image spacing', () => { + it('matches ITK spacing for single-slice images with SpacingBetweenSlices', async () => { + const fileName = writeSingleSliceDicom(); + const manifestName = `single-slice-spacing-${Date.now()}.json`; + await writeManifestToFile( + { + resources: [{ url: `/tmp/${fileName}`, name: fileName }], + }, + manifestName + ); + + await volViewPage.open(`?urls=[tmp/${manifestName}]`); + + expect(await waitForFirstCachedImageSpacing()).toEqual([ + COLUMN_SPACING, + ROW_SPACING, + SPACING_BETWEEN_SLICES, + ]); + }); +}); diff --git a/tests/specs/imageCacheUtils.ts b/tests/specs/imageCacheUtils.ts new file mode 100644 index 000000000..e006ee030 --- /dev/null +++ b/tests/specs/imageCacheUtils.ts @@ -0,0 +1,35 @@ +export async function getFirstCachedImageSpacing() { + return browser.execute(() => { + const app = (document.querySelector('#app') as any)?.__vue_app__; + const pinia = + app?.config?.globalProperties?.$pinia ?? + (() => { + const provides = app?._context?.provides; + if (!provides) return null; + return Reflect.ownKeys(provides) + .map((key) => provides[key as keyof typeof provides]) + .find((value: any) => value?._s instanceof Map); + })(); + + const imageCache = pinia?._s?.get('image-cache'); + const id = imageCache?.imageIds?.[0]; + const imageData = imageCache?.getVtkImageData(id); + if (!imageData) return null; + return Array.from(imageData.getSpacing()).map(Number); + }); +} + +export async function waitForFirstCachedImageSpacing() { + let spacing: number[] | null = null; + await browser.waitUntil( + async () => { + spacing = await getFirstCachedImageSpacing(); + return spacing?.length === 3 && spacing.every(Number.isFinite); + }, + { + timeout: 30_000, + timeoutMsg: 'Expected first cached image spacing to become available', + } + ); + return spacing!; +} diff --git a/tests/specs/syntheticDicom.ts b/tests/specs/syntheticDicom.ts index 8b2c4c410..70744bcee 100644 --- a/tests/specs/syntheticDicom.ts +++ b/tests/specs/syntheticDicom.ts @@ -5,6 +5,7 @@ // PixelSpacing, SliceThickness, image geometry, and zeroed PixelData. const SOP_CLASS_MR = '1.2.840.10008.5.1.4.1.1.4'; +const SOP_CLASS_ULTRASOUND_MULTIFRAME = '1.2.840.10008.5.1.4.1.1.3.1'; const TS_EXPLICIT_VR_LE = '1.2.840.10008.1.2.1'; const enc = new TextEncoder(); @@ -108,6 +109,7 @@ export type SyntheticSliceOptions = { rows?: number; cols?: number; pixelSpacing?: readonly [number, number]; + spacingBetweenSlices?: number; sliceThickness?: number; modality?: string; patientName?: string; @@ -127,6 +129,7 @@ export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { rows = 4, cols = 4, pixelSpacing = [1, 1] as const, + spacingBetweenSlices, sliceThickness = 1, modality = 'MR', patientName = 'TEST', @@ -146,6 +149,9 @@ export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { da(0x0010, 0x0030, '19700101'), cs(0x0010, 0x0040, 'O'), ds(0x0018, 0x0050, String(sliceThickness)), + ...(spacingBetweenSlices == null + ? [] + : [ds(0x0018, 0x0088, String(spacingBetweenSlices))]), ui(0x0020, 0x000d, studyUid), ui(0x0020, 0x000e, seriesUid), sh(0x0020, 0x0010, '1'), @@ -187,6 +193,82 @@ export function buildSyntheticDicom(opts: SyntheticSliceOptions): Uint8Array { return combine(new Uint8Array(128), enc.encode('DICM'), fileMeta, dataset); } +export type SyntheticCineOptions = { + studyUid: string; + seriesUid: string; + sopUid: string; + numberOfFrames?: number; + rows?: number; + cols?: number; + pixelSpacing?: readonly [number, number]; + patientName?: string; + patientId?: string; + seriesNumber?: number; + studyDate?: string; +}; + +export function buildSyntheticCineDicom( + opts: SyntheticCineOptions +): Uint8Array { + const { + studyUid, + seriesUid, + sopUid, + numberOfFrames = 3, + rows = 4, + cols = 5, + pixelSpacing = [1, 1] as const, + patientName = 'TEST', + patientId = 'TEST001', + seriesNumber = 1, + studyDate = '20260101', + } = opts; + + const pixelData = new Uint8Array(numberOfFrames * rows * cols); + for (let i = 0; i < pixelData.length; i++) pixelData[i] = i % 251; + + const dataset = combine( + ui(0x0008, 0x0016, SOP_CLASS_ULTRASOUND_MULTIFRAME), + ui(0x0008, 0x0018, sopUid), + da(0x0008, 0x0020, studyDate), + da(0x0008, 0x0021, studyDate), + cs(0x0008, 0x0060, 'US'), + pn(0x0010, 0x0010, patientName), + lo(0x0010, 0x0020, patientId), + da(0x0010, 0x0030, '19700101'), + cs(0x0010, 0x0040, 'O'), + ui(0x0020, 0x000d, studyUid), + ui(0x0020, 0x000e, seriesUid), + sh(0x0020, 0x0010, '1'), + is(0x0020, 0x0011, String(seriesNumber)), + lo(0x0008, 0x103e, 'Synthetic cine'), + us(0x0028, 0x0002, 1), + cs(0x0028, 0x0004, 'MONOCHROME2'), + is(0x0028, 0x0008, String(numberOfFrames)), + us(0x0028, 0x0010, rows), + us(0x0028, 0x0011, cols), + ds(0x0028, 0x0030, pixelSpacing.map((n) => n.toString()).join('\\')), + us(0x0028, 0x0100, 8), + us(0x0028, 0x0101, 8), + us(0x0028, 0x0102, 7), + us(0x0028, 0x0103, 0), + elemLong(0x7fe0, 0x0010, 'OB', pixelData) + ); + + const fileMetaBody = combine( + elemLong(0x0002, 0x0001, 'OB', new Uint8Array([0x00, 0x01])), + ui(0x0002, 0x0002, SOP_CLASS_ULTRASOUND_MULTIFRAME), + ui(0x0002, 0x0003, sopUid), + ui(0x0002, 0x0010, TS_EXPLICIT_VR_LE) + ); + const fileMeta = combine( + elemShort(0x0002, 0x0000, 'UL', writeLong(fileMetaBody.length)), + fileMetaBody + ); + + return combine(new Uint8Array(128), enc.encode('DICM'), fileMeta, dataset); +} + // Pseudo-UID unique per call within a test process. All-numeric so it // stays within DICOM UID character constraints. let counter = 0;