fix: image slices are displayed in reverse order, if we switch from common layout to 2D MPR (#5022)
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
instanceSortingCriteria: {
|
||||
sortFunctions: {},
|
||||
defaultSortFunctionName: '',
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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'},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
};
|
||||
`,
|
||||
},
|
||||
|
||||
@ -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();
|
||||
|
||||
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 131 KiB |