Skip to content

Commit 057707e

Browse files
authored
fix(adapters): load co-planar SEGs whose ImageOrientationPatient is DICOM strings (#2816)
Coerce ImageOrientationPatient to numbers in checkOrientation and prepareSegMultiframeMetadata so a co-planar DICOMweb SEG (DS strings) is not misclassified as orthogonal. Adds a checkOrientation unit test.
1 parent 85ce867 commit 057707e

3 files changed

Lines changed: 115 additions & 1 deletion

File tree

‎packages/adapters/src/adapters/Cornerstone3D/Segmentation/labelmapImagesFromBuffer.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,28 @@ function prepareSegMultiframeMetadata(multiframe: Record<string, unknown>) {
4848
if (perFrame && !Array.isArray(perFrame)) {
4949
multiframe.PerFrameFunctionalGroupsSequence = [perFrame];
5050
}
51+
52+
// ImageOrientationPatient can arrive as DICOM DS strings (e.g. from DICOMweb
53+
// JSON metadata). Downstream orientation checks compare it against numeric
54+
// source cosines with a type-strict equality (utilities.isEqual), so an
55+
// in-plane SEG would otherwise be misclassified as orthogonal/out-of-plane.
56+
// Normalize it to numbers on the shared and per-frame functional groups.
57+
type FunctionalGroup = {
58+
PlaneOrientationSequence?: { ImageOrientationPatient?: unknown };
59+
};
60+
const coerceIop = (group?: FunctionalGroup) => {
61+
const seq = group?.PlaneOrientationSequence;
62+
if (seq && Array.isArray(seq.ImageOrientationPatient)) {
63+
seq.ImageOrientationPatient = seq.ImageOrientationPatient.map(Number);
64+
}
65+
};
66+
coerceIop(multiframe.SharedFunctionalGroupsSequence as FunctionalGroup);
67+
const perFrameGroups = multiframe.PerFrameFunctionalGroupsSequence as
68+
| FunctionalGroup[]
69+
| undefined;
70+
if (Array.isArray(perFrameGroups)) {
71+
perFrameGroups.forEach(coerceIop);
72+
}
5173
}
5274

5375
function getFrameNumberFromImageId(imageId: string): number | undefined {

‎packages/adapters/src/adapters/helpers/checkOrientation.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,16 @@ export default function checkOrientation(
1919
// Check if in plane.
2020
const PerFrameFunctionalGroups = PerFrameFunctionalGroupsSequence[0];
2121

22-
const iop =
22+
const iopRaw =
2323
sharedImageOrientationPatient ||
2424
PerFrameFunctionalGroups.PlaneOrientationSequence.ImageOrientationPatient;
2525

26+
// ImageOrientationPatient can arrive as DICOM DS strings (e.g. from DICOMweb
27+
// JSON metadata) while validOrientations are numeric source cosines. isEqual
28+
// is type-strict, so a string-vs-number mismatch would make an in-plane SEG
29+
// look perpendicular. Coerce to numbers before comparing.
30+
const iop = Array.isArray(iopRaw) ? iopRaw.map(Number) : iopRaw;
31+
2632
const inPlane = validOrientations.some((operation) =>
2733
utilities.isEqual(iop, operation, tolerance)
2834
);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import checkOrientation from '../src/adapters/helpers/checkOrientation';
3+
4+
// The source data is a standard axial acquisition (row cosines [1,0,0],
5+
// column cosines [0,1,0]). validOrientations holds numeric cosines because they
6+
// are derived from the source image's parsed imagePlaneModule.
7+
const validOrientations = [[1, 0, 0, 0, 1, 0]];
8+
const sourceDataDimensions = [512, 512, 1];
9+
const tolerance = 1e-3;
10+
11+
const multiframeWithSharedIop = (iop) => ({
12+
Rows: 512,
13+
Columns: 512,
14+
SharedFunctionalGroupsSequence: {
15+
PlaneOrientationSequence: { ImageOrientationPatient: iop },
16+
},
17+
PerFrameFunctionalGroupsSequence: [{}],
18+
});
19+
20+
describe('checkOrientation', () => {
21+
it('classifies a co-planar SEG as Planar when ImageOrientationPatient is numeric', () => {
22+
const multiframe = multiframeWithSharedIop([1, 0, 0, 0, 1, 0]);
23+
expect(
24+
checkOrientation(
25+
multiframe,
26+
validOrientations,
27+
sourceDataDimensions,
28+
tolerance
29+
)
30+
).toBe('Planar');
31+
});
32+
33+
it('classifies a co-planar SEG as Planar when ImageOrientationPatient is DICOM DS strings', () => {
34+
// Regression: DICOMweb JSON metadata delivers DS values as strings. The
35+
// orientation check compares them against numeric source cosines with a
36+
// type-strict equality, which previously misclassified this perfectly
37+
// in-plane SEG as orthogonal ("Segmentations orthogonal to the acquisition
38+
// plane of the source data are not yet supported.").
39+
const multiframe = multiframeWithSharedIop(['1', '0', '0', '0', '1', '0']);
40+
expect(
41+
checkOrientation(
42+
multiframe,
43+
validOrientations,
44+
sourceDataDimensions,
45+
tolerance
46+
)
47+
).toBe('Planar');
48+
});
49+
50+
it('still classifies a genuinely perpendicular SEG as Perpendicular (string IOP)', () => {
51+
// Row cosines [1,0,0], column cosines [0,0,-1]: perpendicular to the axial
52+
// source. Coercion must not mask real perpendicular segmentations.
53+
const multiframe = multiframeWithSharedIop(['1', '0', '0', '0', '0', '-1']);
54+
expect(
55+
checkOrientation(
56+
multiframe,
57+
validOrientations,
58+
sourceDataDimensions,
59+
tolerance
60+
)
61+
).toBe('Perpendicular');
62+
});
63+
64+
it('falls back to the per-frame ImageOrientationPatient when no shared group is present', () => {
65+
const multiframe = {
66+
Rows: 512,
67+
Columns: 512,
68+
SharedFunctionalGroupsSequence: {},
69+
PerFrameFunctionalGroupsSequence: [
70+
{
71+
PlaneOrientationSequence: {
72+
ImageOrientationPatient: ['1', '0', '0', '0', '1', '0'],
73+
},
74+
},
75+
],
76+
};
77+
expect(
78+
checkOrientation(
79+
multiframe,
80+
validOrientations,
81+
sourceDataDimensions,
82+
tolerance
83+
)
84+
).toBe('Planar');
85+
});
86+
});

0 commit comments

Comments
 (0)