@bettermedicine/imeasure 0.0.5 → 0.0.6-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();
@@ -5,18 +5,39 @@ import { calculateZAxisLength, roundPoint3 } from "./utility";
5
5
  // Tests first.
6
6
  export const getInteractiveImeasureCropCuboidDimensions = (measurement, worldToVoxelAffine) => {
7
7
  const [voxelStart, voxelEnd] = measurement.map((pt) => roundPoint3(affineTranslateVec3(pt, worldToVoxelAffine)));
8
+ const voxelToWorldAffine = invertAffineMatrix(worldToVoxelAffine);
8
9
  const x_size = Math.abs(voxelStart[0] - voxelEnd[0]) +
9
10
  INTERACTIVE_IMEASURE_BASE.PADDING * 2;
10
11
  const y_size = Math.abs(voxelStart[1] - voxelEnd[1]) +
11
12
  INTERACTIVE_IMEASURE_BASE.PADDING * 2;
13
+ // if the z-axis distance between the two points is not null, we need to take
14
+ // z-axis length into account as well.
15
+ const zSizeWithoutPadding = Math.abs(voxelStart[2] - voxelEnd[2]);
16
+ const maxDistanceBetweenPointsAxially = Math.max(x_size, y_size);
17
+ const zLength = calculateZAxisLength(maxDistanceBetweenPointsAxially, voxelToWorldAffine);
18
+ if (zSizeWithoutPadding !== 0) {
19
+ const pixelSpacing = voxelToWorldAffine[0];
20
+ const sliceThickness = voxelToWorldAffine[10];
21
+ // lets start by calculating the proportion between x/y pixel spacing and
22
+ // series slice thickness.
23
+ const proportion = sliceThickness / pixelSpacing;
24
+ // then find the z-axis length based on the largest axis
25
+ // lets take z-axis length and scale it to x/y axis length
26
+ const z_size = Math.max(zSizeWithoutPadding + INTERACTIVE_IMEASURE_BASE.PADDING * 2, INTERACTIVE_IMEASURE_BASE.Z);
27
+ const zSizeScaledWithoutPadding = Math.round(zSizeWithoutPadding * proportion);
28
+ const zSizeScaledPadded = zSizeScaledWithoutPadding + 2 * INTERACTIVE_IMEASURE_BASE.PADDING;
29
+ return [
30
+ Math.max(x_size, zSizeScaledPadded, INTERACTIVE_IMEASURE_BASE.X),
31
+ Math.max(y_size, zSizeScaledPadded, INTERACTIVE_IMEASURE_BASE.Y),
32
+ Math.max(z_size, zLength),
33
+ ];
34
+ }
12
35
  let xyCubeSize = Math.max(x_size, y_size, INTERACTIVE_IMEASURE_BASE.X);
13
36
  // the model always expects an even number of voxels on each axis
14
37
  if (xyCubeSize % 2 !== 0) {
15
38
  xyCubeSize += 1;
16
39
  }
17
- const voxelToWorldAffine = invertAffineMatrix(worldToVoxelAffine);
18
- const maxDistanceBetweenPoints = Math.max(x_size, y_size);
19
- const zLength = calculateZAxisLength(maxDistanceBetweenPoints, voxelToWorldAffine);
40
+ console.log("zLength", zLength);
20
41
  return [xyCubeSize, xyCubeSize, zLength];
21
42
  };
