Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions packages/tools/src/tools/ScaleOverlayTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,17 +232,24 @@ class ScaleOverlayTool extends AnnotationDisplayTool {
location
);

// Computes which scale size to use, ex: 100mm, 50mm
const scaleSize = this.computeScaleSize(
// Computes which scale size to use, ex: 100mm, 50mm, and the world
// length it covers
const { scaleSize, worldScaleSize, unit } = this._computeScale(
viewport,
worldWidthViewport,
worldHeightViewport,
location
);
// None of the scale sizes fits the view (it is too small, in the image's
// units, for the smallest size)
if (scaleSize === undefined) {
return renderStatus;
}

// Applies the scale with the predetermined size to the image in
// world coordinates, then converts them to canvas coordinates
const canvasCoordinates = this.computeWorldScaleCoordinates(
scaleSize,
worldScaleSize,
location,
pointSet1
).map((world) => viewport.worldToCanvas(world));
Expand Down Expand Up @@ -333,7 +340,7 @@ class ScaleOverlayTool extends AnnotationDisplayTool {
scaleCanvasCoordinates[0][0] + locationTextOffest[location][0],
scaleCanvasCoordinates[0][1] + locationTextOffest[location][1],
];
const textBoxLines = this._getTextLines(scaleSize);
const textBoxLines = this._getTextLines(scaleSize, unit);

const { tickIds, tickUIDs, tickCoordinates } = this.computeInnerScaleTicks(
scaleSize,
Expand Down Expand Up @@ -381,7 +388,42 @@ class ScaleOverlayTool extends AnnotationDisplayTool {
return renderStatus;
}

_getTextLines(scaleSize: number): string[] | undefined {
/**
* Picks the scale size in the units the image is measured in, and the world
* length that size covers.
* World units are mm on an image with pixel spacing, and pixels on one
* without (hasPixelSpacing false), where the scale is labelled in px. A user
* calibration measures a world length L as L / calibration.scale mm, so a
* scale of S mm covers S * calibration.scale world units.
*
* @param viewport - the viewport the scale is drawn on
* @param worldWidthViewport - the width of the view in world units
* @param worldHeightViewport - the height of the view in world units
* @param location - where the scale is drawn: top, bottom, left or right
* @returns the scale size in `unit`, and the world length it covers
*/
_computeScale(
viewport,
worldWidthViewport: number,
worldHeightViewport: number,
location: string
): { scaleSize: number; worldScaleSize: number; unit: string } {
const imageData = viewport.getImageData?.();
const unit = imageData?.hasPixelSpacing === false ? 'px' : 'mm';
const worldPerUnit = imageData?.calibration?.scale || 1;
const scaleSize = this.computeScaleSize(
worldWidthViewport / worldPerUnit,
worldHeightViewport / worldPerUnit,
location
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

return { scaleSize, worldScaleSize: scaleSize * worldPerUnit, unit };
}

_getTextLines(scaleSize: number, unit = 'mm'): string[] | undefined {
if (unit !== 'mm') {
return [`${scaleSize} ${unit}`];
}
let scaleSizeDisplayValue;
let scaleSizeUnits;
if (scaleSize >= 50) {
Expand Down
120 changes: 120 additions & 0 deletions packages/tools/test/scaleOverlayUnits.jest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { drawLine, drawTextBox } from '../src/drawingSvg';
import { getAnnotations } from '../src/stateManagement/annotation/annotationState';
import ScaleOverlayTool from '../src/tools/ScaleOverlayTool';

jest.mock('../src/drawingSvg', () => ({
drawLine: jest.fn(),
drawTextBox: jest.fn(),
}));

jest.mock('../src/stateManagement/annotation/annotationState', () => ({
addAnnotation: jest.fn(),
getAnnotations: jest.fn(),
}));

/**
* Renders the scale over a 600 x 600 view whose world units map one to one
* to canvas pixels, and returns the label and the length of the scale line
* in canvas pixels.
*/
function renderScale(imageData) {
const tool = new ScaleOverlayTool();
const viewport = {
id: 'viewport',
element: document.createElement('div'),
canvas: { width: 600, height: 600 },
worldToCanvas: ([x, y]) => [x, y],
...(imageData && { getImageData: () => imageData }),
};
getAnnotations.mockReturnValue([
{
annotationUID: 'scale',
data: {
viewportId: 'viewport',
handles: {
points: [
[0, 0, 0],
[600, 0, 0],
[0, 600, 0],
[600, 600, 0],
],
},
},
},
]);
tool.editData = { viewport };

tool.renderAnnotation({ viewport }, {});

if (!drawLine.mock.calls.length) {
return { drawn: drawTextBox.mock.calls.length > 0 };
}
const [, , , start, end] = drawLine.mock.calls[0];
const [, , , lines] = drawTextBox.mock.calls[0];
return { lines, length: Math.hypot(end[0] - start[0], end[1] - start[1]) };
}

describe('ScaleOverlayTool units', () => {
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 1,
});
});

it('labels and sizes the scale in mm on an image with pixel spacing', () => {
const { lines, length } = renderScale({
hasPixelSpacing: true,
spacing: [1, 1, 1],
});

// 600 mm across: 250 mm is the size between 20% and 60% of the width
expect(lines).toEqual(['25 cm']);
expect(length).toBeCloseTo(250);
});

it('labels the scale in pixels on an image without pixel spacing', () => {
// An ultrasound frame without pixel spacing: world units are pixels
const { lines, length } = renderScale({
hasPixelSpacing: false,
spacing: [1, 1, 1],
});

expect(lines).toEqual(['250 px']);
expect(length).toBeCloseTo(250);
});

it('applies a user calibration to the length the scale covers', () => {
// A user calibration of 2: 2 world units measure 1 mm, so the view is
// 300 mm across, and 100 mm covers 200 world units
const { lines, length } = renderScale({
hasPixelSpacing: true,
spacing: [1, 1, 1],
calibration: { type: 'User', scale: 2 },
});

expect(lines).toEqual(['10 cm']);
expect(length).toBeCloseTo(200);
});

it('keeps mm on a viewport without image data', () => {
const { lines, length } = renderScale(undefined);

expect(lines).toEqual(['25 cm']);
expect(length).toBeCloseTo(250);
});

it('draws no scale when none of the sizes fits the view', () => {
// A user calibration of 1000: the view is 0.6 mm across, below the
// smallest scale size
const result = renderScale({
hasPixelSpacing: true,
spacing: [1, 1, 1],
calibration: { type: 'User', scale: 1000 },
});

expect(result).toEqual({ drawn: false });
});
});