segresnet-mlx 0.2.2__tar.gz → 0.2.3__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 (21) hide show
  1. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/PKG-INFO +5 -2
  2. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/README.md +4 -1
  3. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/pyproject.toml +1 -1
  4. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/__init__.py +1 -1
  5. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/medmlx.py +44 -8
  6. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/network.py +9 -4
  7. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/predictor.py +3 -2
  8. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/topology.py +1 -1
  9. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/transforms.py +7 -2
  10. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/weights.py +22 -4
  11. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/.gitignore +0 -0
  12. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/LICENSE +0 -0
  13. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/THIRD_PARTY_NOTICES.md +0 -0
  14. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_arrays.py +0 -0
  15. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_nifti.py +0 -0
  16. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_records.py +0 -0
  17. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_runtime.py +0 -0
  18. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/_types.py +0 -0
  19. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/inference.py +0 -0
  20. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/py.typed +0 -0
  21. {segresnet_mlx-0.2.2 → segresnet_mlx-0.2.3}/src/segresnet_mlx/telemetry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: segresnet-mlx
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Native MLX SegResNet with NumPy sliding-window logit inference
5
5
  Project-URL: Homepage, https://github.com/MedMLX/segresnet-mlx
6
6
  Project-URL: Documentation, https://github.com/MedMLX/segresnet-mlx/blob/main/docs/technical.md
@@ -59,7 +59,10 @@ from huggingface_hub import hf_hub_download
59
59
  from segresnet_mlx.medmlx import run
60
60
 
61
61
  bundle = "brats_mri_segmentation"
62
- weights = hf_hub_download("MedMLX/segresnet-brats-mlx", "segresnet-brats.safetensors")
62
+ weights = hf_hub_download(
63
+ "MedMLX/segresnet-brats-mlx", "segresnet-brats.safetensors",
64
+ revision="14f9cda3649965729663c7365c49fd839c81a01b",
65
+ )
63
66
 
