fix(MetadataProvider): Correctly assign imageId for multiframe images and remove unused frame information retrieval method (#5965)
This commit is contained in:
1 parent
2c54616136
commit
1eee3e6119
7 files changed
+369
-43
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
visitStudy,
|
||||
getViewportCanvasStats,
|
||||
waitForViewportsRendered,
|
||||
waitForViewportRenderCycle,
|
||||
} from './utils';
|
||||
|
||||
// Ultrasound cine study: one series, nine instances, all but one multiframe.
|
||||
// The first display set is the InstanceNumber=1 instance with 94 frames.
|
||||
const usMultiframeStudyUID = '1.2.840.113663.1500.1.248223208.1.1.20110323.105903.687';
|
||||
|
||||
// NM brain SPECT study: one series of five multiframe instances whose per-frame
|
||||
// positions come from DetectorInformationSequence rather than per-frame groups.
|
||||
const nmMultiframeStudyUID = '1.2.276.0.7230010.3.1.2.447481088.1.1669202398.851612';
|
||||
|
||||
// A rendered frame must have some visible content; cine/SPECT frames are mostly
|
||||
// dark background, so the bar is intentionally low. A blank or black viewport
|
||||
// scores 0.
|
||||
const minimumNonBlackRatio = 0.01;
|
||||
|
||||
test.describe('Multiframe ultrasound rendering', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await visitStudy(page, usMultiframeStudyUID, 'viewer', 2000);
|
||||
await waitForViewportsRendered(page);
|
||||
});
|
||||
|
||||
test('should render a multiframe instance as a stack of all its frames', async ({
|
||||
page,
|
||||
viewportPageObject,
|
||||
}) => {
|
||||
const activeViewport = await viewportPageObject.active;
|
||||
|
||||
// All 94 frames of the instance must be enumerated as the stack size.
|
||||
await expect(activeViewport.overlayText.bottomRight.instanceNumber).toContainText('(1/94)', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// The first frame must actually be painted, not a blank/black canvas.
|
||||
const stats = await getViewportCanvasStats({ page });
|
||||
expect(stats.nonBlackRatio).toBeGreaterThan(minimumNonBlackRatio);
|
||||
});
|
||||
|
||||
test('should render distinct content when navigating through frames', async ({
|
||||
page,
|
||||
viewportPageObject,
|
||||
}) => {
|
||||
const activeViewport = await viewportPageObject.active;
|
||||
await expect(activeViewport.overlayText.bottomRight.instanceNumber).toContainText('(1/94)', {
|
||||
timeout: 10000,
|
||||
});
|
||||
const firstFrameStats = await getViewportCanvasStats({ page });
|
||||
expect(firstFrameStats.nonBlackRatio).toBeGreaterThan(minimumNonBlackRatio);
|
||||
|
||||
// Advance one frame: the overlay must track the frame index and the canvas
|
||||
// must not go black (a past regression rendered black from the 2nd frame on).
|
||||
let renderCycle = waitForViewportRenderCycle(page);
|
||||
await activeViewport.sliceNavigation.scrollBy(1);
|
||||
await renderCycle;
|
||||
|
||||
await expect(activeViewport.overlayText.bottomRight.instanceNumber).toContainText('(2/94)');
|
||||
const secondFrameStats = await getViewportCanvasStats({ page });
|
||||
expect(secondFrameStats.nonBlackRatio).toBeGreaterThan(minimumNonBlackRatio);
|
||||
|
||||
// Jump to the last frame: the canvas content must differ from frame 1,
|
||||
// which fails if the stack is stuck rendering the first frame.
|
||||
renderCycle = waitForViewportRenderCycle(page);
|
||||
await activeViewport.sliceNavigation.toLastSlice();
|
||||
await renderCycle;
|
||||
|
||||
await expect(activeViewport.overlayText.bottomRight.instanceNumber).toContainText('(94/94)');
|
||||
const lastFrameStats = await getViewportCanvasStats({ page });
|
||||
expect(lastFrameStats.nonBlackRatio).toBeGreaterThan(minimumNonBlackRatio);
|
||||
expect(lastFrameStats.digest).not.toBe(firstFrameStats.digest);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Multiframe NM rendering', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await visitStudy(page, nmMultiframeStudyUID, 'viewer', 2000);
|
||||
await waitForViewportsRendered(page);
|
||||
});
|
||||
|
||||
test('should render a multiframe NM instance with all frames enumerated', async ({
|
||||
page,
|
||||
viewportPageObject,
|
||||
}) => {
|
||||
const activeViewport = await viewportPageObject.active;
|
||||
const instanceNumberOverlay = activeViewport.overlayText.bottomRight.instanceNumber;
|
||||
|
||||
await expect(instanceNumberOverlay).toContainText('(1/', { timeout: 10000 });
|
||||
|
||||
// The stack size shown by the overlay must be the instance's frame count,
|
||||
// not 1 (which is what a broken multiframe split would produce).
|
||||
const overlayText = await instanceNumberOverlay.textContent();
|
||||
const stackSize = Number(overlayText.match(/\(1\/(\d+)\)/)?.[1]);
|
||||
expect(stackSize).toBeGreaterThan(1);
|
||||
|
||||
const stats = await getViewportCanvasStats({ page });
|
||||
expect(stats.nonBlackRatio).toBeGreaterThan(minimumNonBlackRatio);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export type ViewportCanvasStats = {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Fraction of sampled pixels whose luminance is above the black cutoff (0-1). */
|
||||
nonBlackRatio: number;
|
||||
/** Order-sensitive digest of the sampled pixels; differs when the rendered frame differs. */
|
||||
digest: number;
|
||||
};
|
||||
|
||||
type GetViewportCanvasStatsParams = {
|
||||
page: Page;
|
||||
/** Cornerstone viewport id; the single-viewport default layout uses 'default'. */
|
||||
viewportId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the pixels currently painted on a viewport's on-screen canvas and
|
||||
* returns aggregate stats. This asserts real rendered output (unlike a
|
||||
* services-state read) without a screenshot baseline: `nonBlackRatio` catches
|
||||
* blank/black viewports and `digest` catches a viewport stuck on a previous
|
||||
* frame after navigation.
|
||||
*/
|
||||
export const getViewportCanvasStats = async ({
|
||||
page,
|
||||
viewportId = 'default',
|
||||
}: GetViewportCanvasStatsParams): Promise<ViewportCanvasStats> => {
|
||||
return page.evaluate(
|
||||
({ services, viewportId }: withTestTypes<{ viewportId: string }>) => {
|
||||
const { cornerstoneViewportService } = services;
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId) as any;
|
||||
if (!viewport) {
|
||||
throw new Error(`getViewportCanvasStats: no cornerstone viewport with id "${viewportId}"`);
|
||||
}
|
||||
|
||||
const sourceCanvas = viewport.getCanvas() as HTMLCanvasElement;
|
||||
const { width, height } = sourceCanvas;
|
||||
if (!width || !height) {
|
||||
throw new Error('getViewportCanvasStats: viewport canvas has zero size');
|
||||
}
|
||||
|
||||
// Copy onto a plain 2d canvas so pixels are readable regardless of the
|
||||
// source canvas context type.
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = width;
|
||||
copy.height = height;
|
||||
const ctx = copy.getContext('2d');
|
||||
ctx.drawImage(sourceCanvas, 0, 0);
|
||||
const { data } = ctx.getImageData(0, 0, width, height);
|
||||
|
||||
const blackCutoff = 16; // 0-255 luminance below this counts as black
|
||||
const sampleStride = 4; // sample every 4th pixel to keep this fast
|
||||
let sampled = 0;
|
||||
let nonBlack = 0;
|
||||
let digest = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4 * sampleStride) {
|
||||
const luminance = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
|
||||
sampled += 1;
|
||||
if (luminance > blackCutoff) {
|
||||
nonBlack += 1;
|
||||
}
|
||||
// FNV-style rolling hash over the sampled luminance values
|
||||
digest = (Math.imul(digest ^ Math.round(luminance), 16777619) >>> 0) as number;
|
||||
}
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
nonBlackRatio: sampled ? nonBlack / sampled : 0,
|
||||
digest,
|
||||
};
|
||||
},
|
||||
{ viewportId, services: await page.evaluateHandle('window.services') }
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import { scrollVolumeViewport } from './scrollVolumeViewport';
|
||||
import { attemptAction } from './attemptAction';
|
||||
import { addLengthMeasurement } from './addLengthMeasurement';
|
||||
import { getSvgAttribute } from './getSvgAttribute';
|
||||
import { getViewportCanvasStats } from './getViewportCanvasStats';
|
||||
import { navigateWithViewportArrow } from './navigateWithViewportArrow';
|
||||
import { contourShowOnlyNthSegment } from './contourShowOnlyNthSegment';
|
||||
import { visitStudyAndHydrate } from './visitStudyAndHydrate';
|
||||
@@ -62,6 +63,7 @@ export {
|
||||
addLengthMeasurement,
|
||||
subscribeToMeasurementAdded,
|
||||
getSvgAttribute,
|
||||
getViewportCanvasStats,
|
||||
navigateWithViewportArrow,
|
||||
contourShowOnlyNthSegment,
|
||||
visitStudyAndHydrate,
|
||||
|
||||
Reference in new issue
Block a user