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

View File

@ -127,7 +127,10 @@ const makeDisplaySet = instances => {
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)
/*imageSet.setAttribute(

View File

@ -1,7 +1,9 @@
import { vec3 } from 'gl-matrix';
import toNumber from '@ohif/core/src/utils/toNumber';
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

View File

@ -18,32 +18,32 @@ describe('OHIF Cornerstone Hotkeys', () => {
it('checks if hotkeys "R" and "L" can rotate the image', () => {
cy.get('body').type('R');
cy.get('@viewportInfoMidLeft').should('contains.text', 'P');
cy.get('@viewportInfoMidTop').should('contains.text', 'R');
cy.get('@viewportInfoMidLeft').should('contains.text', 'F');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
// Hotkey L
cy.get('body').type('L');
cy.get('@viewportInfoMidLeft').should('contains.text', 'R');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
cy.get('@viewportInfoMidLeft').should('contains.text', 'A');
cy.get('@viewportInfoMidTop').should('contains.text', 'H');
});
it('checks if hotkeys "ArrowUp" and "ArrowDown" can navigate in the stack', () => {
// Hotkey ArrowDown
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
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', () => {
// Hotkey H
cy.get('body').type('h');
cy.get('@viewportInfoMidLeft').should('contains.text', 'L');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
cy.get('@viewportInfoMidLeft').should('contains.text', 'P');
cy.get('@viewportInfoMidTop').should('contains.text', 'H');
// Hotkey V
cy.get('body').type('v');
cy.get('@viewportInfoMidLeft').should('contains.text', 'L');
cy.get('@viewportInfoMidTop').should('contains.text', 'P');
cy.get('@viewportInfoMidLeft').should('contains.text', 'P');
cy.get('@viewportInfoMidTop').should('contains.text', 'F');
});
// 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', () => {
// Press multiples hotkeys
cy.get('body').type('v+++i');
cy.get('@viewportInfoMidLeft').should('contains.text', 'R');
cy.get('@viewportInfoMidTop').should('contains.text', 'P');
cy.get('@viewportInfoMidLeft').should('contains.text', 'A');
cy.get('@viewportInfoMidTop').should('contains.text', 'F');
// Hotkey SPACEBAR
cy.get('body').type(' ');
cy.get('@viewportInfoMidLeft').should('contains.text', 'R');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
cy.get('@viewportInfoMidLeft').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
cy.get('@viewportInfoBottomLeft').should($txt => {
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
cy.resetViewport();
const expectedText = 'W:958L:479';
const expectedText = 'W:576L:288';
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', () => {
cy.get('@viewportInfoMidLeft').should('contains.text', 'R');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
cy.get('@viewportInfoMidLeft').should('contains.text', 'A');
cy.get('@viewportInfoMidTop').should('contains.text', 'H');
//Click on More button
cy.get('@moreBtnSecondary').click();
@ -421,8 +421,8 @@ describe('OHIF Cornerstone Toolbar', () => {
//Click on Flip button
cy.get('[data-cy="flipHorizontal"]').click();
cy.waitDicomImage();
cy.get('@viewportInfoMidLeft').should('contains.text', 'L');
cy.get('@viewportInfoMidTop').should('contains.text', 'A');
cy.get('@viewportInfoMidLeft').should('contains.text', 'P');
cy.get('@viewportInfoMidTop').should('contains.text', 'H');
});
// 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 { Vector3 } from 'cornerstone-math';
import {
instancesSortCriteria,
isValidForPositionSort,
sortImagesByPatientPosition,
} from '../utils/sortStudy';
type Attributes = Record<string, unknown>;
type Image = {
export type Image = {
StudyInstanceUID?: string;
getData(): {
metadata: {
ImagePositionPatient: number[];
ImageOrientationPatient: number[];
};
};
ImagePositionPatient?: csTypes.Point3;
ImageOrientationPatient?: csTypes.Point3;
};
/**
@ -79,63 +80,46 @@ class ImageSet {
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[] {
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;

View File

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

View File

@ -1,4 +1,6 @@
import { vec3 } from 'gl-matrix';
import isLowPriorityModality from './isLowPriorityModality';
import calculateScanAxisNormal from './calculateScanAxisNormal';
const compareSeriesDateTime = (a, b) => {
const seriesDateA = Date.parse(`${a.seriesDate ?? a.SeriesDate} ${a.seriesTime ?? a.SeriesTime}`);
@ -41,20 +43,23 @@ const seriesSortCriteria = {
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 = {
default: (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;
},
default: sortByInstanceNumber,
sortByInstanceNumber,
};
const sortingCriteria = {
@ -128,6 +133,55 @@ export default function sortStudy(
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 {
sortStudy,
sortStudySeries,
@ -135,4 +189,6 @@ export {
sortingCriteria,
seriesSortCriteria,
instancesSortCriteria,
isValidForPositionSort,
sortImagesByPatientPosition,
};

View File

@ -26,3 +26,17 @@ summary: Migration guide for OHIF 3.11 additional changes
+ 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 }) => {
const studyInstanceUID = '1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5';
const mode = 'viewer';
await visitStudy(page, studyInstanceUID, mode, 2000);
await visitStudy(page, studyInstanceUID, mode, 5000);
});
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.waitForTimeout(5000);
await page.evaluate(() => {
// Access cornerstone directly from the window object
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('Bidirectional').click();
const locator = page.getByTestId('viewport-pane').locator('canvas');
@ -80,8 +84,8 @@ test('should hydrate in MPR correctly', async ({ page }) => {
}
});
// wait 2 seconds
await page.waitForTimeout(2000);
// wait 5 seconds
await page.waitForTimeout(5000);
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.locator('div').filter({ hasText: /^MPR$/ }).first().click();
// wait 2 seconds
await page.waitForTimeout(2000);
// wait 5 seconds
await page.waitForTimeout(5000);
// jump in viewport again
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.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.changeSeriesInMPR);
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