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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/core/cine/DicomCineImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}

Expand Down
18 changes: 18 additions & 0 deletions src/core/cine/__tests__/DicomCineImage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function cineHeader(overrides: Partial<CineHeader> = {}): CineHeader {
bitsAllocated: 8,
planarConfiguration: 0,
photometricInterpretation: 'MONOCHROME2',
pixelSpacing: null,
frameTimeMs: null,
patient: {
PatientID: 'patient-1',
Expand Down Expand Up @@ -136,6 +137,7 @@ describe('DicomCineImage spacing', () => {
const image = new DicomCineImage(
parseResult(
cineHeader({
pixelSpacing: [1.8, 0.45],
regions: [
{
physicalDeltaX: 10,
Expand All @@ -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();
});
});
25 changes: 25 additions & 0 deletions src/core/cine/__tests__/parseCineDicom.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const COMMON_TAGS = (
samplesPerPixel?: number;
photometricInterpretation?: string;
planarConfiguration?: number;
pixelSpacing?: readonly [number, number];
} = {}
) => {
const samplesPerPixel = options.samplesPerPixel ?? 1;
Expand Down Expand Up @@ -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)),
]);
Expand All @@ -144,6 +155,7 @@ function buildNativeDicom(opts: {
samplesPerPixel?: number;
photometricInterpretation?: string;
planarConfiguration?: number;
pixelSpacing?: readonly [number, number];
}): Uint8Array {
const {
numberOfFrames,
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions src/core/cine/parseCineDicom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -79,6 +80,7 @@ export type CineHeader = {
bitsAllocated: number;
planarConfiguration: number;
photometricInterpretation: string;
pixelSpacing: [number, number] | null;
frameTimeMs: number | null;
patient: CinePatientInfo;
study: CineStudyInfo;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/core/dicomTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
1 change: 1 addition & 0 deletions src/store/__tests__/datasets-dicom-cine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ function cineHeader(overrides: Partial<CineHeader> = {}): CineHeader {
bitsAllocated: 8,
planarConfiguration: 0,
photometricInterpretation: 'MONOCHROME1',
pixelSpacing: null,
frameTimeMs: null,
patient: {
PatientID: 'patient-1',
Expand Down
57 changes: 56 additions & 1 deletion src/utils/__tests__/allocateImageFromChunks.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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<string, string> = {}) {
return chunk({
[Tags.ImagePositionPatient]: `0\\0\\${z}`,
...overrides,
});
}

describe('getTypedArrayForDataRange', () => {
it('should handle edge cases', () => {
expect(getTypedArrayForDataRange(-(2 ** 7), 2 ** 7 - 1)).toBe(Int8Array);
Expand All @@ -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]);
});
});
30 changes: 23 additions & 7 deletions src/utils/allocateImageFromChunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')!;
Expand All @@ -23,6 +24,10 @@ function toVec(s: Maybe<string>): 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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
57 changes: 57 additions & 0 deletions tests/specs/cine-pixel-spacing.e2e.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
});
});
Loading
Loading