diff --git a/packages/adapters/src/adapters/Cornerstone3D/Segmentation/labelmapImagesFromBuffer.ts b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/labelmapImagesFromBuffer.ts index 09025f2a89..22d44e6e7b 100644 --- a/packages/adapters/src/adapters/Cornerstone3D/Segmentation/labelmapImagesFromBuffer.ts +++ b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/labelmapImagesFromBuffer.ts @@ -48,6 +48,28 @@ function prepareSegMultiframeMetadata(multiframe: Record) { if (perFrame && !Array.isArray(perFrame)) { multiframe.PerFrameFunctionalGroupsSequence = [perFrame]; } + + // ImageOrientationPatient can arrive as DICOM DS strings (e.g. from DICOMweb + // JSON metadata). Downstream orientation checks compare it against numeric + // source cosines with a type-strict equality (utilities.isEqual), so an + // in-plane SEG would otherwise be misclassified as orthogonal/out-of-plane. + // Normalize it to numbers on the shared and per-frame functional groups. + type FunctionalGroup = { + PlaneOrientationSequence?: { ImageOrientationPatient?: unknown }; + }; + const coerceIop = (group?: FunctionalGroup) => { + const seq = group?.PlaneOrientationSequence; + if (seq && Array.isArray(seq.ImageOrientationPatient)) { + seq.ImageOrientationPatient = seq.ImageOrientationPatient.map(Number); + } + }; + coerceIop(multiframe.SharedFunctionalGroupsSequence as FunctionalGroup); + const perFrameGroups = multiframe.PerFrameFunctionalGroupsSequence as + | FunctionalGroup[] + | undefined; + if (Array.isArray(perFrameGroups)) { + perFrameGroups.forEach(coerceIop); + } } function getFrameNumberFromImageId(imageId: string): number | undefined { diff --git a/packages/adapters/src/adapters/helpers/checkOrientation.ts b/packages/adapters/src/adapters/helpers/checkOrientation.ts index d95fe9cde2..a2d588c0fc 100644 --- a/packages/adapters/src/adapters/helpers/checkOrientation.ts +++ b/packages/adapters/src/adapters/helpers/checkOrientation.ts @@ -19,10 +19,16 @@ export default function checkOrientation( // Check if in plane. const PerFrameFunctionalGroups = PerFrameFunctionalGroupsSequence[0]; - const iop = + const iopRaw = sharedImageOrientationPatient || PerFrameFunctionalGroups.PlaneOrientationSequence.ImageOrientationPatient; + // ImageOrientationPatient can arrive as DICOM DS strings (e.g. from DICOMweb + // JSON metadata) while validOrientations are numeric source cosines. isEqual + // is type-strict, so a string-vs-number mismatch would make an in-plane SEG + // look perpendicular. Coerce to numbers before comparing. + const iop = Array.isArray(iopRaw) ? iopRaw.map(Number) : iopRaw; + const inPlane = validOrientations.some((operation) => utilities.isEqual(iop, operation, tolerance) ); diff --git a/packages/adapters/test/checkOrientation.jest.js b/packages/adapters/test/checkOrientation.jest.js new file mode 100644 index 0000000000..19753bd5e8 --- /dev/null +++ b/packages/adapters/test/checkOrientation.jest.js @@ -0,0 +1,86 @@ +import { describe, it, expect } from '@jest/globals'; +import checkOrientation from '../src/adapters/helpers/checkOrientation'; + +// The source data is a standard axial acquisition (row cosines [1,0,0], +// column cosines [0,1,0]). validOrientations holds numeric cosines because they +// are derived from the source image's parsed imagePlaneModule. +const validOrientations = [[1, 0, 0, 0, 1, 0]]; +const sourceDataDimensions = [512, 512, 1]; +const tolerance = 1e-3; + +const multiframeWithSharedIop = (iop) => ({ + Rows: 512, + Columns: 512, + SharedFunctionalGroupsSequence: { + PlaneOrientationSequence: { ImageOrientationPatient: iop }, + }, + PerFrameFunctionalGroupsSequence: [{}], +}); + +describe('checkOrientation', () => { + it('classifies a co-planar SEG as Planar when ImageOrientationPatient is numeric', () => { + const multiframe = multiframeWithSharedIop([1, 0, 0, 0, 1, 0]); + expect( + checkOrientation( + multiframe, + validOrientations, + sourceDataDimensions, + tolerance + ) + ).toBe('Planar'); + }); + + it('classifies a co-planar SEG as Planar when ImageOrientationPatient is DICOM DS strings', () => { + // Regression: DICOMweb JSON metadata delivers DS values as strings. The + // orientation check compares them against numeric source cosines with a + // type-strict equality, which previously misclassified this perfectly + // in-plane SEG as orthogonal ("Segmentations orthogonal to the acquisition + // plane of the source data are not yet supported."). + const multiframe = multiframeWithSharedIop(['1', '0', '0', '0', '1', '0']); + expect( + checkOrientation( + multiframe, + validOrientations, + sourceDataDimensions, + tolerance + ) + ).toBe('Planar'); + }); + + it('still classifies a genuinely perpendicular SEG as Perpendicular (string IOP)', () => { + // Row cosines [1,0,0], column cosines [0,0,-1]: perpendicular to the axial + // source. Coercion must not mask real perpendicular segmentations. + const multiframe = multiframeWithSharedIop(['1', '0', '0', '0', '0', '-1']); + expect( + checkOrientation( + multiframe, + validOrientations, + sourceDataDimensions, + tolerance + ) + ).toBe('Perpendicular'); + }); + + it('falls back to the per-frame ImageOrientationPatient when no shared group is present', () => { + const multiframe = { + Rows: 512, + Columns: 512, + SharedFunctionalGroupsSequence: {}, + PerFrameFunctionalGroupsSequence: [ + { + PlaneOrientationSequence: { + ImageOrientationPatient: ['1', '0', '0', '0', '1', '0'], + }, + }, + ], + }; + expect( + checkOrientation( + multiframe, + validOrientations, + sourceDataDimensions, + tolerance + ) + ).toBe('Planar'); + }); +});