@bettermedicine/imeasure 0.0.5 → 0.0.7-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,10 +10,15 @@ Better Medicine's iMeasure API.
10
10
  - [Cropping](#cropping)
11
11
  - [Cropping region calulation](#cropping-region-calulation)
12
12
  - [Executing the crop](#executing-the-crop)
13
+ - [Working with data from GPU texture buffers](#working-with-data-from-gpu-texture-buffers)
14
+ - [Image data bit depth](#image-data-bit-depth)
13
15
  - [Affines](#affines)
14
16
  - [World metadata](#world-metadata)
15
17
  - [Composing the request payload](#composing-the-request-payload)
16
18
 
19
+ You can find the primary documentation site at [https://docs.imeasure.bettermedicine.ai](https://docs.imeasure.bettermedicine.ai).
20
+ Module references mentioned in this document can be located under the `@bettermedicine/namespaces` sidebar route.
21
+
17
22
  ## Installation
18
23
 
19
24
  ```bash
@@ -89,7 +94,15 @@ const {
89
94
  } = imageCrop;
90
95
  ```
91
96
 
92
- Note: in modern versions of Cornerstone 3D, the image volume's voxel data only exists in the GPU texture buffer.
97
+ > :bulb: **Note**: In case the measurement we're generating the crop region around is near the edge of the volume or in case the measurement
98
+ > is long enough, the crop region may extend beyond the volume's boundaries. In such cases, the consumer is expected to provide a non-cuboid input
99
+ > volume but still preserve metadata about the originally intended crop region. The utilities provided in the cropping module will handle this
100
+ > case automatically by supplying `cropBoundariesUnclippedVoxel` and `cropCuboidCenterVoxel` corresponding to the original crop region, while the
101
+ > `cropBoundariesVoxel`, `cropBoundariesWorld` and `shape` attributes in `TCropMetadata` will be adjusted to the actual effective cropped region.
102
+
103
+ ##### Working with data from GPU texture buffers
104
+
105
+ In modern versions of Cornerstone 3D, unless forced by configuration, the image volume's voxel data only exists in the GPU texture buffer.
93
106
  If the above function outputs empty `voxelValues`, we need to take a few additional steps to ensure the data is reachable by our code:
94
107
 
95
108
  ```ts
@@ -183,6 +196,24 @@ function getVTKImageData(cornerstoneVolumeID: VolumeID): vtkImageData {
183
196
  }
184
197
  ```
185
198
 
199
+ ##### Image data bit depth
200
+
201
+ DICOM CT HU values typically fit within the range allowance of a 16-bit signed integer. As such, this is what the iMeasure API expects
202
+ as the scalar data type. Unless you're forcing 16-bit textures via OHIF config, the scalar data may be of the `Float32Array` type.
203
+ You can slash the converted `vtkImageData` object's memory footprint by half by using our `formatting.float32ArrayToInt16Array`
204
+ utility inlined into the above `vtkDataArray` instantiation:
205
+
206
+ ```ts
207
+ import { formatting } from '@bettermedicine/imeasure';
208
+
209
+ const da = vtkDataArray.newInstance({
210
+ ...
211
+ values: formatting.float32ArrayToInt16Array(
212
+ baseVolume.voxelManager.getCompleteScalarDataArray()
213
+ ),
214
+ });
215
+ ```
216
+
186
217
  #### Affines
187
218
 
188
219
  In order to accurately calculate the crop region, we need some more information about the the shape, orientation, origin and spacing of the series' world coordinate system. The standard way to express this is through an affine transformation matrix - a 4x4 matrix that describes how to transform points from the voxel coordinate system to the world coordinate system.
@@ -238,8 +269,7 @@ const voxelPoint = math.affines.affineTranslateVec3(worldPoint, worldToVoxelMatr
238
269
 
239
270
  ### World metadata
240
271
 
241
- TODO: A brief explanation of which world metadata we require for the request and how to derive it. For now,
242
- please refer to the IO module's documentation for the `IStaticIMeasurePayload` type.
272
+ Please refer to the IO module's documentation for the `IStaticIMeasurePayload` type.
243
273
 
244
274
  ### Composing the request payload
245
275
 
@@ -251,6 +281,7 @@ const worldMetadata = ...
251
281
  const cropMetadata = ...
252
282
  const clicks = ...
253
283
  const id = uuidv4();
284
+ // note: this should be an Int16Array - see the bit depth section above.
254
285
  const imageVoxelValues = ... // the flattened array of voxel values from the cropped region
255
286
 
256
287
  const requestPayload = formatting.formatStaticPayload(
@@ -267,7 +298,8 @@ const requestPayload = formatting.formatStaticPayload(
267
298
  const resp = await fetch('https://api.bettermedicine.com/imeasure', {
268
299
  method: 'POST',
269
300
  body: requestPayload,
270
- // note: setting headers manually here is not recommended as Fetch should handle it correctly automatically.
301
+ // note: setting content-type headers manually here is not recommended as `fetch`
302
+ // should handle it correctly automatically.
271
303
  });
272
304
 
273
305
  const responseData = await resp.json();
@@ -1,23 +1,33 @@
1
1
  import { affineTranslateVec3, invertAffineMatrix } from "./affines";
2
2
  import { INTERACTIVE_IMEASURE_BASE } from "../constants";
3
- import { calculateZAxisLength, roundPoint3 } from "./utility";
3
+ import { roundPoint3 } from "./utility";
4
+ const worldLengthAndAffineToCropCuboidDimensions = (worldLength, worldToVoxelAffine) => {
5
+ const voxelToWorldAffine = invertAffineMatrix(worldToVoxelAffine);
6
+ // we effectively need to make the length of the measurement fit the crop region
7
+ // in all 3 dimensions.
8
+ const pixelSpacing = voxelToWorldAffine[0];
9
+ const sliceThickness = voxelToWorldAffine[10];
10
+ // calculate the length in voxel space
11
+ let xyLength = Math.max(INTERACTIVE_IMEASURE_BASE.X, Math.round(worldLength / pixelSpacing) +
12
+ INTERACTIVE_IMEASURE_BASE.PADDING * 2);
13
+ if (xyLength % 2 !== 0) {
14
+ xyLength += 1; // ensure even number
15
+ }
16
+ let zLength = Math.max(INTERACTIVE_IMEASURE_BASE.Z, Math.round((xyLength * pixelSpacing) / sliceThickness));
17
+ if (zLength % 2 !== 0) {
18
+ zLength += 1; // ensure even number
19
+ }
20
+ return [xyLength, xyLength, zLength];
21
+ };
4
22
  // TODO: should expand scale based on z-axis input point diff.
5
23
  // Tests first.
6
24
  export const getInteractiveImeasureCropCuboidDimensions = (measurement, worldToVoxelAffine) => {
7
- const [voxelStart, voxelEnd] = measurement.map((pt) => roundPoint3(affineTranslateVec3(pt, worldToVoxelAffine)));
8
- const x_size = Math.abs(voxelStart[0] - voxelEnd[0]) +
9
- INTERACTIVE_IMEASURE_BASE.PADDING * 2;
10
- const y_size = Math.abs(voxelStart[1] - voxelEnd[1]) +
11
- INTERACTIVE_IMEASURE_BASE.PADDING * 2;
12
- let xyCubeSize = Math.max(x_size, y_size, INTERACTIVE_IMEASURE_BASE.X);
13
- // the model always expects an even number of voxels on each axis
14
- if (xyCubeSize % 2 !== 0) {
15
- xyCubeSize += 1;
16
- }
17
- const voxelToWorldAffine = invertAffineMatrix(worldToVoxelAffine);
18
- const maxDistanceBetweenPoints = Math.max(x_size, y_size);
19
- const zLength = calculateZAxisLength(maxDistanceBetweenPoints, voxelToWorldAffine);
20
- return [xyCubeSize, xyCubeSize, zLength];
25
+ const [worldStart, worldEnd] = measurement;
26
+ // calculate the world-space length of the measurement
27
+ const worldLength = Math.sqrt(Math.pow(worldEnd[0] - worldStart[0], 2) +
28
+ Math.pow(worldEnd[1] - worldStart[1], 2) +
29
+ Math.pow(worldEnd[2] - worldStart[2], 2));
30
+ return worldLengthAndAffineToCropCuboidDimensions(worldLength, worldToVoxelAffine);
21
31
  };
22
32
  const getCropCuboidFromCenterAndEdgeLengths = (voxelSpaceCenter, edgeLengths) => {
23
33
  const minCropCoordinates = [
@@ -47,6 +57,7 @@ const getCropCuboidFromCenterAndEdgeLengths = (voxelSpaceCenter, edgeLengths) =>
47
57
  export const calculateInteractiveImeasureCropRegion = (worldBasePoints, voxelToWorldAffine) => {
48
58
  let center;
49
59
  const singlePoint = worldBasePoints.length === 1;
60
+ let worldBaseLength = 0;
50
61
  if (singlePoint) {
51
62
  center = worldBasePoints[0];
52
63
  }
@@ -56,19 +67,15 @@ export const calculateInteractiveImeasureCropRegion = (worldBasePoints, voxelToW
56
67
  (worldBasePoints[0][1] + worldBasePoints[1][1]) / 2,
57
68
  (worldBasePoints[0][2] + worldBasePoints[1][2]) / 2,
58
69
  ];
70
+ worldBaseLength = Math.sqrt(Math.pow(worldBasePoints[1][0] - worldBasePoints[0][0], 2) +
71
+ Math.pow(worldBasePoints[1][1] - worldBasePoints[0][1], 2) +
72
+ Math.pow(worldBasePoints[1][2] - worldBasePoints[0][2], 2));
59
73
  }
60
74
  // we want to convert (and round) this to voxel coordinates
61
75
  const worldToVoxelAffine = invertAffineMatrix(voxelToWorldAffine);
62
76
  const voxelSpaceCenter = roundPoint3(affineTranslateVec3(center, worldToVoxelAffine));
63
77
  // if we only have a single click, use constants for edge lengths.
64
- let x_edge = INTERACTIVE_IMEASURE_BASE.X;
65
- let y_edge = INTERACTIVE_IMEASURE_BASE.Y;
66
- let z_edge = INTERACTIVE_IMEASURE_BASE.Z;
67
- if (!singlePoint) {
68
- // if we have two clicks, calculate the edges based on the points
69
- const tempProposedCuboidEdges = getInteractiveImeasureCropCuboidDimensions(worldBasePoints, worldToVoxelAffine);
70
- [x_edge, y_edge, z_edge] = tempProposedCuboidEdges;
71
- }
78
+ const [x_edge, y_edge, z_edge] = worldLengthAndAffineToCropCuboidDimensions(worldBaseLength, worldToVoxelAffine);
72
79
  const { cropCubePoints, croppingPlanes } = getCropCuboidFromCenterAndEdgeLengths(voxelSpaceCenter, {
73
80
  x: x_edge,
74
81
  y: y_edge,
@@ -95,17 +102,17 @@ export const calculateInteractiveImeasureCropRegion = (worldBasePoints, voxelToW
95
102
  * The function maintains the center and cropping planes, and updates edge lengths if necessary.
96
103
  */
97
104
  export const calculateInteractiveImeasureBoundingBox = (firstClicks, extraClicks = [], voxelToWorldAffine) => {
98
- const worldToVoxelAffine = invertAffineMatrix(voxelToWorldAffine);
99
105
  const { center, voxelSpaceCenter, cropCubePoints, croppingPlanes, edgeLengths, } = calculateInteractiveImeasureCropRegion(firstClicks, voxelToWorldAffine);
100
106
  let maxDistanceFromCenter = 0;
101
- const extraClicksInVoxelSpace = extraClicks.map((pt) => roundPoint3(affineTranslateVec3(pt, worldToVoxelAffine)));
102
- extraClicksInVoxelSpace.forEach((point) => {
103
- maxDistanceFromCenter = Math.max(Math.abs(point[0] - voxelSpaceCenter[0]), Math.abs(point[1] - voxelSpaceCenter[1]), maxDistanceFromCenter);
104
- // TODO: handle z-axis differences
107
+ extraClicks.forEach((point) => {
108
+ const distanceFromCenter = Math.sqrt(Math.pow(point[0] - center[0], 2) +
109
+ Math.pow(point[1] - center[1], 2) +
110
+ Math.pow(point[2] - center[2], 2));
111
+ maxDistanceFromCenter = Math.max(distanceFromCenter, maxDistanceFromCenter);
105
112
  });
106
- const halfEdgeLength = maxDistanceFromCenter + INTERACTIVE_IMEASURE_BASE.PADDING;
113
+ const newEdgeLengths = worldLengthAndAffineToCropCuboidDimensions(maxDistanceFromCenter * 2, voxelToWorldAffine);
107
114
  // check if we need to expand the box at all?
108
- if (halfEdgeLength * 2 < edgeLengths.x) {
115
+ if (newEdgeLengths[0] <= edgeLengths.x) {
109
116
  return {
110
117
  center,
111
118
  voxelSpaceCenter,
@@ -114,19 +121,21 @@ export const calculateInteractiveImeasureBoundingBox = (firstClicks, extraClicks
114
121
  edgeLengths,
115
122
  };
116
123
  }
117
- const zLength = calculateZAxisLength(halfEdgeLength * 2, voxelToWorldAffine);
118
- const newEdgeLengths = {
119
- x: halfEdgeLength * 2,
120
- y: halfEdgeLength * 2,
121
- z: zLength,
122
- };
123
- const { cropCubePoints: newCropCubePoints, croppingPlanes: newCroppingPlanes, } = getCropCuboidFromCenterAndEdgeLengths(voxelSpaceCenter, newEdgeLengths);
124
+ const { cropCubePoints: newCropCubePoints, croppingPlanes: newCroppingPlanes, } = getCropCuboidFromCenterAndEdgeLengths(voxelSpaceCenter, {
125
+ x: newEdgeLengths[0],
126
+ y: newEdgeLengths[1],
127
+ z: newEdgeLengths[2],
128
+ });
124
129
  return {
125
130
  center,
126
131
  voxelSpaceCenter,
127
132
  cropCubePoints: newCropCubePoints,
128
133
  croppingPlanes: newCroppingPlanes,
129
- edgeLengths: newEdgeLengths,
134
+ edgeLengths: {
135
+ x: newEdgeLengths[0],
136
+ y: newEdgeLengths[1],
137
+ z: newEdgeLengths[2],
138
+ },
130
139
  };
131
140
  };
132
141
  /**
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,184 @@
1
+ import { mat4 } from "gl-matrix";
2
+ import { it, describe, expect } from "vitest";
3
+ import { calculateInteractiveImeasureBoundingBox, getInteractiveImeasureCropCuboidDimensions, } from "./cropping";
4
+ const identityMatrix = mat4.fromValues(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
5
+ // a matrix where the pixel spacing is 1mm and slice thickness is 2mm
6
+ // since these are world-to-voxel affine matrices, the 0.5 indicates
7
+ // that for every z-axis millimeter in the world we move 0.5 units in voxel space
8
+ const thickSlicesMatrix = mat4.fromValues(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0.5, // slice thickness
9
+ 0, 0, 0, 0, 1);
10
+ describe("getInteractiveImeasureCropCuboidDimensions", () => {
11
+ const testCases = [
12
+ {
13
+ it: "returns expected minimums in case of a very short base measurement",
14
+ measurement: [
15
+ [100, 100, 256],
16
+ [101, 101, 256],
17
+ ],
18
+ expectedDimensions: [128, 128, 128],
19
+ },
20
+ {
21
+ it: "returns expected dimensions for a larger measurement",
22
+ measurement: [
23
+ [0, 100, 256],
24
+ [0, 0, 256],
25
+ ],
26
+ // the length of the line in the largest axis (X) is 100, so the z-axis length
27
+ // is 100 + 2*padding = 116 but x/y dimensions are not scaled as they still fit
28
+ // the minimum size of 128
29
+ expectedDimensions: [128, 128, 128],
30
+ },
31
+ {
32
+ it: "returns expected dimensions that extend past minimums in x/y axes",
33
+ measurement: [
34
+ [0, 200, 256],
35
+ [0, 0, 256],
36
+ ],
37
+ expectedDimensions: [216, 216, 216],
38
+ },
39
+ {
40
+ it: "properly scales z-axis even if x/y movement is minimal",
41
+ measurement: [
42
+ [0, 0, 0],
43
+ [0, 0, 100],
44
+ ],
45
+ // length of the line in the largest axis is 100 so z-axis of crop cuboid
46
+ // should scale accordingly
47
+ expectedDimensions: [128, 128, 128],
48
+ },
49
+ {
50
+ it: "properly scales z-axis when movement happens in all axes",
51
+ measurement: [
52
+ [0, 0, 0],
53
+ [50, 50, 50],
54
+ ],
55
+ expectedDimensions: [128, 128, 128],
56
+ },
57
+ {
58
+ it: "properly scales all axes when movement happens in all axes and doesn't fit minimums",
59
+ measurement: [
60
+ [0, 0, 0],
61
+ [200, 200, 200],
62
+ ],
63
+ expectedDimensions: [362, 362, 362],
64
+ },
65
+ {
66
+ it: "properly scales z-axis with minimal x/y movement and thick slices",
67
+ measurement: [
68
+ [0, 0, 0],
69
+ [0, 0, 100],
70
+ ],
71
+ // 100/2 + 2*padding = 50 + 16 = 66
72
+ expectedDimensions: [128, 128, 64],
73
+ matrix: thickSlicesMatrix,
74
+ },
75
+ {
76
+ it: "properly scales z-axis with minimal x/y movement and thick slices when z-axis doesn't fit minimums",
77
+ measurement: [
78
+ [0, 0, 0],
79
+ [0, 0, 200],
80
+ ],
81
+ // the length of the measurement is 200 -> measurement + padding gives us 216 in x/y and
82
+ // half that in z-axis due to slice thickness
83
+ expectedDimensions: [216, 216, 108],
84
+ matrix: thickSlicesMatrix,
85
+ },
86
+ {
87
+ it: "properly scales all axes with thick slices",
88
+ measurement: [
89
+ [0, 0, 0],
90
+ [200, 200, 200],
91
+ ],
92
+ expectedDimensions: [362, 362, 182],
93
+ matrix: thickSlicesMatrix,
94
+ },
95
+ {
96
+ it: "properly scales all axes if z-axis diff is minimal but not null",
97
+ measurement: [
98
+ [0, 0, 0],
99
+ [200, 200, 100],
100
+ ],
101
+ // whereas a 100mm movement in z-axis would result in 66 voxels of cuboid length,
102
+ // we _are_ still trying to keep it cuboid shaped - since the naive z-axis calculation
103
+ // of `max(x_length, y_length) * proportion` yields 108 voxels, we use that as it
104
+ // is bigger and thus more likely to fit a spherical shape.
105
+ expectedDimensions: [316, 316, 158],
106
+ matrix: thickSlicesMatrix,
107
+ },
108
+ ];
109
+ const hasOnly = testCases.some((test) => test.only);
110
+ testCases.forEach(({ it: name, measurement, expectedDimensions, matrix, only }) => {
111
+ if (hasOnly && !only) {
112
+ return; // skip this test if there is an 'only' test case
113
+ }
114
+ it(name, () => {
115
+ const dims = getInteractiveImeasureCropCuboidDimensions(measurement, matrix || identityMatrix);
116
+ expect(dims).toEqual(expectedDimensions);
117
+ });
118
+ });
119
+ });
120
+ describe("calculateInteractiveImeasureBoundingBox", () => {
121
+ const testCases = [
122
+ {
123
+ it: "returns a base bounding box for a single click",
124
+ baseClicks: [[100, 100, 100]],
125
+ extraClicks: [],
126
+ expectedCenter: [100, 100, 100],
127
+ expectedEdgeLengths: { x: 128, y: 128, z: 128 },
128
+ },
129
+ {
130
+ it: "returns a base bounding box for a very short measurement",
131
+ baseClicks: [
132
+ [99, 99, 99],
133
+ [101, 101, 101],
134
+ ],
135
+ extraClicks: [],
136
+ expectedCenter: [100, 100, 100],
137
+ expectedEdgeLengths: { x: 128, y: 128, z: 128 },
138
+ },
139
+ {
140
+ it: "returns base bounding box if extra clicks fit within the initial box",
141
+ baseClicks: [
142
+ [99, 99, 99],
143
+ [101, 101, 101],
144
+ ],
145
+ extraClicks: [
146
+ [100, 100, 100],
147
+ [102, 102, 102],
148
+ ],
149
+ expectedCenter: [100, 100, 100],
150
+ expectedEdgeLengths: { x: 128, y: 128, z: 128 },
151
+ },
152
+ {
153
+ it: "returns scaled box if base clicks don't fit minimums but extra clicks fit",
154
+ baseClicks: [
155
+ [50, 50, 50],
156
+ [150, 150, 150],
157
+ ],
158
+ extraClicks: [[100, 100, 100]],
159
+ expectedCenter: [100, 100, 100],
160
+ expectedEdgeLengths: { x: 190, y: 190, z: 190 },
161
+ },
162
+ {
163
+ it: "returns scaled box if base clicks fit minimums but extra clicks don't",
164
+ baseClicks: [
165
+ [99, 99, 99],
166
+ [101, 101, 101],
167
+ ],
168
+ extraClicks: [[99, 99, 250]],
169
+ expectedCenter: [100, 100, 100],
170
+ expectedEdgeLengths: { x: 316, y: 316, z: 316 },
171
+ },
172
+ ];
173
+ const hasOnly = testCases.some((test) => test.only);
174
+ testCases.forEach(({ it: name, baseClicks, extraClicks, worldToVoxelAffine, expectedCenter, expectedEdgeLengths, only, }) => {
175
+ if (hasOnly && !only) {
176
+ return; // skip this test if there is an 'only' test case
177
+ }
178
+ it(name, () => {
179
+ const result = calculateInteractiveImeasureBoundingBox(baseClicks, extraClicks, worldToVoxelAffine || identityMatrix);
180
+ expect(result.center).toEqual(expectedCenter);
181
+ expect(result.edgeLengths).toEqual(expectedEdgeLengths);
182
+ });
183
+ });
184
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettermedicine/imeasure",
3
- "version": "0.0.5",
3
+ "version": "0.0.7-0",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "prepack": "npm run build",
@@ -0,0 +1,260 @@
1
+ import { mat4 } from "gl-matrix";
2
+ import { it, describe, expect, test } from "vitest";
3
+ import {
4
+ calculateInteractiveImeasureBoundingBox,
5
+ getInteractiveImeasureCropCuboidDimensions,
6
+ } from "./cropping";
7
+ import { Point3D } from "../types/cornerstone";
8
+
9
+ type TCropTestCase = {
10
+ it: string;
11
+ measurement: [[number, number, number], [number, number, number]];
12
+ expectedDimensions: [number, number, number];
13
+ matrix?: mat4; // an undefined matrix will default to identity
14
+ only?: true; // only run this test case - useful for debugging
15
+ };
16
+ const identityMatrix = mat4.fromValues(
17
+ 1,
18
+ 0,
19
+ 0,
20
+ 0,
21
+ 0,
22
+ 1,
23
+ 0,
24
+ 0,
25
+ 0,
26
+ 0,
27
+ 1,
28
+ 0,
29
+ 0,
30
+ 0,
31
+ 0,
32
+ 1
33
+ );
34
+ // a matrix where the pixel spacing is 1mm and slice thickness is 2mm
35
+ // since these are world-to-voxel affine matrices, the 0.5 indicates
36
+ // that for every z-axis millimeter in the world we move 0.5 units in voxel space
37
+ const thickSlicesMatrix = mat4.fromValues(
38
+ 1,
39
+ 0,
40
+ 0,
41
+ 0,
42
+ 0,
43
+ 1,
44
+ 0,
45
+ 0,
46
+ 0,
47
+ 0,
48
+ 0.5, // slice thickness
49
+ 0,
50
+ 0,
51
+ 0,
52
+ 0,
53
+ 1
54
+ );
55
+
56
+ describe("getInteractiveImeasureCropCuboidDimensions", () => {
57
+ const testCases: TCropTestCase[] = [
58
+ {
59
+ it: "returns expected minimums in case of a very short base measurement",
60
+ measurement: [
61
+ [100, 100, 256],
62
+ [101, 101, 256],
63
+ ],
64
+ expectedDimensions: [128, 128, 128],
65
+ },
66
+ {
67
+ it: "returns expected dimensions for a larger measurement",
68
+ measurement: [
69
+ [0, 100, 256],
70
+ [0, 0, 256],
71
+ ],
72
+ // the length of the line in the largest axis (X) is 100, so the z-axis length
73
+ // is 100 + 2*padding = 116 but x/y dimensions are not scaled as they still fit
74
+ // the minimum size of 128
75
+ expectedDimensions: [128, 128, 128],
76
+ },
77
+ {
78
+ it: "returns expected dimensions that extend past minimums in x/y axes",
79
+ measurement: [
80
+ [0, 200, 256],
81
+ [0, 0, 256],
82
+ ],
83
+ expectedDimensions: [216, 216, 216],
84
+ },
85
+ {
86
+ it: "properly scales z-axis even if x/y movement is minimal",
87
+ measurement: [
88
+ [0, 0, 0],
89
+ [0, 0, 100],
90
+ ],
91
+ // length of the line in the largest axis is 100 so z-axis of crop cuboid
92
+ // should scale accordingly
93
+ expectedDimensions: [128, 128, 128],
94
+ },
95
+ {
96
+ it: "properly scales z-axis when movement happens in all axes",
97
+ measurement: [
98
+ [0, 0, 0],
99
+ [50, 50, 50],
100
+ ],
101
+ expectedDimensions: [128, 128, 128],
102
+ },
103
+ {
104
+ it: "properly scales all axes when movement happens in all axes and doesn't fit minimums",
105
+ measurement: [
106
+ [0, 0, 0],
107
+ [200, 200, 200],
108
+ ],
109
+ expectedDimensions: [362, 362, 362],
110
+ },
111
+ {
112
+ it: "properly scales z-axis with minimal x/y movement and thick slices",
113
+ measurement: [
114
+ [0, 0, 0],
115
+ [0, 0, 100],
116
+ ],
117
+ // 100/2 + 2*padding = 50 + 16 = 66
118
+ expectedDimensions: [128, 128, 64],
119
+ matrix: thickSlicesMatrix,
120
+ },
121
+ {
122
+ it: "properly scales z-axis with minimal x/y movement and thick slices when z-axis doesn't fit minimums",
123
+ measurement: [
124
+ [0, 0, 0],
125
+ [0, 0, 200],
126
+ ],
127
+ // the length of the measurement is 200 -> measurement + padding gives us 216 in x/y and
128
+ // half that in z-axis due to slice thickness
129
+ expectedDimensions: [216, 216, 108],
130
+ matrix: thickSlicesMatrix,
131
+ },
132
+ {
133
+ it: "properly scales all axes with thick slices",
134
+ measurement: [
135
+ [0, 0, 0],
136
+ [200, 200, 200],
137
+ ],
138
+ expectedDimensions: [362, 362, 182],
139
+ matrix: thickSlicesMatrix,
140
+ },
141
+ {
142
+ it: "properly scales all axes if z-axis diff is minimal but not null",
143
+ measurement: [
144
+ [0, 0, 0],
145
+ [200, 200, 100],
146
+ ],
147
+ // whereas a 100mm movement in z-axis would result in 66 voxels of cuboid length,
148
+ // we _are_ still trying to keep it cuboid shaped - since the naive z-axis calculation
149
+ // of `max(x_length, y_length) * proportion` yields 108 voxels, we use that as it
150
+ // is bigger and thus more likely to fit a spherical shape.
151
+ expectedDimensions: [316, 316, 158],
152
+ matrix: thickSlicesMatrix,
153
+ },
154
+ ];
155
+ const hasOnly = testCases.some((test) => test.only);
156
+ testCases.forEach(
157
+ ({ it: name, measurement, expectedDimensions, matrix, only }) => {
158
+ if (hasOnly && !only) {
159
+ return; // skip this test if there is an 'only' test case
160
+ }
161
+ it(name, () => {
162
+ const dims = getInteractiveImeasureCropCuboidDimensions(
163
+ measurement,
164
+ matrix || identityMatrix
165
+ );
166
+ expect(dims).toEqual(expectedDimensions);
167
+ });
168
+ }
169
+ );
170
+ });
171
+
172
+ type TBBTestCase = {
173
+ it: string;
174
+ baseClicks: [Point3D, Point3D] | [Point3D];
175
+ extraClicks: Point3D[];
176
+ worldToVoxelAffine?: mat4;
177
+ expectedCenter: [number, number, number];
178
+ expectedEdgeLengths: { x: number; y: number; z: number };
179
+ only?: true; // only run this test case - useful for debugging
180
+ };
181
+
182
+ describe("calculateInteractiveImeasureBoundingBox", () => {
183
+ const testCases: TBBTestCase[] = [
184
+ {
185
+ it: "returns a base bounding box for a single click",
186
+ baseClicks: [[100, 100, 100]],
187
+ extraClicks: [],
188
+ expectedCenter: [100, 100, 100],
189
+ expectedEdgeLengths: { x: 128, y: 128, z: 128 },
190
+ },
191
+ {
192
+ it: "returns a base bounding box for a very short measurement",
193
+ baseClicks: [
194
+ [99, 99, 99],
195
+ [101, 101, 101],
196
+ ],
197
+ extraClicks: [],
198
+ expectedCenter: [100, 100, 100],
199
+ expectedEdgeLengths: { x: 128, y: 128, z: 128 },
200
+ },
201
+ {
202
+ it: "returns base bounding box if extra clicks fit within the initial box",
203
+ baseClicks: [
204
+ [99, 99, 99],
205
+ [101, 101, 101],
206
+ ],
207
+ extraClicks: [
208
+ [100, 100, 100],
209
+ [102, 102, 102],
210
+ ],
211
+ expectedCenter: [100, 100, 100],
212
+ expectedEdgeLengths: { x: 128, y: 128, z: 128 },
213
+ },
214
+ {
215
+ it: "returns scaled box if base clicks don't fit minimums but extra clicks fit",
216
+ baseClicks: [
217
+ [50, 50, 50],
218
+ [150, 150, 150],
219
+ ],
220
+ extraClicks: [[100, 100, 100]],
221
+ expectedCenter: [100, 100, 100],
222
+ expectedEdgeLengths: { x: 190, y: 190, z: 190 },
223
+ },
224
+ {
225
+ it: "returns scaled box if base clicks fit minimums but extra clicks don't",
226
+ baseClicks: [
227
+ [99, 99, 99],
228
+ [101, 101, 101],
229
+ ],
230
+ extraClicks: [[99, 99, 250]],
231
+ expectedCenter: [100, 100, 100],
232
+ expectedEdgeLengths: { x: 316, y: 316, z: 316 },
233
+ },
234
+ ];
235
+ const hasOnly = testCases.some((test) => test.only);
236
+ testCases.forEach(
237
+ ({
238
+ it: name,
239
+ baseClicks,
240
+ extraClicks,
241
+ worldToVoxelAffine,
242
+ expectedCenter,
243
+ expectedEdgeLengths,
244
+ only,
245
+ }) => {
246
+ if (hasOnly && !only) {
247
+ return; // skip this test if there is an 'only' test case
248
+ }
249
+ it(name, () => {
250
+ const result = calculateInteractiveImeasureBoundingBox(
251
+ baseClicks,
252
+ extraClicks,
253
+ worldToVoxelAffine || identityMatrix
254
+ );
255
+ expect(result.center).toEqual(expectedCenter);
256
+ expect(result.edgeLengths).toEqual(expectedEdgeLengths);
257
+ });
258
+ }
259
+ );
260
+ });
@@ -10,37 +10,55 @@ import {
10
10
  TIMeasureCropRegion,
11
11
  } from "../types/crop";
12
12
 
13
+ const worldLengthAndAffineToCropCuboidDimensions = (
14
+ worldLength: number,
15
+ worldToVoxelAffine: mat4
16
+ ): [number, number, number] => {
17
+ const voxelToWorldAffine = invertAffineMatrix(worldToVoxelAffine);
18
+ // we effectively need to make the length of the measurement fit the crop region
19
+ // in all 3 dimensions.
20
+ const pixelSpacing = voxelToWorldAffine[0];
21
+ const sliceThickness = voxelToWorldAffine[10];
22
+ // calculate the length in voxel space
23
+ let xyLength = Math.max(
24
+ INTERACTIVE_IMEASURE_BASE.X,
25
+ Math.round(worldLength / pixelSpacing) +
26
+ INTERACTIVE_IMEASURE_BASE.PADDING * 2
27
+ );
28
+ if (xyLength % 2 !== 0) {
29
+ xyLength += 1; // ensure even number
30
+ }
31
+
32
+ let zLength = Math.max(
33
+ INTERACTIVE_IMEASURE_BASE.Z,
34
+ Math.round((xyLength * pixelSpacing) / sliceThickness)
35
+ );
36
+ if (zLength % 2 !== 0) {
37
+ zLength += 1; // ensure even number
38
+ }
39
+
40
+ return [xyLength, xyLength, zLength];
41
+ };
42
+
13
43
  // TODO: should expand scale based on z-axis input point diff.
14
44
  // Tests first.
15
45
  export const getInteractiveImeasureCropCuboidDimensions = (
16
46
  measurement: [Point3D, Point3D],
17
47
  worldToVoxelAffine: mat4
18
48
  ): number[] => {
19
- const [voxelStart, voxelEnd] = measurement.map((pt) =>
20
- roundPoint3(affineTranslateVec3(pt, worldToVoxelAffine))
21
- );
22
-
23
- const x_size =
24
- Math.abs(voxelStart[0] - voxelEnd[0]) +
25
- INTERACTIVE_IMEASURE_BASE.PADDING * 2;
26
- const y_size =
27
- Math.abs(voxelStart[1] - voxelEnd[1]) +
28
- INTERACTIVE_IMEASURE_BASE.PADDING * 2;
29
-
30
- let xyCubeSize = Math.max(x_size, y_size, INTERACTIVE_IMEASURE_BASE.X);
31
- // the model always expects an even number of voxels on each axis
32
- if (xyCubeSize % 2 !== 0) {
33
- xyCubeSize += 1;
34
- }
35
- const voxelToWorldAffine = invertAffineMatrix(worldToVoxelAffine);
36
- const maxDistanceBetweenPoints = Math.max(x_size, y_size);
49
+ const [worldStart, worldEnd] = measurement;
37
50
 
38
- const zLength = calculateZAxisLength(
39
- maxDistanceBetweenPoints,
40
- voxelToWorldAffine
51
+ // calculate the world-space length of the measurement
52
+ const worldLength = Math.sqrt(
53
+ Math.pow(worldEnd[0] - worldStart[0], 2) +
54
+ Math.pow(worldEnd[1] - worldStart[1], 2) +
55
+ Math.pow(worldEnd[2] - worldStart[2], 2)
41
56
  );
42
57
 
43
- return [xyCubeSize, xyCubeSize, zLength];
58
+ return worldLengthAndAffineToCropCuboidDimensions(
59
+ worldLength,
60
+ worldToVoxelAffine
61
+ );
44
62
  };
45
63
 
46
64
  const getCropCuboidFromCenterAndEdgeLengths = (
@@ -78,7 +96,7 @@ export const calculateInteractiveImeasureCropRegion = (
78
96
  ): TIMeasureCropRegion => {
79
97
  let center: Point3D;
80
98
  const singlePoint = worldBasePoints.length === 1;
81
-
99
+ let worldBaseLength = 0;
82
100
  if (singlePoint) {
83
101
  center = worldBasePoints[0];
84
102
  } else {
@@ -87,6 +105,11 @@ export const calculateInteractiveImeasureCropRegion = (
87
105
  (worldBasePoints[0][1] + worldBasePoints[1][1]) / 2,
88
106
  (worldBasePoints[0][2] + worldBasePoints[1][2]) / 2,
89
107
  ];
108
+ worldBaseLength = Math.sqrt(
109
+ Math.pow(worldBasePoints[1][0] - worldBasePoints[0][0], 2) +
110
+ Math.pow(worldBasePoints[1][1] - worldBasePoints[0][1], 2) +
111
+ Math.pow(worldBasePoints[1][2] - worldBasePoints[0][2], 2)
112
+ );
90
113
  }
91
114
 
92
115
  // we want to convert (and round) this to voxel coordinates
@@ -96,18 +119,10 @@ export const calculateInteractiveImeasureCropRegion = (
96
119
  );
97
120
 
98
121
  // if we only have a single click, use constants for edge lengths.
99
- let x_edge = INTERACTIVE_IMEASURE_BASE.X;
100
- let y_edge = INTERACTIVE_IMEASURE_BASE.Y;
101
- let z_edge = INTERACTIVE_IMEASURE_BASE.Z;
102
-
103
- if (!singlePoint) {
104
- // if we have two clicks, calculate the edges based on the points
105
- const tempProposedCuboidEdges = getInteractiveImeasureCropCuboidDimensions(
106
- worldBasePoints as [Point3D, Point3D],
107
- worldToVoxelAffine
108
- );
109
- [x_edge, y_edge, z_edge] = tempProposedCuboidEdges;
110
- }
122
+ const [x_edge, y_edge, z_edge] = worldLengthAndAffineToCropCuboidDimensions(
123
+ worldBaseLength,
124
+ worldToVoxelAffine
125
+ );
111
126
 
112
127
  const { cropCubePoints, croppingPlanes } =
113
128
  getCropCuboidFromCenterAndEdgeLengths(voxelSpaceCenter, {
@@ -142,8 +157,6 @@ export const calculateInteractiveImeasureBoundingBox = (
142
157
  extraClicks: Point3D[] = [],
143
158
  voxelToWorldAffine: mat4
144
159
  ): TIMeasureCropRegion => {
145
- const worldToVoxelAffine = invertAffineMatrix(voxelToWorldAffine);
146
-
147
160
  const {
148
161
  center,
149
162
  voxelSpaceCenter,
@@ -153,24 +166,22 @@ export const calculateInteractiveImeasureBoundingBox = (
153
166
  } = calculateInteractiveImeasureCropRegion(firstClicks, voxelToWorldAffine);
154
167
 
155
168
  let maxDistanceFromCenter = 0;
156
- const extraClicksInVoxelSpace = extraClicks.map((pt) =>
157
- roundPoint3(affineTranslateVec3(pt, worldToVoxelAffine))
158
- );
159
169
 
160
- extraClicksInVoxelSpace.forEach((point) => {
161
- maxDistanceFromCenter = Math.max(
162
- Math.abs(point[0] - voxelSpaceCenter[0]),
163
- Math.abs(point[1] - voxelSpaceCenter[1]),
164
- maxDistanceFromCenter
170
+ extraClicks.forEach((point) => {
171
+ const distanceFromCenter = Math.sqrt(
172
+ Math.pow(point[0] - center[0], 2) +
173
+ Math.pow(point[1] - center[1], 2) +
174
+ Math.pow(point[2] - center[2], 2)
165
175
  );
166
- // TODO: handle z-axis differences
176
+ maxDistanceFromCenter = Math.max(distanceFromCenter, maxDistanceFromCenter);
167
177
  });
168
178
 
169
- const halfEdgeLength =
170
- maxDistanceFromCenter + INTERACTIVE_IMEASURE_BASE.PADDING;
171
-
179
+ const newEdgeLengths = worldLengthAndAffineToCropCuboidDimensions(
180
+ maxDistanceFromCenter * 2,
181
+ voxelToWorldAffine
182
+ );
172
183
  // check if we need to expand the box at all?
173
- if (halfEdgeLength * 2 < edgeLengths.x) {
184
+ if (newEdgeLengths[0] <= edgeLengths.x) {
174
185
  return {
175
186
  center,
176
187
  voxelSpaceCenter,
@@ -179,23 +190,24 @@ export const calculateInteractiveImeasureBoundingBox = (
179
190
  edgeLengths,
180
191
  };
181
192
  }
182
- const zLength = calculateZAxisLength(halfEdgeLength * 2, voxelToWorldAffine);
183
-
184
- const newEdgeLengths = {
185
- x: halfEdgeLength * 2,
186
- y: halfEdgeLength * 2,
187
- z: zLength,
188
- };
189
193
  const {
190
194
  cropCubePoints: newCropCubePoints,
191
195
  croppingPlanes: newCroppingPlanes,
192
- } = getCropCuboidFromCenterAndEdgeLengths(voxelSpaceCenter, newEdgeLengths);
196
+ } = getCropCuboidFromCenterAndEdgeLengths(voxelSpaceCenter, {
197
+ x: newEdgeLengths[0],
198
+ y: newEdgeLengths[1],
199
+ z: newEdgeLengths[2],
200
+ });
193
201
  return {
194
202
  center,
195
203
  voxelSpaceCenter,
196
204
  cropCubePoints: newCropCubePoints,
197
205
  croppingPlanes: newCroppingPlanes,
198
- edgeLengths: newEdgeLengths,
206
+ edgeLengths: {
207
+ x: newEdgeLengths[0],
208
+ y: newEdgeLengths[1],
209
+ z: newEdgeLengths[2],
210
+ },
199
211
  };
200
212
  };
201
213