22
43
  const getCropCuboidFromCenterAndEdgeLengths = (voxelSpaceCenter, edgeLengths) => {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,119 @@
1
+ import { mat4 } from "gl-matrix";
2
+ import { it, describe, expect } from "vitest";
3
+ import { getInteractiveImeasureCropCuboidDimensions } from "./cropping";
4
+ describe("getInteractiveImeasureCropCuboidDimensions", () => {
5
+ const identityMatrix = mat4.fromValues(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
6
+ // a matrix where the pixel spacing is 1mm and slice thickness is 2mm
7
+ // since these are world-to-voxel affine matrices, the 0.5 indicates
8
+ // that for every z-axis millimeter in the world we move 0.5 units in voxel space
9
+ const thickSlicesMatrix = mat4.fromValues(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0.5, // slice thickness
10
+ 0, 0, 0, 0, 1);
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, 32],
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, 116],
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, 116],
48
+ },
49
+ {
50
+ it: "properly scales z-axis when movement happens in all axes",
51
+ measurement: [
52
+ [0, 0, 0],
53
+ [100, 100, 100],
54
+ ],
55
+ expectedDimensions: [128, 128, 116],
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: [216, 216, 216],
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, 66],
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
+ // 200/2 + 2*padding = 100 + 16 = 116
82
+ expectedDimensions: [216, 216, 116],
83
+ matrix: thickSlicesMatrix,
84
+ },
85
+ {
86
+ it: "properly scales all axes with thick slices",
87
+ measurement: [
88
+ [0, 0, 0],
89
+ [200, 200, 200],
90
+ ],
91
+ // 200/2 + 2*padding = 100 + 16 = 116
92
+ expectedDimensions: [216, 216, 116],
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: [216, 216, 108],
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettermedicine/imeasure",
3
- "version": "0.0.5",
3
+ "version": "0.0.6-0",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "prepack": "npm run build",
@@ -0,0 +1,168 @@
1
+ import { mat4 } from "gl-matrix";
2
+ import { it, describe, expect } from "vitest";
3
+ import { Point3D } from "../types/cornerstone";
4
+ import { getInteractiveImeasureCropCuboidDimensions } from "./cropping";
5
+
6
+ type TCropTestCase = {
7
+ it: string;
8
+ measurement: [[number, number, number], [number, number, number]];
9
+ expectedDimensions: [number, number, number];
10
+ matrix?: mat4; // an undefined matrix will default to identity
11
+ only?: true; // only run this test case - useful for debugging
12
+ };
13
+
14
+ describe("getInteractiveImeasureCropCuboidDimensions", () => {
15
+ const identityMatrix = mat4.fromValues(
16
+ 1,
17
+ 0,
18
+ 0,
19
+ 0,
20
+ 0,
21
+ 1,
22
+ 0,
23
+ 0,
24
+ 0,
25
+ 0,
26
+ 1,
27
+ 0,
28
+ 0,
29
+ 0,
30
+ 0,
31
+ 1
32
+ );
33
+ // a matrix where the pixel spacing is 1mm and slice thickness is 2mm
34
+ // since these are world-to-voxel affine matrices, the 0.5 indicates
35
+ // that for every z-axis millimeter in the world we move 0.5 units in voxel space
36
+ const thickSlicesMatrix = mat4.fromValues(
37
+ 1,
38
+ 0,
39
+ 0,
40
+ 0,
41
+ 0,
42
+ 1,
43
+ 0,
44
+ 0,
45
+ 0,
46
+ 0,
47
+ 0.5, // slice thickness
48
+ 0,
49
+ 0,
50
+ 0,
51
+ 0,
52
+ 1
53
+ );
54
+
55
+ const testCases: TCropTestCase[] = [
56
+ {
57
+ it: "returns expected minimums in case of a very short base measurement",
58
+ measurement: [
59
+ [100, 100, 256],
60
+ [101, 101, 256],
61
+ ],
62
+ expectedDimensions: [128, 128, 32],
63
+ },
64
+ {
65
+ it: "returns expected dimensions for a larger measurement",
66
+ measurement: [
67
+ [0, 100, 256],
68
+ [0, 0, 256],
69
+ ],
70
+ // the length of the line in the largest axis (X) is 100, so the z-axis length
71
+ // is 100 + 2*padding = 116 but x/y dimensions are not scaled as they still fit
72
+ // the minimum size of 128
73
+ expectedDimensions: [128, 128, 116],
74
+ },
75
+ {
76
+ it: "returns expected dimensions that extend past minimums in x/y axes",
77
+ measurement: [
78
+ [0, 200, 256],
79
+ [0, 0, 256],
80
+ ],
81
+ expectedDimensions: [216, 216, 216],
82
+ },
83
+ {
84
+ it: "properly scales z-axis even if x/y movement is minimal",
85
+ measurement: [
86
+ [0, 0, 0],
87
+ [0, 0, 100],
88
+ ],
89
+ // length of the line in the largest axis is 100 so z-axis of crop cuboid
90
+ // should scale accordingly
91
+ expectedDimensions: [128, 128, 116],
92
+ },
93
+ {
94
+ it: "properly scales z-axis when movement happens in all axes",
95
+ measurement: [
96
+ [0, 0, 0],
97
+ [100, 100, 100],
98
+ ],
99
+ expectedDimensions: [128, 128, 116],
100
+ },
101
+ {
102
+ it: "properly scales all axes when movement happens in all axes and doesn't fit minimums",
103
+ measurement: [
104
+ [0, 0, 0],
105
+ [200, 200, 200],
106
+ ],
107
+ expectedDimensions: [216, 216, 216],
108
+ },
109
+ {
110
+ it: "properly scales z-axis with minimal x/y movement and thick slices",
111
+ measurement: [
112
+ [0, 0, 0],
113
+ [0, 0, 100],
114
+ ],
115
+ // 100/2 + 2*padding = 50 + 16 = 66
116
+ expectedDimensions: [128, 128, 66],
117
+ matrix: thickSlicesMatrix,
118
+ },
119
+ {
120
+ it: "properly scales z-axis with minimal x/y movement and thick slices when z-axis doesn't fit minimums",
121
+ measurement: [
122
+ [0, 0, 0],
123
+ [0, 0, 200],
124
+ ],
125
+ // 200/2 + 2*padding = 100 + 16 = 116
126
+ expectedDimensions: [216, 216, 116],
127
+ matrix: thickSlicesMatrix,
128
+ },
129
+ {
130
+ it: "properly scales all axes with thick slices",
131
+ measurement: [
132
+ [0, 0, 0],
133
+ [200, 200, 200],
134
+ ],
135
+ // 200/2 + 2*padding = 100 + 16 = 116
136
+ expectedDimensions: [216, 216, 116],
137
+ matrix: thickSlicesMatrix,
138
+ },
139
+ {
140
+ it: "properly scales all axes if z-axis diff is minimal but not null",
141
+ measurement: [
142
+ [0, 0, 0],
143
+ [200, 200, 100],
144
+ ],
145
+ // whereas a 100mm movement in z-axis would result in 66 voxels of cuboid length,
146
+ // we _are_ still trying to keep it cuboid shaped - since the naive z-axis calculation
147
+ // of `max(x_length, y_length) * proportion` yields 108 voxels, we use that as it
148
+ // is bigger and thus more likely to fit a spherical shape.
149
+ expectedDimensions: [216, 216, 108],
150
+ matrix: thickSlicesMatrix,
151
+ },
152
+ ];
153
+ const hasOnly = testCases.some((test) => test.only);
154
+ testCases.forEach(
155
+ ({ it: name, measurement, expectedDimensions, matrix, only }) => {
156
+ if (hasOnly && !only) {
157
+ return; // skip this test if there is an 'only' test case
158
+ }
159
+ it(name, () => {
160
+ const dims = getInteractiveImeasureCropCuboidDimensions(
161
+ measurement,
162
+ matrix || identityMatrix
163
+ );
164
+ expect(dims).toEqual(expectedDimensions);
165
+ });
166
+ }
167
+ );
168
+ });
@@ -19,6 +19,7 @@ export const getInteractiveImeasureCropCuboidDimensions = (
19
19
  const [voxelStart, voxelEnd] = measurement.map((pt) =>
20
20
  roundPoint3(affineTranslateVec3(pt, worldToVoxelAffine))
21
21
  );
22
+ const voxelToWorldAffine = invertAffineMatrix(worldToVoxelAffine);
22
23
 
23
24
  const x_size =
24
25
  Math.abs(voxelStart[0] - voxelEnd[0]) +
@@ -26,19 +27,51 @@ export const getInteractiveImeasureCropCuboidDimensions = (
26
27
  const y_size =
27
28
  Math.abs(voxelStart[1] - voxelEnd[1]) +
28
29
  INTERACTIVE_IMEASURE_BASE.PADDING * 2;
30
+ // if the z-axis distance between the two points is not null, we need to take
31
+ // z-axis length into account as well.
32
+ const zSizeWithoutPadding = Math.abs(voxelStart[2] - voxelEnd[2]);
33
+
34
+ const maxDistanceBetweenPointsAxially = Math.max(x_size, y_size);
35
+
36
+ const zLength = calculateZAxisLength(
37
+ maxDistanceBetweenPointsAxially,
38
+ voxelToWorldAffine
39
+ );
40
+
41
+ if (zSizeWithoutPadding !== 0) {
42
+ const pixelSpacing = voxelToWorldAffine[0];
43
+ const sliceThickness = voxelToWorldAffine[10];
44
+ // lets start by calculating the proportion between x/y pixel spacing and
45
+ // series slice thickness.
46
+ const proportion = sliceThickness / pixelSpacing;
47
+ // then find the z-axis length based on the largest axis
48
+
49
+ // lets take z-axis length and scale it to x/y axis length
50
+ const z_size = Math.max(
51
+ zSizeWithoutPadding + INTERACTIVE_IMEASURE_BASE.PADDING * 2,
52
+ INTERACTIVE_IMEASURE_BASE.Z
53
+ );
54
+
55
+ const zSizeScaledWithoutPadding = Math.round(
56
+ zSizeWithoutPadding * proportion
57
+ );
58
+
59
+ const zSizeScaledPadded =
60
+ zSizeScaledWithoutPadding + 2 * INTERACTIVE_IMEASURE_BASE.PADDING;
61
+ return [
62
+ Math.max(x_size, zSizeScaledPadded, INTERACTIVE_IMEASURE_BASE.X),
63
+ Math.max(y_size, zSizeScaledPadded, INTERACTIVE_IMEASURE_BASE.Y),
64
+ Math.max(z_size, zLength),
65
+ ];
66
+ }
29
67
 
30
68
  let xyCubeSize = Math.max(x_size, y_size, INTERACTIVE_IMEASURE_BASE.X);
31
69
  // the model always expects an even number of voxels on each axis
32
70
  if (xyCubeSize % 2 !== 0) {
33
71
  xyCubeSize += 1;
34
72
  }
35
- const voxelToWorldAffine = invertAffineMatrix(worldToVoxelAffine);
36
- const maxDistanceBetweenPoints = Math.max(x_size, y_size);
37
73
 
38
- const zLength = calculateZAxisLength(
39
- maxDistanceBetweenPoints,
40
- voxelToWorldAffine
41
- );
74
+ console.log("zLength", zLength);
42
75
 
43
76
  return [xyCubeSize, xyCubeSize, zLength];
44
77
  };