Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
13 changes: 10 additions & 3 deletions packages/core/src/cache/classes/ImageVolume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export class ImageVolume {
hasPixelSpacing: boolean;
/** Property to store additional information */
additionalDetails?: Record<string, unknown>;
/**
* Property to store the number of dimension groups.
* @deprecated
*/
numDimensionGroups: number;

/**
* The new volume model which solely relies on the separate image data
Expand All @@ -83,11 +88,13 @@ export class ImageVolume {
voxelManager?: IVoxelManager<number> | IVoxelManager<RGB>;
dataType?: PixelDataTypedArrayString;

// @deprecated
/**
* Calculates the number of time points to be the number of dimension groups
* as a fallback for existing handling.
* @deprecated
*/
get numTimePoints(): number {
// @ts-expect-error
return typeof this.numDimensionGroups === 'number'
// @ts-expect-error
? this.numDimensionGroups
: 1;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
* In-Plane Decimation Modifier
*
* A volume modifier that reduces the resolution of a volume in the in-plane dimensions
* (i and j axes).
* (i and j axes).
*
* The modifier:
* - Applies decimation factors from `context.options.ijkDecimation` for the i and j dimensions
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ import cache from '../cache/cache';
import getDynamicVolumeInfo from './getDynamicVolumeInfo';
import autoLoad from './autoLoad';
import scaleArray from './scaleArray';
import splitImageIdsBy4DTags from './splitImageIdsBy4DTags';
import splitImageIdsBy4DTags, {
handleMultiframe4D,
generateFrameImageId,
} from './splitImageIdsBy4DTags';
import { deepClone } from './deepClone';
import { jumpToSlice } from './jumpToSlice';
import scroll from './scroll';
Expand Down Expand Up @@ -192,6 +195,8 @@ export {
scaleArray,
deepClone,
splitImageIdsBy4DTags,
handleMultiframe4D,
generateFrameImageId,
pointInShapeCallback,
deepEqual,
jumpToSlice,
Expand Down
167 changes: 166 additions & 1 deletion packages/core/src/utilities/splitImageIdsBy4DTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,166 @@ import * as metaData from '../metaData';
// (2001,1003) Philips Diffusion B-factor [OK]
// (0019,100c) Siemens Diffusion B Value [Implemented, not tested]
// (0043,1039) GE Diffusion B Value [OK]
//
// Multiframe 4D Support (NM Multi-frame Module):
// (0054,0070) TimeSlotVector [OK]
// (0054,0080) SliceVector [Used for ordering within time slots]

interface MappedIPP {
imageId: string;
imagePositionPatient;
}

interface MultiframeSplitResult {
imageIdGroups: string[][];
splittingTag: string;
}

/**
* Generates frame-specific imageIds for a multiframe image.
* Replaces the frame number in the imageId with the specified frame number (1-based).
*
* @param baseImageId - The base imageId that must contain a "/frames/" pattern followed by a digit.
* Expected format: e.g., "wadouri:http://example.com/image/frames/1" or "wadors:/path/to/image.dcm/frames/1".
* The pattern "/frames/\d+" will be replaced with "/frames/{frameNumber}".
* @param frameNumber - The frame number to use (1-based)
* @returns The imageId with the frame number replaced
* @throws {Error} If baseImageId does not contain the required "/frames/" pattern, throws an error
* with a clear message indicating the expected format.
*/
function generateFrameImageId(
baseImageId: string,
frameNumber: number
): string {
const framePattern = /\/frames\/\d+/;

if (!framePattern.test(baseImageId)) {
throw new Error(
`generateFrameImageId: baseImageId must contain a "/frames/" pattern followed by a digit. ` +
`Expected format: e.g., "wadouri:http://example.com/image/frames/1" or "wadors:/path/to/image.dcm/frames/1". ` +
`Received: ${baseImageId}`
);
}

return baseImageId.replace(framePattern, `/frames/${frameNumber}`);
}

/**
* Handles multiframe 4D splitting using TimeSlotVector (0054,0070).
* For NM Multi-frame images where frames are indexed by time slot and slice.
*
* @param imageIds - Array containing the base imageId (typically just one for multiframe).
* The base imageId must contain a "/frames/" pattern (e.g., "wadouri:http://example.com/image/frames/1").
* See generateFrameImageId for format requirements.
* @returns Split result if multiframe 4D is detected, null otherwise
*/
function handleMultiframe4D(imageIds: string[]): MultiframeSplitResult | null {
if (!imageIds || imageIds.length === 0) {
return null;
}

const baseImageId = imageIds[0];
const instance = metaData.get('instance', baseImageId);

if (!instance) {
return null;
}

const numberOfFrames = instance.NumberOfFrames;
if (!numberOfFrames || numberOfFrames <= 1) {
return null;
}

const timeSlotVector = instance.TimeSlotVector;
if (!timeSlotVector || !Array.isArray(timeSlotVector)) {
return null;
}

const sliceVector = instance.SliceVector;
const numberOfSlices = instance.NumberOfSlices;

if (timeSlotVector.length !== numberOfFrames) {
console.warn(
'TimeSlotVector length does not match NumberOfFrames:',
timeSlotVector.length,
'vs',
numberOfFrames
);
return null;
}

if (sliceVector) {
if (!Array.isArray(sliceVector)) {
console.warn(
'SliceVector exists but is not an array. Expected length:',
numberOfFrames
);
return null;
}

if (
sliceVector.length !== numberOfFrames ||
sliceVector.some((val) => val === undefined)
) {
console.warn(
'SliceVector exists but has invalid length or undefined entries. Expected length:',
numberOfFrames,
'Actual length:',
sliceVector.length
);
return null;
}
}

const timeSlotGroups: Map<
number,
Array<{ frameIndex: number; sliceIndex: number }>
> = new Map();

for (let frameIndex = 0; frameIndex < numberOfFrames; frameIndex++) {
const timeSlot = timeSlotVector[frameIndex];
const sliceIndex = sliceVector?.[frameIndex] ?? frameIndex;

if (!timeSlotGroups.has(timeSlot)) {
timeSlotGroups.set(timeSlot, []);
}

timeSlotGroups.get(timeSlot).push({ frameIndex, sliceIndex });
}

const sortedTimeSlots = Array.from(timeSlotGroups.keys()).sort(
(a, b) => a - b
);

const imageIdGroups: string[][] = sortedTimeSlots.map((timeSlot) => {
const frames = timeSlotGroups.get(timeSlot);

frames.sort((a, b) => a.sliceIndex - b.sliceIndex);

return frames.map((frame) =>
generateFrameImageId(baseImageId, frame.frameIndex + 1)
);
});

const expectedSlicesPerTimeSlot = numberOfSlices || imageIdGroups[0]?.length;
const allGroupsHaveSameLength = imageIdGroups.every(
(group) => group.length === expectedSlicesPerTimeSlot
);

if (!allGroupsHaveSameLength) {
console.warn(
'Multiframe 4D split resulted in uneven time slot groups. Expected',
expectedSlicesPerTimeSlot,
'slices per time slot.'
);
}

return {
imageIdGroups,
splittingTag: 'TimeSlotVector',
};
}

const groupBy = (array, key) => {
return array.reduce((rv, x) => {
(rv[x[key]] = rv[x[key]] || []).push(x);
Expand Down Expand Up @@ -173,16 +327,26 @@ function getPetFrameReferenceTime(imageId) {
/**
* Split the imageIds array by 4D tags into groups. Each group must have the
* same number of imageIds or the same imageIds array passed in is returned.
*
* For multiframe images (NumberOfFrames > 1), this function checks for
* TimeSlotVector (0054,0070) which is common in NM (Nuclear Medicine) gated
* SPECT/PET images. The TimeSlotVector indicates which time slot each frame
* belongs to, and SliceVector (0054,0080) indicates the slice position.
*
* @param imageIds - array of imageIds
* @returns imageIds grouped by 4D tags
*/
function splitImageIdsBy4DTags(imageIds: string[]): {
imageIdGroups: string[][];
splittingTag: string | null;
} {
const multiframeResult = handleMultiframe4D(imageIds);
if (multiframeResult) {
return multiframeResult;
}

const positionGroups = getIPPGroups(imageIds);
if (!positionGroups) {
// When no position groups are found, return the original array wrapped and indicate no tag was used
return { imageIdGroups: [imageIds], splittingTag: null };
}

Expand Down Expand Up @@ -229,3 +393,4 @@ function splitImageIdsBy4DTags(imageIds: string[]): {
}

export default splitImageIdsBy4DTags;
export { handleMultiframe4D, generateFrameImageId };
Loading
Loading