64
67
  result = run(
65
68
  bundle=bundle,
@@ -30,7 +30,10 @@ from huggingface_hub import hf_hub_download
30
30
  from segresnet_mlx.medmlx import run
31
31
 
32
32
  bundle = "brats_mri_segmentation"
33
- weights = hf_hub_download("MedMLX/segresnet-brats-mlx", "segresnet-brats.safetensors")
33
+ weights = hf_hub_download(
34
+ "MedMLX/segresnet-brats-mlx", "segresnet-brats.safetensors",
35
+ revision="14f9cda3649965729663c7365c49fd839c81a01b",
36
+ )
34
37
 
35
38
  result = run(
36
39
  bundle=bundle,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "segresnet-mlx"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Native MLX SegResNet with NumPy sliding-window logit inference"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.15"
@@ -21,7 +21,7 @@ if TYPE_CHECKING:
21
21
  from segresnet_mlx.weights import load_model as load_model
22
22
  from segresnet_mlx.weights import required_shapes as required_shapes
23
23
 
24
- __version__ = "0.2.2"
24
+ __version__ = "0.2.3"
25
25
 
26
26
  _EXPORTS = {
27
27
  "BUNDLE_CONFIGS": "segresnet_mlx.predictor",
@@ -292,16 +292,23 @@ def run(
292
292
  ``brats_mri_segmentation`` (``t1c_path``, ``t1_path``, ``t2_path``,
293
293
  ``flair_path``: co-registered MRI on one grid), or ``wholeBody_ct_segmentation``
294
294
  / ``wholeBody_ct_segmentation_lowres`` (``image_path``: one CT volume, segmented
295
- at 1.5 mm or 3 mm respectively). ``weights_path`` is the bundle's
296
- ``models/model.pt`` (or ``models/model_lowres.pt`` for the lowres whole-body
297
- variant) converted with :func:`segresnet_mlx.convert_checkpoint`.
295
+ at 1.5 mm or 3 mm respectively). ``weights_path`` is a native ``.safetensors``
296
+ file: ``segresnet-brats.safetensors`` from ``MedMLX/segresnet-brats-mlx`` for
297
+ BraTS, or ``segresnet-renal-cect.safetensors`` from
298
+ ``MedMLX/segresnet-renal-cect-mlx`` for renal CECT. These published files need
299
+ no conversion. For other checkpoints, convert the original ``models/model.pt``
300
+ (``models/model_lowres.pt`` for lowres whole-body) with
301
+ :func:`segresnet_mlx.convert_checkpoint` and pass the resulting file.
298
302
 
299
303
  Inputs must be finite 3D NIfTI volumes; multi-input bundles need identical shape
300
304
  and affine (atol 1e-5). Writes ``<bundle>_mask.nii.gz`` in ``output_dir`` on the
301
305
  first input's shape and reader-selected affine (see ``Volume``): renal labels 0-5
302
306
  and whole-body labels 0-104 as float32, BraTS labels 0, 1, 2, 4 as uint8, as the
303
307
  bundles' ``SaveImaged`` wrote them. An existing mask is replaced only when
304
- ``overwrite`` is true; output aliases of input images are always refused.
308
+ ``overwrite`` is true; output aliases of input images are refused by a
309
+ preflight check. Spatial affine values are assumed to be in millimeters; the
310
+ NIfTI spatial-unit field is not checked or converted. Paths are not locked
311
+ against concurrent replacement after the alias check.
305
312
 
306
313
  ``arithmetic_profile="compensated-fp32"`` uses Metal float pairs for
307
314
  convolution and GroupNorm accumulation, with float32 layer boundaries.
@@ -312,6 +319,27 @@ def run(
312
319
  requires transpose-convolution upsampling and compares with the unchanged
313
320
  original graph evaluated in float64 throughout its network and inferer.
314
321
 
322
+ Args:
323
+ bundle: ``brats_mri_segmentation``, ``renalStructures_CECT_segmentation``,
324
+ ``wholeBody_ct_segmentation`` or ``wholeBody_ct_segmentation_lowres``.
325
+ weights_path: Path to the matching native ``.safetensors`` file, such as
326
+ ``segresnet-brats.safetensors`` or ``segresnet-renal-cect.safetensors``.
327
+ Hugging Face cache file paths are accepted; directories and Torch ``.pt``
328
+ checkpoints are not.
329
+ output_dir: Directory for ``<bundle>_mask.nii.gz``; created if missing.
330
+ arterial_path: Renal arterial-phase 3D NIfTI; required for the renal bundle.
331
+ venous_path: Renal venous-phase 3D NIfTI on the arterial grid; required for renal.
332
+ excretory_path: Renal excretory-phase 3D NIfTI on the arterial grid; required
333
+ for renal.
334
+ t1c_path: BraTS contrast-enhanced T1 3D NIfTI; required for BraTS.
335
+ t1_path: BraTS T1 3D NIfTI on the T1c grid; required for BraTS.
336
+ t2_path: BraTS T2 3D NIfTI on the T1c grid; required for BraTS.
337
+ flair_path: BraTS FLAIR 3D NIfTI on the T1c grid; required for BraTS.
338
+ image_path: Single 3D CT NIfTI; required for either whole-body bundle.
339
+ overwrite: Replace an existing output mask when true; defaults to false.
340
+ arithmetic_profile: ``torch-order-fp32`` (default), ``compensated-fp32``,
341
+ or ``compensated-fp48``; the last supports only the renal bundle.
342
+
315
343
  Returns:
316
344
  ``{"outputs": {"mask": path}, "details": {...}}``; ``details`` is described
317
345
  by ``segresnet_mlx._records.Details`` and always has
@@ -534,7 +562,9 @@ def postprocess_wholebody(logits: Float32Array, inverse: SpatialInverse) -> Floa
534
562
 
535
563
 
536
564
  def _weights_file(weights_path: str) -> Path:
537
- path = Path(weights_path).expanduser().resolve()
565
+ # MLX identifies the file format from the supplied name. Hub cache paths are
566
+ # named .safetensors symlinks whose underlying content-addressed blobs have no suffix.
567
+ path = Path(weights_path).expanduser().absolute()
538
568
  hint = (
539
569
  "Convert the bundle's models/model.pt once with "
540
570
  "segresnet_mlx.convert_checkpoint(model_pt, destination, spec=...) "
@@ -556,8 +586,9 @@ def _weights_file(weights_path: str) -> Path:
556
586
  def load_inputs(roles: list[tuple[str, str | None]]) -> list[Volume]:
557
587
  """Load each ``(role, path)`` and require one shared voxel grid.
558
588
 
559
- Every volume must match the first one's shape and affine (``atol=1e-5``); the
560
- runner does not resample, so a mismatch raises ``InvalidInputError``.
589
+ Every volume must match the first one's shape and affine (``atol=1e-5``).
590
+ This check does not align mismatched inputs; bundle preprocessing may still
591
+ resample the admitted grid afterward.
561
592
  """
562
593
  volumes = [load_volume(Path(str(path)).expanduser().resolve(), role) for role, path in roles]
563
594
  reference = volumes[0]
@@ -573,7 +604,12 @@ def load_inputs(roles: list[tuple[str, str | None]]) -> list[Volume]:
573
604
 
574
605
 
575
606
  def load_volume(path: Path, role: str) -> Volume:
576
- """Read finite 3D voxels using the affine selected by the upstream nibabel reader."""
607
+ """Read 3D voxels using the affine selected by the upstream nibabel reader.
608
+
609
+ Source voxels are checked for finiteness before conversion to float32; values
610
+ outside the float32 range can overflow during that conversion. Spatial units
611
+ and affine invertibility are not checked here.
612
+ """
577
613
  from segresnet_mlx._nifti import ImageFileError, nib
578
614
 
579
615
  if not path.is_file():
@@ -21,7 +21,8 @@ def group_mean(values: Array, mx: MLX) -> Array:
21
21
  """Mean over the last axis of ``(N, G, S)`` values, summed in 256-element tiles.
22
22
 
23
23
  Tiling bounds the float32 accumulation depth of a group that spans a whole
24
- volume. Returns ``(N, G, 1)``. ``SegResNet`` does not call this (its GroupNorm
24
+ volume. The last dimension must be nonempty. Returns ``(N, G, 1)``. ``SegResNet``
25
+ does not call this (its GroupNorm
25
26
  comes from the ``medmlx_core`` operators); it is exercised by
26
27
  ``tests/test_equivalence.py``.
27
28
  """
@@ -34,16 +35,20 @@ def group_mean(values: Array, mx: MLX) -> Array:
34
35
  class SegResNet:
35
36
  """Raw-logit forward pass for MONAI's 3-D, group-normalized SegResNet.
36
37
 
37
- Tensors are channels-first ``(N, C, D, H, W)`` float32 at every layer boundary.
38
+ Tensors are channels-first ``(N, C, D, H, W)``; the supported inference
39
+ contract uses float32 inputs and returned logits. This class checks rank,
40
+ channel count and spatial divisibility but does not itself check input dtype
41
+ or finiteness; the public patch and sliding-window helpers do.
38
42
  ``weights`` uses MONAI's state-dict keys and Torch parameter layouts
39
43
  (convolution weights are ``(out, in, kD, kH, kW)``); ``required_shapes`` in
40
44
  ``segresnet_mlx.weights`` lists them. Inference only: dropout is omitted and
41
45
  no activation is applied to the output.
42
46
 
43
- ``arithmetic_profile`` selects the accumulation scheme (see the README):
47
+ ``arithmetic_profile`` selects the accumulation scheme (see ``docs/technical.md``):
44
48
  ``"torch-order-fp32"`` (default), ``"compensated-fp32"``, or
45
49
  ``"compensated-fp48"``, which needs ``upsample_mode="deconv"`` and raises
46
- ``ValueError`` otherwise.
50
+ ``ValueError`` otherwise. The first two profiles use float32 layer boundaries;
51
+ ``compensated-fp48`` carries paired float32 values through the hidden layers.
47
52
  """
48
53
 
49
54
  def __init__(
@@ -50,8 +50,9 @@ class SlidingWindowConfig:
50
50
  def from_bundle_config(cls, config: InfererConfig | BundleConfig) -> SlidingWindowConfig:
51
51
  """Read the staged JSON's inferer section without evaluating bundle expressions.
52
52
 
53
- Missing keys fall back to MONAI's defaults. Values are taken literally, so a
54
- reference such as ``@displayable_configs#sw_overlap`` is not resolved.
53
+ ``roi_size`` is required; the other listed keys default to MONAI's
54
+ values. Values are taken literally, so a reference such as
55
+ ``@displayable_configs#sw_overlap`` is not resolved.
55
56
  """
56
57
  inferer = cast(InfererConfig, config.get("inferer", config))
57
58
  return cls(
@@ -6,7 +6,7 @@ import math
6
6
  from dataclasses import dataclass
7
7
  from typing import Literal
8
8
 
9
- # Accumulation scheme for convolution and GroupNorm; see the README for the three
9
+ # Accumulation scheme for convolution and GroupNorm; see docs/technical.md for the three
10
10
  # profiles. ``compensated-fp48`` additionally requires transpose-convolution upsampling.
11
11
  type ArithmeticProfile = Literal["torch-order-fp32", "compensated-fp32", "compensated-fp48"]
12
12
 
@@ -10,7 +10,8 @@ bundle's ``MergeClassesd`` and the NIfTI writer. Spatial resampling follows MONA
10
10
  as ``Spacing`` does and returned as float32.
11
11
 
12
12
  Image arrays are channel-first float32 ``(C, *spatial)`` and affines are 4x4 float64
13
- voxel-to-world matrices as stored in the NIfTI header; ``pixdim`` values are in mm.
13
+ voxel-to-world matrices as stored in the NIfTI header. Bundle ``pixdim`` targets
14
+ are in mm; these functions do not inspect or convert the input's spatial units.
14
15
  """
15
16
 
16
17
  from __future__ import annotations
@@ -373,7 +374,11 @@ def brats_labels(binary: Float32Array) -> npt.NDArray[np.int64]:
373
374
 
374
375
 
375
376
  def write_nifti(path: Path, data: HostArray, affine: Float64Array, dtype: npt.DTypeLike) -> None:
376
- """MONAI ``NibabelWriter``: cast, then set sform and qform (code 1) from the affine."""
377
+ """Cast and write a NIfTI with sform and qform code 1.
378
+
379
+ The sform stores the affine within NIfTI precision. The qform cannot
380
+ represent shear, which NiBabel removes when encoding that form.
381
+ """
377
382
  array = np.asarray(data).astype(dtype, copy=False)
378
383
  matrix = to_affine_nd(3, affine)
379
384
  image = nib.Nifti1Image(array, matrix)
@@ -78,6 +78,8 @@ def convert_state_dict(
78
78
 
79
79
  ``state`` is either a raw state dict or a checkpoint holding it under the
80
80
  ``"model"`` key. Arrays keep their Torch layout and are returned C-contiguous.
81
+ Validation checks the parameter inventory and values, not the checkpoint's
82
+ identity or origin; other checkpoints with the same topology are admissible.
81
83
 
82
84
  Raises:
83
85
  InvalidInputError: If a required parameter is missing, an unsupported one is
@@ -117,11 +119,26 @@ def convert_checkpoint(checkpoint_path: Path, destination: Path, *, spec: SegRes
117
119
 
118
120
  The checkpoint is read with the shared loader (``torch.load`` with its default
119
121
  ``weights_only=True``, so Torch from the ``conversion`` extra is required) and
120
- checked by ``convert_state_dict``. Parent directories of ``destination`` are
121
- created. MLX is also needed to write the file, so this runs only on a supported
122
- Apple Silicon host.
122
+ checked by ``convert_state_dict``. ``destination`` must end in
123
+ ``.safetensors``. Parent directories of ``destination`` are
124
+ created. An existing destination is replaced, except when it aliases the
125
+ source checkpoint. Alias checks occur before conversion and do not lock out
126
+ subsequent filesystem changes. MLX is also needed to write the file, so this
127
+ runs only on a supported Apple Silicon host.
128
+
129
+ Raises:
130
+ InvalidInputError: The destination has a different suffix or aliases the
131
+ source checkpoint, or checkpoint contents fail ``convert_state_dict``.
123
132
  """
124
133
 
134
+ if destination.suffix != ".safetensors":
135
+ raise InvalidInputError("Converted weights destination must end in .safetensors")
136
+ if checkpoint_path.resolve() == destination.resolve() or (
137
+ checkpoint_path.is_file()
138
+ and destination.is_file()
139
+ and checkpoint_path.samefile(destination)
140
+ ):
141
+ raise InvalidInputError("Converted weights must not overwrite their source checkpoint")
125
142
  mx = import_mlx()
126
143
  converted = convert_state_dict(load_torch_checkpoint(checkpoint_path), spec)
127
144
  destination.parent.mkdir(parents=True, exist_ok=True)
@@ -141,8 +158,9 @@ def load_model(
141
158
  """Load converted float32 safetensors into a ``SegResNet`` for ``spec``.
142
159
 
143
160
  Raises:
144
- InvalidInputError: If the file is not a mapping, its keys or shapes differ
161
+ InvalidInputError: If the loaded value is not a mapping, its keys or shapes differ
145
162
  from ``required_shapes(spec)``, or a parameter is not finite float32.
163
+ RuntimeError: If MLX cannot open or decode the weights file.
146
164
  """
147
165
  mx = import_mlx()
148
166
  raw: object = mx.load(str(weights_path))
File without changes
File without changes