fix: image slices are displayed in reverse order, if we switch from common layout to 2D MPR (#5022)

This commit is contained in:
arul-trenser 2025-07-17 17:41:26 +05:30 committed by GitHub
parent 8dda7d5f81
commit 6a4ca70836
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 202 additions and 102 deletions

View File

@ -0,0 +1,6 @@
export default {
instanceSortingCriteria: {
sortFunctions: {},
defaultSortFunctionName: '',
},
};

View File

@ -22,6 +22,7 @@ import userPreferencesCustomization from './customizations/userPreferencesCustom
import reportDialogCustomization from './customizations/reportDialogCustomization'; import reportDialogCustomization from './customizations/reportDialogCustomization';
import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomization'; import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomization';
import onboardingCustomization from './customizations/onboardingCustomization'; import onboardingCustomization from './customizations/onboardingCustomization';
import instanceSortingCriteriaCustomization from './customizations/instanceSortingCriteriaCustomization';
/** /**
* *
* Note: this is an example of how the customization module can be used * Note: this is an example of how the customization module can be used
@ -69,6 +70,7 @@ export default function getCustomizationModule({ servicesManager, extensionManag
...reportDialogCustomization, ...reportDialogCustomization,
...hotkeyBindingsCustomization, ...hotkeyBindingsCustomization,
...onboardingCustomization, ...onboardingCustomization,
...instanceSortingCriteriaCustomization,
}, },
}, },
]; ];

View File

@ -127,7 +127,10 @@ const makeDisplaySet = instances => {
FrameOfReferenceUID: instance.FrameOfReferenceUID, FrameOfReferenceUID: instance.FrameOfReferenceUID,
}); });
imageSet.sortBy(instancesSortCriteria.default); const { servicesManager } = appContext;
const { customizationService } = servicesManager.services;
imageSet.sort(customizationService);
// Include the first image instance number (after sorted) // Include the first image instance number (after sorted)
/*imageSet.setAttribute( /*imageSet.setAttribute(

View File

@ -1,7 +1,9 @@
import { vec3 } from 'gl-matrix'; import { vec3 } from 'gl-matrix';
import toNumber from '@ohif/core/src/utils/toNumber'; import toNumber from '@ohif/core/src/utils/toNumber';
import { _getPerpendicularDistance } from '@ohif/core/src/utils/isDisplaySetReconstructable'; import { _getPerpendicularDistance } from '@ohif/core/src/utils/isDisplaySetReconstructable';
import calculateScanAxisNormal from '../calculateScanAxisNormal'; import { utils } from '@ohif/core';
const { calculateScanAxisNormal } = utils;
/** /**
* Checks if there is a position shift between consecutive frames * Checks if there is a position shift between consecutive frames

View File

@ -18,32 +18,32 @@ describe('OHIF Cornerstone Hotkeys', () => {
it('checks if hotkeys "R" and "L" can rotate the image', () => { it('checks if hotkeys "R" and "L" can rotate the image', () => {
cy.get('body').type('R'); cy.get('body').type('R');
cy.get('@viewportInfoMidLeft').should('contains.text', 'P'); cy.get('@viewportInfoMidLeft').should('contains.text', 'F');
cy.get('@viewportInfoMidTop').should('contains.text', 'R'); cy.get('@viewportInfoMidTop').should('contains.text', 'A');
// Hotkey L // Hotkey L
cy.get('body').type('L'); cy.get('body').type('L');
cy.get('@viewportInfoMidLeft').should('contains.text', 'R'); cy.get('@viewportInfoMidLeft').should('contains.text', 'A');
cy.get('@viewportInfoMidTop').should('contains.text', 'A'); cy.get('@viewportInfoMidTop').should('contains.text', 'H');
}); });
it('checks if hotkeys "ArrowUp" and "ArrowDown" can navigate in the stack', () => { it('checks if hotkeys "ArrowUp" and "ArrowDown" can navigate in the stack', () => {
// Hotkey ArrowDown // Hotkey ArrowDown
cy.get('body').type('{downarrow}'); cy.get('body').type('{downarrow}');
cy.get('@viewportInfoBottomRight').should('contains.text', 'I:2 (2/26)'); cy.get('@viewportInfoBottomRight').should('contains.text', 'I:11 (2/26)');
// Hotkey ArrowUp // Hotkey ArrowUp
cy.get('body').type('{uparrow}'); cy.get('body').type('{uparrow}');
cy.get('@viewportInfoBottomRight').should('contains.text', 'I:1 (1/26)'); cy.get('@viewportInfoBottomRight').should('contains.text', 'I:10 (1/26)');
}); });
it('checks if hotkeys "V" and "H" can flip the image', () => { it('checks if hotkeys "V" and "H" can flip the image', () => {
// Hotkey H // Hotkey H
cy.get('body').type('h'); cy.get('body').type('h');
cy.get('@viewportInfoMidLeft').should('contains.text', 'L'); cy.get('@viewportInfoMidLeft').should('contains.text', 'P');
cy.get('@viewportInfoMidTop').should('contains.text', 'A'); cy.get('@viewportInfoMidTop').should('contains.text', 'H');
// Hotkey V // Hotkey V
cy.get('body').type('v'); cy.get('body').type('v');
cy.get('@viewportInfoMidLeft').should('contains.text', 'L'); cy.get('@viewportInfoMidLeft').should('contains.text', 'P');
cy.get('@viewportInfoMidTop').should('contains.text', 'P'); cy.get('@viewportInfoMidTop').should('contains.text', 'F');
}); });
// it('checks if hotkeys "+", "-" and "=" can zoom in, out and fit to viewport', () => { // it('checks if hotkeys "+", "-" and "=" can zoom in, out and fit to viewport', () => {
@ -68,13 +68,13 @@ describe('OHIF Cornerstone Hotkeys', () => {
it('checks if hotkey "SPACEBAR" can reset the image', () => { it('checks if hotkey "SPACEBAR" can reset the image', () => {
// Press multiples hotkeys // Press multiples hotkeys
cy.get('body').type('v+++i'); cy.get('body').type('v+++i');
cy.get('@viewportInfoMidLeft').should('contains.text', 'R'); cy.get('@viewportInfoMidLeft').should('contains.text', 'A');
cy.get('@viewportInfoMidTop').should('contains.text', 'P'); cy.get('@viewportInfoMidTop').should('contains.text', 'F');
// Hotkey SPACEBAR // Hotkey SPACEBAR
cy.get('body').type(' '); cy.get('body').type(' ');
cy.get('@viewportInfoMidLeft').should('contains.text', 'R'); cy.get('@viewportInfoMidLeft').should('contains.text', 'A');
cy.get('@viewportInfoMidTop').should('contains.text', 'A'); cy.get('@viewportInfoMidTop').should('contains.text', 'H');
}); });
/* /*

View File

@ -85,7 +85,7 @@ describe('OHIF Cornerstone Toolbar', () => {
// The exact text is slightly dependent on the viewport resolution, so leave a range // The exact text is slightly dependent on the viewport resolution, so leave a range
cy.get('@viewportInfoBottomLeft').should($txt => { cy.get('@viewportInfoBottomLeft').should($txt => {
const text = $txt.text(); const text = $txt.text();
expect(text).to.include('L:479'); expect(text).to.include('L:288');
}); });
}); });
@ -143,7 +143,7 @@ describe('OHIF Cornerstone Toolbar', () => {
//Click on reset button //Click on reset button
cy.resetViewport(); cy.resetViewport();
const expectedText = 'W:958L:479'; const expectedText = 'W:576L:288';
cy.get('@viewportInfoBottomLeft').should('have.text', expectedText); cy.get('@viewportInfoBottomLeft').should('have.text', expectedText);
}); });
@ -412,8 +412,8 @@ describe('OHIF Cornerstone Toolbar', () => {
}); });
*/ */
it('check if Flip tool will flip the image in the viewport', () => { it('check if Flip tool will flip the image in the viewport', () => {
cy.get('@viewportInfoMidLeft').should('contains.text', 'R'); cy.get('@viewportInfoMidLeft').should('contains.text', 'A');
cy.get('@viewportInfoMidTop').should('contains.text', 'A'); cy.get('@viewportInfoMidTop').should('contains.text', 'H');
//Click on More button //Click on More button
cy.get('@moreBtnSecondary').click(); cy.get('@moreBtnSecondary').click();
@ -421,8 +421,8 @@ describe('OHIF Cornerstone Toolbar', () => {
//Click on Flip button //Click on Flip button
cy.get('[data-cy="flipHorizontal"]').click(); cy.get('[data-cy="flipHorizontal"]').click();
cy.waitDicomImage(); cy.waitDicomImage();
cy.get('@viewportInfoMidLeft').should('contains.text', 'L'); cy.get('@viewportInfoMidLeft').should('contains.text', 'P');
cy.get('@viewportInfoMidTop').should('contains.text', 'A'); cy.get('@viewportInfoMidTop').should('contains.text', 'H');
}); });
// it('checks if stack sync is preserved on new display set and uses FOR', () => { // it('checks if stack sync is preserved on new display set and uses FOR', () => {

View File

@ -1,15 +1,16 @@
import { Types as csTypes } from '@cornerstonejs/core';
import guid from '../utils/guid.js'; import guid from '../utils/guid.js';
import { Vector3 } from 'cornerstone-math'; import {
instancesSortCriteria,
isValidForPositionSort,
sortImagesByPatientPosition,
} from '../utils/sortStudy';
type Attributes = Record<string, unknown>; type Attributes = Record<string, unknown>;
type Image = { export type Image = {
StudyInstanceUID?: string; StudyInstanceUID?: string;
getData(): { ImagePositionPatient?: csTypes.Point3;
metadata: { ImageOrientationPatient?: csTypes.Point3;
ImagePositionPatient: number[];
ImageOrientationPatient: number[];
};
};
}; };
/** /**
@ -79,63 +80,46 @@ class ImageSet {
return this.images[index]; return this.images[index];
} }
/**
* Default image sorting. Sorts by the following (in order of priority)
* 1. Image position (if ImagePositionPatient and ImageOrientationPatient are defined)
* 2. Sort by a provided sortingCallback Criteria
* Note: Images are sorted in-place and a reference to the sorted image array is returned.
*
* @returns images - reference to images after sorting
*/
sort(customizationService): Image[] {
// Check instanceSort customization
const customizedSortingCriteria =
customizationService.getCustomization('instanceSortingCriteria');
const combinedSortFunctions = Object.assign(
{},
instancesSortCriteria,
customizedSortingCriteria.sortFunctions
);
const userSpecifiedCriteria = customizedSortingCriteria.defaultSortFunctionName;
// Prefer customized sort function when available
if (typeof combinedSortFunctions[userSpecifiedCriteria] === 'function') {
return this.images.sort(combinedSortFunctions[userSpecifiedCriteria]);
}
// If image position patient is not available, sort by InstanceNumber
if (!isValidForPositionSort(this.images)) {
return this.images.sort(instancesSortCriteria.sortByInstanceNumber);
}
// Do image position patient sorting as default sort
return sortImagesByPatientPosition(this.images);
}
/**
* Sort using the provided callback function.
* Note: Images are sorted in-place and a reference to the sorted image array is returned.
*
* @param sortingCallback - sorting function
* @returns images - reference to images after sorting
*/
sortBy(sortingCallback: (a: Image, b: Image) => number): Image[] { sortBy(sortingCallback: (a: Image, b: Image) => number): Image[] {
return this.images.sort(sortingCallback); return this.images.sort(sortingCallback);
} }
sortByImagePositionPatient(): void {
const images = this.images;
const referenceImagePositionPatient = _getImagePositionPatient(images[0]);
const refIppVec = new Vector3(
referenceImagePositionPatient[0],
referenceImagePositionPatient[1],
referenceImagePositionPatient[2]
);
const ImageOrientationPatient = _getImageOrientationPatient(images[0]);
const scanAxisNormal = new Vector3(
ImageOrientationPatient[0],
ImageOrientationPatient[1],
ImageOrientationPatient[2]
).cross(
new Vector3(
ImageOrientationPatient[3],
ImageOrientationPatient[4],
ImageOrientationPatient[5]
)
);
const distanceImagePairs = images.map(function (image: Image) {
const ippVec = new Vector3(..._getImagePositionPatient(image));
const positionVector = refIppVec.clone().sub(ippVec);
const distance = positionVector.dot(scanAxisNormal);
return {
distance,
image,
};
});
distanceImagePairs.sort(function (a, b) {
return b.distance - a.distance;
});
const sortedImages = distanceImagePairs.map(a => a.image);
images.sort(function (a, b) {
return sortedImages.indexOf(a) - sortedImages.indexOf(b);
});
}
}
function _getImagePositionPatient(image) {
return image.getData().metadata.ImagePositionPatient;
}
function _getImageOrientationPatient(image) {
return image.getData().metadata.ImageOrientationPatient;
} }
export default ImageSet; export default ImageSet;

View File

@ -43,6 +43,7 @@ import { createStudyBrowserTabs } from './createStudyBrowserTabs';
import { sopClassDictionary } from './sopClassDictionary'; import { sopClassDictionary } from './sopClassDictionary';
import * as MeasurementFilters from './measurementFilters'; import * as MeasurementFilters from './measurementFilters';
import getClosestOrientationFromIOP from './getClosestOrientationFromIOP'; import getClosestOrientationFromIOP from './getClosestOrientationFromIOP';
import calculateScanAxisNormal from './calculateScanAxisNormal';
// Commented out unused functionality. // Commented out unused functionality.
// Need to implement new mechanism for derived displaySets using the displaySetManager. // Need to implement new mechanism for derived displaySets using the displaySetManager.
@ -91,6 +92,7 @@ const utils = {
createStudyBrowserTabs, createStudyBrowserTabs,
MeasurementFilters, MeasurementFilters,
getClosestOrientationFromIOP, getClosestOrientationFromIOP,
calculateScanAxisNormal,
}; };
export { export {

View File

@ -1,4 +1,6 @@
import { vec3 } from 'gl-matrix';
import isLowPriorityModality from './isLowPriorityModality'; import isLowPriorityModality from './isLowPriorityModality';
import calculateScanAxisNormal from './calculateScanAxisNormal';
const compareSeriesDateTime = (a, b) => { const compareSeriesDateTime = (a, b) => {
const seriesDateA = Date.parse(`${a.seriesDate ?? a.SeriesDate} ${a.seriesTime ?? a.SeriesTime}`); const seriesDateA = Date.parse(`${a.seriesDate ?? a.SeriesDate} ${a.seriesTime ?? a.SeriesTime}`);
@ -41,20 +43,23 @@ const seriesSortCriteria = {
seriesInfoSortingCriteria, seriesInfoSortingCriteria,
}; };
const sortByInstanceNumber = (a, b) => {
// Sort by InstanceNumber (0020,0013)
const aInstance = parseInt(a.InstanceNumber) || 0;
const bInstance = parseInt(b.InstanceNumber) || 0;
if (aInstance !== bInstance) {
return (parseInt(a.InstanceNumber) || 0) - (parseInt(b.InstanceNumber) || 0);
}
// Fallback rule to enable consistent sorting
if (a.SOPInstanceUID === b.SOPInstanceUID) {
return 0;
}
return a.SOPInstanceUID < b.SOPInstanceUID ? -1 : 1;
};
const instancesSortCriteria = { const instancesSortCriteria = {
default: (a, b) => { default: sortByInstanceNumber,
// Sort by InstanceNumber (0020,0013) sortByInstanceNumber,
const aInstance = parseInt(a.InstanceNumber) || 0;
const bInstance = parseInt(b.InstanceNumber) || 0;
if (aInstance !== bInstance) {
return (parseInt(a.InstanceNumber) || 0) - (parseInt(b.InstanceNumber) || 0);
}
// Fallback rule to enable consistent sorting
if (a.SOPInstanceUID === b.SOPInstanceUID) {
return 0;
}
return a.SOPInstanceUID < b.SOPInstanceUID ? -1 : 1;
},
}; };
const sortingCriteria = { const sortingCriteria = {
@ -128,6 +133,55 @@ export default function sortStudy(
return study; return study;
} }
function isValidForPositionSort(images): boolean {
if (images.length <= 1) {
return false; // No need to sort if there's only one image
}
// Use the first image as a reference
const referenceImagePositionPatient = images[0].ImagePositionPatient;
const imageOrientationPatient = images[0].ImageOrientationPatient;
if (!referenceImagePositionPatient || !imageOrientationPatient) {
return false;
}
return true;
}
/**
* Sort by image position, calculated using imageOrientationPatient and ImagePositionPatient
* If imageOrientationPatient or ImagePositionPatient is not available, Images will be sorted by the provided sortingCriteria
* Note: Images are sorted in-place and a reference to the sorted image array is returned.
*
* @returns images - reference to images after sorting
*/
const sortImagesByPatientPosition = images => {
const referenceImagePositionPatient = images[0].ImagePositionPatient;
const imageOrientationPatient = images[0].ImageOrientationPatient;
// Calculate the scan axis normal using the cross product
const scanAxisNormal = calculateScanAxisNormal(imageOrientationPatient);
// Compute distances from each image to the reference image
const distanceInstancePairs = images.map(image => {
const imagePositionPatient = image.ImagePositionPatient;
const deltaVector = vec3.create();
const distance = vec3.dot(
scanAxisNormal,
vec3.subtract(deltaVector, imagePositionPatient, referenceImagePositionPatient)
);
return { distance, image };
});
// Sort images based on the computed distances
distanceInstancePairs.sort((a, b) => b.distance - a.distance);
// Reorder the images in the original array
for (const [index, item] of distanceInstancePairs.entries()) {
images[index] = item.image;
}
return images;
};
export { export {
sortStudy, sortStudy,
sortStudySeries, sortStudySeries,
@ -135,4 +189,6 @@ export {
sortingCriteria, sortingCriteria,
seriesSortCriteria, seriesSortCriteria,
instancesSortCriteria, instancesSortCriteria,
isValidForPositionSort,
sortImagesByPatientPosition,
}; };

View File

@ -26,3 +26,17 @@ summary: Migration guide for OHIF 3.11 additional changes
+ extensionsManager + extensionsManager
+ }); + });
``` ```
**Images sort by position patient**
The ImageSet sort has been modified: images are now sorted by ImagePositionPatient by default. If ImagePositionPatient is not available, the sort will fall back to InstanceNumber.
**To revert to the previous sorting method, you can add the customization instanceSortingCriteria as shown below:**
```
customizationService.setCustomizations({
'instanceSortingCriteria': {
$set: {defaultSortFunctionName: 'default'},
},
});
```

View File

@ -1818,6 +1818,31 @@ window.config = {
}; };
}, },
}], }],
};
`,
},
{
id: 'instanceSortingCriteria',
description: 'Defines the instance sorting criteria to sort the images',
default: `{
sortFunctions: {},
defaultSortFunctionName: '',
}`,
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'instanceSortingCriteria': {
$set: {
sortFunctions: {
sort: (a, b) => {}
},
defaultSortFunctionName: 'sort',
},
},
}
],
}; };
`, `,
}, },

View File

@ -4,7 +4,7 @@ import { visitStudy, checkForScreenshot, screenShotPaths, simulateClicksOnElemen
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
const studyInstanceUID = '1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5'; const studyInstanceUID = '1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5';
const mode = 'viewer'; const mode = 'viewer';
await visitStudy(page, studyInstanceUID, mode, 2000); await visitStudy(page, studyInstanceUID, mode, 5000);
}); });
test('should hydrate in MPR correctly', async ({ page }) => { test('should hydrate in MPR correctly', async ({ page }) => {
@ -15,6 +15,8 @@ test('should hydrate in MPR correctly', async ({ page }) => {
await page.locator(':text("S:7")').first().dblclick(); await page.locator(':text("S:7")').first().dblclick();
await page.waitForTimeout(5000);
await page.evaluate(() => { await page.evaluate(() => {
// Access cornerstone directly from the window object // Access cornerstone directly from the window object
const cornerstone = window.cornerstone; const cornerstone = window.cornerstone;
@ -34,6 +36,8 @@ test('should hydrate in MPR correctly', async ({ page }) => {
} }
}); });
await page.waitForTimeout(5000);
await page.getByTestId('MeasurementTools-split-button-secondary').click(); await page.getByTestId('MeasurementTools-split-button-secondary').click();
await page.getByTestId('Bidirectional').click(); await page.getByTestId('Bidirectional').click();
const locator = page.getByTestId('viewport-pane').locator('canvas'); const locator = page.getByTestId('viewport-pane').locator('canvas');
@ -80,8 +84,8 @@ test('should hydrate in MPR correctly', async ({ page }) => {
} }
}); });
// wait 2 seconds // wait 5 seconds
await page.waitForTimeout(2000); await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.scrollAway); await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.scrollAway);
@ -92,8 +96,8 @@ test('should hydrate in MPR correctly', async ({ page }) => {
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.locator('div').filter({ hasText: /^MPR$/ }).first().click(); await page.locator('div').filter({ hasText: /^MPR$/ }).first().click();
// wait 2 seconds // wait 5 seconds
await page.waitForTimeout(2000); await page.waitForTimeout(5000);
// jump in viewport again // jump in viewport again
await page.getByTestId('data-row').first().click(); await page.getByTestId('data-row').first().click();
@ -104,6 +108,8 @@ test('should hydrate in MPR correctly', async ({ page }) => {
await page.locator(':text("S:3")').first().dblclick(); await page.locator(':text("S:3")').first().dblclick();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.changeSeriesInMPR); await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.changeSeriesInMPR);
await page.getByTestId('data-row').first().click(); await page.getByTestId('data-row').first().click();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 131 KiB