voidspace 0.1.3__tar.gz → 0.1.4__tar.gz

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.
Files changed (28) hide show
  1. {voidspace-0.1.3/src/voidspace.egg-info → voidspace-0.1.4}/PKG-INFO +18 -5
  2. {voidspace-0.1.3 → voidspace-0.1.4}/README.md +17 -4
  3. {voidspace-0.1.3 → voidspace-0.1.4}/pyproject.toml +1 -1
  4. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/__init__.py +1 -1
  5. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/morphology.py +5 -7
  6. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/segment.py +16 -3
  7. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/workflows.py +14 -16
  8. {voidspace-0.1.3 → voidspace-0.1.4/src/voidspace.egg-info}/PKG-INFO +18 -5
  9. {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_segment.py +28 -1
  10. {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_workflows.py +42 -0
  11. {voidspace-0.1.3 → voidspace-0.1.4}/LICENSE +0 -0
  12. {voidspace-0.1.3 → voidspace-0.1.4}/setup.cfg +0 -0
  13. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/change.py +0 -0
  14. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/cli.py +0 -0
  15. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/io.py +0 -0
  16. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/metrics.py +0 -0
  17. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace/models.py +0 -0
  18. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace.egg-info/SOURCES.txt +0 -0
  19. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace.egg-info/dependency_links.txt +0 -0
  20. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace.egg-info/entry_points.txt +0 -0
  21. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace.egg-info/requires.txt +0 -0
  22. {voidspace-0.1.3 → voidspace-0.1.4}/src/voidspace.egg-info/top_level.txt +0 -0
  23. {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_aim_io.py +0 -0
  24. {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_change.py +0 -0
  25. {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_cli.py +0 -0
  26. {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_metrics.py +0 -0
  27. {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_models.py +0 -0
  28. {voidspace-0.1.3 → voidspace-0.1.4}/tests/test_morphology.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voidspace
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Spacing-aware voidspace analysis for HR-pQCT segmentations
5
5
  Author: Matthias Walle
6
6
  License-Expression: MIT
@@ -36,8 +36,10 @@ Dynamic: license-file
36
36
  Spacing-aware voidspace analysis for HR-pQCT segmentations.
37
37
 
38
38
  `voidspace` is a standalone logic package. It does not segment bone, register
39
- scans, create common regions, or resample images. It expects inputs that are
40
- already in the image space to analyze.
39
+ scans or create common regions. It expects inputs already in the same physical
40
+ space. File workflows place cropped masks on the reference grid with
41
+ nearest-neighbor resampling using origin, spacing, and direction; this does not
42
+ estimate registration between scans.
41
43
 
42
44
  The command line interface reads common SimpleITK image formats and Scanco AIM
43
45
  masks. AIM masks are read in native scaling, interpreted as foreground where
@@ -55,8 +57,19 @@ nonzero, and written back as native binary AIM masks using the input geometry.
55
57
 
56
58
  When no periosteal mask is supplied, the segmentation-only workflow estimates
57
59
  the analysis domain from the closed segmentation by excluding background
58
- connected to the image border. Supplying an explicit mask remains preferred
59
- when that contour is available.
60
+ connected to lateral (in-plane) image borders, not the two scan ends. Supplying
61
+ an explicit full bone mask remains preferred, especially for breached cortex.
62
+
63
+ Closing and erosion use edge continuation along the scan axis before cropping
64
+ back to the acquired volume. This assumes the endpoint cross-sections continue
65
+ beyond the scan, rather than treating acquisition ends as empty biological space.
66
+ Physical morphology radii and volume thresholds are unchanged. This boundary
67
+ policy is not a validation of exact scanner/IPL equivalence.
68
+
69
+ All voidspace includes large voidspace: the large map is a size-filtered subset.
70
+ For common-region reporting, compute maps on the full bone domain with `run-case`,
71
+ then use `analyze-maps` to clip and measure without recomputing morphology at the
72
+ common-region boundaries. Force-rerun old outputs to apply these corrections.
60
73
 
61
74
  ## Algorithm
62
75
 
@@ -3,8 +3,10 @@
3
3
  Spacing-aware voidspace analysis for HR-pQCT segmentations.
4
4
 
5
5
  `voidspace` is a standalone logic package. It does not segment bone, register
6
- scans, create common regions, or resample images. It expects inputs that are
7
- already in the image space to analyze.
6
+ scans or create common regions. It expects inputs already in the same physical
7
+ space. File workflows place cropped masks on the reference grid with
8
+ nearest-neighbor resampling using origin, spacing, and direction; this does not
9
+ estimate registration between scans.
8
10
 
9
11
  The command line interface reads common SimpleITK image formats and Scanco AIM
10
12
  masks. AIM masks are read in native scaling, interpreted as foreground where
@@ -22,8 +24,19 @@ nonzero, and written back as native binary AIM masks using the input geometry.
22
24
 
23
25
  When no periosteal mask is supplied, the segmentation-only workflow estimates
24
26
  the analysis domain from the closed segmentation by excluding background
25
- connected to the image border. Supplying an explicit mask remains preferred
26
- when that contour is available.
27
+ connected to lateral (in-plane) image borders, not the two scan ends. Supplying
28
+ an explicit full bone mask remains preferred, especially for breached cortex.
29
+
30
+ Closing and erosion use edge continuation along the scan axis before cropping
31
+ back to the acquired volume. This assumes the endpoint cross-sections continue
32
+ beyond the scan, rather than treating acquisition ends as empty biological space.
33
+ Physical morphology radii and volume thresholds are unchanged. This boundary
34
+ policy is not a validation of exact scanner/IPL equivalence.
35
+
36
+ All voidspace includes large voidspace: the large map is a size-filtered subset.
37
+ For common-region reporting, compute maps on the full bone domain with `run-case`,
38
+ then use `analyze-maps` to clip and measure without recomputing morphology at the
39
+ common-region boundaries. Force-rerun old outputs to apply these corrections.
27
40
 
28
41
  ## Algorithm
29
42
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "voidspace"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Spacing-aware voidspace analysis for HR-pQCT segmentations"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -12,7 +12,7 @@ from voidspace.models import (
12
12
  from voidspace.segment import segment_voidspace
13
13
  from voidspace.workflows import analyze_maps, compare, intersect_masks, run_case
14
14
 
15
- __version__ = "0.1.3"
15
+ __version__ = "0.1.4"
16
16
 
17
17
  __all__ = [
18
18
  "VoidspaceChangeMasks",
@@ -66,7 +66,7 @@ def remove_small_components(mask: np.ndarray, min_voxels: int, connectivity: int
66
66
  return keep[labels]
67
67
 
68
68
 
69
- def remove_border_connected_components(mask: np.ndarray, connectivity: int = 3) -> np.ndarray:
69
+ def remove_border_connected_components(mask: np.ndarray, connectivity: int = 3, *, axes=(0, 1, 2)) -> np.ndarray:
70
70
  mask = np.asarray(mask, dtype=bool)
71
71
  if mask.ndim != 3:
72
72
  raise ValueError("mask must be a 3D array")
@@ -75,12 +75,10 @@ def remove_border_connected_components(mask: np.ndarray, connectivity: int = 3)
75
75
  if count == 0:
76
76
  return np.zeros_like(mask, dtype=bool)
77
77
 
78
- border_labels = set(np.unique(labels[0, :, :]))
79
- border_labels.update(np.unique(labels[-1, :, :]))
80
- border_labels.update(np.unique(labels[:, 0, :]))
81
- border_labels.update(np.unique(labels[:, -1, :]))
82
- border_labels.update(np.unique(labels[:, :, 0]))
83
- border_labels.update(np.unique(labels[:, :, -1]))
78
+ border_labels = set()
79
+ for axis in axes:
80
+ border_labels.update(np.unique(np.take(labels, 0, axis=axis)))
81
+ border_labels.update(np.unique(np.take(labels, -1, axis=axis)))
84
82
  border_labels.discard(0)
85
83
  remove = np.zeros(count + 1, dtype=bool)
86
84
  if border_labels:
@@ -25,6 +25,17 @@ def _validate_same_shape(*arrays: np.ndarray) -> None:
25
25
  raise ValueError(f"all masks must have the same shape, got {sorted(shapes)}")
26
26
 
27
27
 
28
+ def _continue_scan_ends(mask, footprint, operation):
29
+ """Continue the endpoint slices, without continuing lateral exterior background."""
30
+ radius = footprint.shape[0] // 2
31
+ if radius == 0:
32
+ return operation(mask, structure=footprint, border_value=0)
33
+ # Closing contains a dilation and an erosion; retain both operations' support.
34
+ padding = 2 * radius
35
+ padded = np.pad(mask, ((padding, padding), (0, 0), (0, 0)), mode="edge")
36
+ return operation(padded, structure=footprint, border_value=0)[padding:-padding]
37
+
38
+
28
39
  def segment_voidspace(
29
40
  segmentation: np.ndarray,
30
41
  spacing_mm: tuple[float, float, float],
@@ -35,7 +46,7 @@ def segment_voidspace(
35
46
  bone = _validate_3d("segmentation", segmentation)
36
47
  if mask is None:
37
48
  domain = np.ones_like(bone, dtype=bool)
38
- domain_source = "segmentation_border_background"
49
+ domain_source = "segmentation_lateral_background"
39
50
  else:
40
51
  domain = _validate_3d("mask", mask)
41
52
  _validate_same_shape(bone, domain)
@@ -47,11 +58,12 @@ def segment_voidspace(
47
58
  connectivity=params.connectivity,
48
59
  )
49
60
  closing_fp = ellipsoid_footprint(params.closing_radius_mm, spacing_mm)
50
- filled_bone = ndi.binary_closing(bone_in_domain, structure=closing_fp) & domain
61
+ filled_bone = _continue_scan_ends(bone_in_domain, closing_fp, ndi.binary_closing) & domain
51
62
  if mask is None:
52
63
  interior_background = remove_border_connected_components(
53
64
  ~filled_bone,
54
65
  connectivity=params.connectivity,
66
+ axes=(1, 2),
55
67
  )
56
68
  domain = filled_bone | interior_background
57
69
  filled_bone = filled_bone & domain
@@ -59,7 +71,7 @@ def segment_voidspace(
59
71
 
60
72
  if params.boundary_erosion_radius_mm > 0:
61
73
  erosion_fp = ellipsoid_footprint(params.boundary_erosion_radius_mm, spacing_mm)
62
- candidate_void = ndi.binary_erosion(candidate_void, structure=erosion_fp, border_value=0)
74
+ candidate_void = _continue_scan_ends(candidate_void, erosion_fp, ndi.binary_erosion)
63
75
  candidate_void = candidate_void & domain
64
76
 
65
77
  all_void = remove_small_components(
@@ -81,5 +93,6 @@ def segment_voidspace(
81
93
  "min_large_void_volume_mm3": params.min_large_void_volume_mm3,
82
94
  "structuring_element": "spacing_aware_ellipsoid",
83
95
  "domain_source": domain_source,
96
+ "scan_end_policy": "edge_continuation",
84
97
  },
85
98
  )
@@ -34,7 +34,10 @@ def _reference_image(reference) -> sitk.Image:
34
34
  def _array_on_reference_grid(array, source_reference, target_reference) -> np.ndarray:
35
35
  source_image = _reference_image(source_reference)
36
36
  target_image = _reference_image(target_reference)
37
- if source_image.GetSize() == target_image.GetSize() and source_image.GetOrigin() == target_image.GetOrigin():
37
+ if source_image.GetSize() == target_image.GetSize() and all(
38
+ np.allclose(getattr(source_image, getter)(), getattr(target_image, getter)(), rtol=0, atol=1e-6)
39
+ for getter in ("GetOrigin", "GetSpacing", "GetDirection")
40
+ ):
38
41
  return np.asarray(array, dtype=bool)
39
42
  image = sitk.GetImageFromArray(np.asarray(array, dtype=np.uint8))
40
43
  image.CopyInformation(source_image)
@@ -98,9 +101,8 @@ def run_case(
98
101
  raise FileExistsError(f"output already exists: {existing_outputs[0]}")
99
102
 
100
103
  if mask_path is not None:
101
- domain_mask, mask_spacing, _mask_reference = read_mask(mask_path)
102
- if mask_spacing != spacing or domain_mask.shape != segmentation.shape:
103
- raise ValueError("mask must be in the same space as segmentation")
104
+ domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
105
+ domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
104
106
  else:
105
107
  domain_mask = None
106
108
 
@@ -137,12 +139,10 @@ def analyze_maps(
137
139
  if existing_outputs and not force:
138
140
  raise FileExistsError(f"output already exists: {existing_outputs[0]}")
139
141
 
140
- all_void, all_spacing, _all_reference = read_mask(all_void_path)
141
- if all_spacing != spacing or all_void.shape != large_void.shape:
142
- raise ValueError("large and all void masks must be in the same space")
143
- domain_mask, mask_spacing, _mask_reference = read_mask(mask_path)
144
- if mask_spacing != spacing or domain_mask.shape != large_void.shape:
145
- raise ValueError("mask must be aligned with the void masks")
142
+ all_void, _all_spacing, all_reference = read_mask(all_void_path)
143
+ all_void = _array_on_reference_grid(all_void, all_reference, reference)
144
+ domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
145
+ domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
146
146
 
147
147
  masked_large_void = large_void & domain_mask
148
148
  masked_all_void = all_void & domain_mask
@@ -177,15 +177,13 @@ def compare(
177
177
  if existing_outputs and not force:
178
178
  raise FileExistsError(f"output already exists: {existing_outputs[0]}")
179
179
 
180
- followup, followup_spacing, _followup_reference = read_mask(followup_void_path)
181
- if followup_spacing != spacing or followup.shape != baseline.shape:
182
- raise ValueError("baseline and followup void masks must already be aligned")
180
+ followup, _followup_spacing, followup_reference = read_mask(followup_void_path)
181
+ followup = _array_on_reference_grid(followup, followup_reference, reference)
183
182
 
184
183
  domain_mask = None
185
184
  if mask_path is not None:
186
- domain_mask, mask_spacing, _mask_reference = read_mask(mask_path)
187
- if mask_spacing != spacing or domain_mask.shape != baseline.shape:
188
- raise ValueError("mask must be aligned with the void masks")
185
+ domain_mask, _mask_spacing, mask_reference = read_mask(mask_path)
186
+ domain_mask = _array_on_reference_grid(domain_mask, mask_reference, reference)
189
187
 
190
188
  change = classify_voidspace_change(baseline, followup, mask=domain_mask)
191
189
  write_mask_like(change.stable, reference, stable_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voidspace
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Spacing-aware voidspace analysis for HR-pQCT segmentations
5
5
  Author: Matthias Walle
6
6
  License-Expression: MIT
@@ -36,8 +36,10 @@ Dynamic: license-file
36
36
  Spacing-aware voidspace analysis for HR-pQCT segmentations.
37
37
 
38
38
  `voidspace` is a standalone logic package. It does not segment bone, register
39
- scans, create common regions, or resample images. It expects inputs that are
40
- already in the image space to analyze.
39
+ scans or create common regions. It expects inputs already in the same physical
40
+ space. File workflows place cropped masks on the reference grid with
41
+ nearest-neighbor resampling using origin, spacing, and direction; this does not
42
+ estimate registration between scans.
41
43
 
42
44
  The command line interface reads common SimpleITK image formats and Scanco AIM
43
45
  masks. AIM masks are read in native scaling, interpreted as foreground where
@@ -55,8 +57,19 @@ nonzero, and written back as native binary AIM masks using the input geometry.
55
57
 
56
58
  When no periosteal mask is supplied, the segmentation-only workflow estimates
57
59
  the analysis domain from the closed segmentation by excluding background
58
- connected to the image border. Supplying an explicit mask remains preferred
59
- when that contour is available.
60
+ connected to lateral (in-plane) image borders, not the two scan ends. Supplying
61
+ an explicit full bone mask remains preferred, especially for breached cortex.
62
+
63
+ Closing and erosion use edge continuation along the scan axis before cropping
64
+ back to the acquired volume. This assumes the endpoint cross-sections continue
65
+ beyond the scan, rather than treating acquisition ends as empty biological space.
66
+ Physical morphology radii and volume thresholds are unchanged. This boundary
67
+ policy is not a validation of exact scanner/IPL equivalence.
68
+
69
+ All voidspace includes large voidspace: the large map is a size-filtered subset.
70
+ For common-region reporting, compute maps on the full bone domain with `run-case`,
71
+ then use `analyze-maps` to clip and measure without recomputing morphology at the
72
+ common-region boundaries. Force-rerun old outputs to apply these corrections.
60
73
 
61
74
  ## Algorithm
62
75
 
@@ -3,6 +3,33 @@ import numpy as np
3
3
  from voidspace import VoidspaceParameters, segment_voidspace
4
4
 
5
5
 
6
+ def _open_tube():
7
+ yy, xx = np.indices((65, 65))
8
+ radius = np.sqrt((xx - 32) ** 2 + (yy - 32) ** 2)
9
+ full = np.broadcast_to(radius < 25, (31, 65, 65)).copy()
10
+ cavity = np.broadcast_to(radius < 15, full.shape)
11
+ params = VoidspaceParameters(closing_radius_mm=1, boundary_erosion_radius_mm=1,
12
+ min_large_void_volume_mm3=10,
13
+ bone_speckle_min_voxels=1, void_speckle_min_voxels=1)
14
+ return full & ~cavity, full, params
15
+
16
+
17
+ def test_open_end_void_survives_without_explicit_domain():
18
+ bone, full, params = _open_tube()
19
+ result = segment_voidspace(bone, (1, 1, 1), parameters=params)
20
+ assert result.large_void[:, 32, 32].all()
21
+ assert not result.all_void[~full].any()
22
+ assert not np.any(result.large_void & ~result.all_void)
23
+
24
+
25
+ def test_end_slices_match_interior_for_uniform_tube():
26
+ bone, full, params = _open_tube()
27
+ result = segment_voidspace(bone, (1, 1, 1), mask=full, parameters=params)
28
+ assert np.array_equal(result.filled_bone[0], result.filled_bone[15])
29
+ assert np.array_equal(result.all_void[0], result.all_void[15])
30
+ assert np.array_equal(result.all_void[-1], result.all_void[15])
31
+
32
+
6
33
  def _solid_cube_with_cavity(size=31, cavity_radius=4):
7
34
  bone = np.zeros((size, size, size), dtype=bool)
8
35
  bone[4:-4, 4:-4, 4:-4] = True
@@ -65,6 +92,6 @@ def test_segment_voidspace_derives_internal_domain_without_periosteal_mask():
65
92
 
66
93
  result = segment_voidspace(bone, (0.061, 0.061, 0.061), parameters=params)
67
94
 
68
- assert result.metadata["domain_source"] == "segmentation_border_background"
95
+ assert result.metadata["domain_source"] == "segmentation_lateral_background"
69
96
  assert result.large_void[cavity].any()
70
97
  assert not result.all_void[~peri].any()
@@ -2,6 +2,23 @@ import numpy as np
2
2
  import SimpleITK as sitk
3
3
 
4
4
  from voidspace import VoidspaceParameters, analyze_maps, compare, intersect_masks, run_case
5
+ import pytest
6
+
7
+
8
+ @pytest.mark.parametrize("size, origin", [(3, (1, 1, 1)), (5, (1, 0, 0))])
9
+ def test_run_case_places_analysis_mask_on_segmentation_grid(tmp_path, size, origin):
10
+ segmentation = np.zeros((5, 5, 5), dtype=np.uint8)
11
+ _write_mask(tmp_path / "seg.nii.gz", segmentation)
12
+ mask_image = sitk.GetImageFromArray(np.ones((size,) * 3, dtype=np.uint8))
13
+ mask_image.SetOrigin(origin)
14
+ sitk.WriteImage(mask_image, str(tmp_path / "domain.nii.gz"))
15
+ result = run_case(segmentation_path=tmp_path / "seg.nii.gz",
16
+ mask_path=tmp_path / "domain.nii.gz", output_dir=tmp_path / "out",
17
+ parameters=VoidspaceParameters(closing_radius_mm=0, boundary_erosion_radius_mm=0,
18
+ min_large_void_volume_mm3=0, bone_speckle_min_voxels=1, void_speckle_min_voxels=1))
19
+ assert result.metrics.total_volume_mm3 == (27 if size == 3 else 100)
20
+ output = sitk.GetArrayFromImage(sitk.ReadImage(str(result.all_mask_path)))
21
+ assert not output[:, :, 0].any()
5
22
 
6
23
 
7
24
  def _write_mask(path, array):
@@ -119,3 +136,28 @@ def test_intersect_masks_resamples_cropped_mask_to_reference_grid(tmp_path):
119
136
  expected[1:4, 1:4, 1:4] = True
120
137
  assert image.GetSize() == (5, 5, 5)
121
138
  assert np.array_equal(result, expected)
139
+
140
+
141
+ def test_analyze_maps_honors_direction_with_matching_sizes_and_origins(tmp_path):
142
+ full = np.ones((5, 5, 5), dtype=bool)
143
+ _write_mask(tmp_path / "all.nii.gz", full)
144
+ _write_mask(tmp_path / "large.nii.gz", full)
145
+ mask = sitk.GetImageFromArray(full.astype(np.uint8))
146
+ mask.SetDirection((-1, 0, 0, 0, 1, 0, 0, 0, 1))
147
+ sitk.WriteImage(mask, str(tmp_path / "domain.nii.gz"))
148
+ result = analyze_maps(large_void_path=tmp_path / "large.nii.gz", all_void_path=tmp_path / "all.nii.gz",
149
+ mask_path=tmp_path / "domain.nii.gz", output_dir=tmp_path / "out")
150
+ assert result.metrics.total_volume_mm3 == 25
151
+
152
+
153
+ def test_compare_places_cropped_maps_in_same_physical_space(tmp_path):
154
+ baseline = np.zeros((5, 5, 5), dtype=bool)
155
+ baseline[2, 2, 2] = True
156
+ _write_mask(tmp_path / "baseline.nii.gz", baseline)
157
+ cropped = sitk.GetImageFromArray(np.ones((1, 1, 1), dtype=np.uint8))
158
+ cropped.SetOrigin((2, 2, 2))
159
+ sitk.WriteImage(cropped, str(tmp_path / "followup.nii.gz"))
160
+ result = compare(baseline_void_path=tmp_path / "baseline.nii.gz",
161
+ followup_void_path=tmp_path / "followup.nii.gz", output_dir=tmp_path / "out")
162
+ assert result.metrics.stable_volume_mm3 == 1
163
+ assert result.metrics.expanded_volume_mm3 == result.metrics.contracted_volume_mm3 == 0
File without changes
File without changes
File without changes
File without changes