voxeldata 0.0.1__py3-none-any.whl

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.
voxeldata/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """VoxelData: spatially referenced voxel arrays built on xarray.
2
+
3
+ VoxelData defines a small, modality-independent data model for arrays gridded in
4
+ space. A VoxelData array is an `xarray.DataArray` with native voxel dimensions
5
+ `(k, j, i)` (plus any extra leading dimensions) and a single
6
+ [VoxelToWorldIndex][voxeldata.geometry.VoxelToWorldIndex] that lazily derives world
7
+ coordinates `(z, y, x)` from a voxel-to-world affine.
8
+ """
9
+
10
+ from voxeldata._dims import CORE_DIMS, GRID_DIM, TIME_DIM, VOXEL_DIMS, WORLD_DIMS
11
+ from voxeldata.accessors import VoxelDataAccessor
12
+ from voxeldata.affine import (
13
+ apply_affine,
14
+ get_bounding_box,
15
+ get_relative_affine,
16
+ reindex_voxels,
17
+ reindex_voxels_like,
18
+ )
19
+ from voxeldata.create import create_voxeldata
20
+ from voxeldata.geometry import (
21
+ VoxelToWorldIndex,
22
+ VoxelToWorldTransform,
23
+ attach_voxel_to_world_index,
24
+ convert_length_units,
25
+ get_affine_axis_scalings,
26
+ get_affine_direction_matrix,
27
+ get_voxel_to_world_affine,
28
+ get_voxel_to_world_direction_matrix,
29
+ get_voxel_to_world_index,
30
+ get_voxel_to_world_index_origin,
31
+ get_voxel_to_world_index_spacing,
32
+ get_voxel_to_world_spatial_dims,
33
+ get_voxel_to_world_units,
34
+ has_axis_aligned_voxel_to_world_index,
35
+ has_voxel_to_world_index,
36
+ require_scalar_grid_affine,
37
+ restore_voxel_to_world_index,
38
+ )
39
+ from voxeldata.validation import (
40
+ canonicalize_voxeldata,
41
+ ensure_voxeldata,
42
+ require_dataarray,
43
+ require_positive_finite,
44
+ validate_voxeldata,
45
+ )
46
+
47
+ __all__ = [
48
+ "CORE_DIMS",
49
+ "GRID_DIM",
50
+ "TIME_DIM",
51
+ "VOXEL_DIMS",
52
+ "WORLD_DIMS",
53
+ "VoxelDataAccessor",
54
+ "VoxelToWorldIndex",
55
+ "VoxelToWorldTransform",
56
+ "apply_affine",
57
+ "attach_voxel_to_world_index",
58
+ "canonicalize_voxeldata",
59
+ "convert_length_units",
60
+ "create_voxeldata",
61
+ "ensure_voxeldata",
62
+ "get_affine_axis_scalings",
63
+ "get_affine_direction_matrix",
64
+ "get_bounding_box",
65
+ "get_relative_affine",
66
+ "get_voxel_to_world_affine",
67
+ "get_voxel_to_world_direction_matrix",
68
+ "get_voxel_to_world_index",
69
+ "get_voxel_to_world_index_origin",
70
+ "get_voxel_to_world_index_spacing",
71
+ "get_voxel_to_world_spatial_dims",
72
+ "get_voxel_to_world_units",
73
+ "has_axis_aligned_voxel_to_world_index",
74
+ "has_voxel_to_world_index",
75
+ "reindex_voxels",
76
+ "reindex_voxels_like",
77
+ "require_dataarray",
78
+ "require_positive_finite",
79
+ "require_scalar_grid_affine",
80
+ "restore_voxel_to_world_index",
81
+ "validate_voxeldata",
82
+ ]
@@ -0,0 +1,201 @@
1
+ """Coordinate spacing helpers shared across voxeldata modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+ import xarray as xr
10
+
11
+
12
+ def get_representative_step(
13
+ values: npt.NDArray[np.floating], uniformity_tolerance: float = 1e-2
14
+ ) -> tuple[float | None, bool]:
15
+ """Return a representative step size for a 1D coordinate array.
16
+
17
+ Parameters
18
+ ----------
19
+ values : numpy.ndarray
20
+ One-dimensional coordinate values.
21
+ uniformity_tolerance : float, default: 1e-2
22
+ Maximum allowed per-interval relative deviation from the median consecutive
23
+ difference. Each interval must satisfy
24
+ `|interval - median| <= uniformity_tolerance * |median|` for the coordinate
25
+ to be considered uniform.
26
+
27
+ Returns
28
+ -------
29
+ float or None
30
+ Exact step size when sampling is uniform, the median consecutive difference
31
+ when sampling is non-uniform, or `None` when fewer than two values are
32
+ provided.
33
+ bool
34
+ Whether the returned step is a median approximation derived from non-uniform
35
+ spacing.
36
+ """
37
+ if len(values) < 2:
38
+ return None, False
39
+
40
+ diffs = np.diff(values)
41
+ median = float(np.median(diffs))
42
+ if np.isclose(median, 0.0):
43
+ is_uniform = bool(np.isclose(np.max(diffs), np.min(diffs)))
44
+ else:
45
+ is_uniform = bool(
46
+ np.allclose(diffs, median, rtol=uniformity_tolerance, atol=0.0)
47
+ )
48
+
49
+ if is_uniform:
50
+ return median, False
51
+ return median, True
52
+
53
+
54
+ class CoordinateSpacingInfo:
55
+ """Result of per-dimension spacing analysis.
56
+
57
+ Parameters
58
+ ----------
59
+ value : float or None
60
+ Exact spacing when the coordinate is uniform. `None` otherwise, including for
61
+ single-point coordinates.
62
+ median : float or None
63
+ Median consecutive difference for numeric coordinates with two or more points.
64
+ Available for both uniform and non-uniform coordinates; `None` for missing,
65
+ non-numeric, or single-point coordinates.
66
+ warn_msg : str or None
67
+ Warning message to emit, or `None` if no warning is needed.
68
+ """
69
+
70
+ __slots__ = ("median", "value", "warn_msg")
71
+
72
+ def __init__(
73
+ self,
74
+ value: float | None,
75
+ median: float | None,
76
+ warn_msg: str | None,
77
+ ) -> None:
78
+ self.value = value
79
+ self.median = median
80
+ self.warn_msg = warn_msg
81
+
82
+
83
+ def get_coordinate_spacing_info(
84
+ dim: str,
85
+ data: xr.DataArray,
86
+ uniformity_tolerance: float,
87
+ ) -> CoordinateSpacingInfo:
88
+ """Compute coordinate spacing information for a single dimension.
89
+
90
+ Parameters
91
+ ----------
92
+ dim : str
93
+ Dimension name.
94
+ data : xarray.DataArray
95
+ DataArray whose coordinate to inspect.
96
+ uniformity_tolerance : float
97
+ Maximum allowed per-interval relative deviation from the median consecutive
98
+ difference.
99
+
100
+ Returns
101
+ -------
102
+ CoordinateSpacingInfo
103
+ Spacing result for the dimension.
104
+ """
105
+ if dim not in data.coords:
106
+ return CoordinateSpacingInfo(
107
+ value=None,
108
+ median=None,
109
+ warn_msg=f"Dimension '{dim}' has no coordinate; spacing is undefined.",
110
+ )
111
+
112
+ coord = data.coords[dim]
113
+ if not np.issubdtype(coord.dtype, np.integer) and not np.issubdtype(
114
+ coord.dtype, np.floating
115
+ ):
116
+ return CoordinateSpacingInfo(value=None, median=None, warn_msg=None)
117
+
118
+ # A coordinate named after its dim is not necessarily 1D (e.g. a grid-dependent
119
+ # "time" coordinate can be (time, grid)-shaped). Compute the step along `dim`
120
+ # independently for every combination of the other dims' values.
121
+ other_dims = [d for d in coord.dims if d != dim]
122
+ dim_axis = coord.dims.index(dim)
123
+
124
+ if coord.shape[dim_axis] < 2:
125
+ return CoordinateSpacingInfo(
126
+ value=None,
127
+ median=None,
128
+ warn_msg=(
129
+ f"Dimension '{dim}' has a single coordinate point; spacing is "
130
+ "undefined."
131
+ ),
132
+ )
133
+
134
+ values = np.moveaxis(coord.values, dim_axis, 0).reshape(coord.shape[dim_axis], -1)
135
+
136
+ steps: list[float] = []
137
+ for column in range(values.shape[1]):
138
+ step, is_approximate = get_representative_step(
139
+ values[:, column], uniformity_tolerance=uniformity_tolerance
140
+ )
141
+ assert step is not None # values.shape[0] >= 2, checked above.
142
+ if is_approximate:
143
+ return CoordinateSpacingInfo(
144
+ value=None,
145
+ median=float(np.median(steps + [step])),
146
+ warn_msg=(
147
+ f"Coordinate '{dim}' has non-uniform sampling; spacing is "
148
+ "undefined."
149
+ ),
150
+ )
151
+ steps.append(step)
152
+
153
+ if other_dims and not np.allclose(
154
+ steps, steps[0], rtol=uniformity_tolerance, atol=0.0
155
+ ):
156
+ return CoordinateSpacingInfo(
157
+ value=None,
158
+ median=float(np.median(steps)),
159
+ warn_msg=(
160
+ f"Coordinate '{dim}' spacing along '{dim}' differs across "
161
+ f"{other_dims!r}; spacing is undefined."
162
+ ),
163
+ )
164
+
165
+ return CoordinateSpacingInfo(value=steps[0], median=steps[0], warn_msg=None)
166
+
167
+
168
+ def get_coordinate_origins(data: xr.DataArray) -> dict[str, float]:
169
+ """Return the first coordinate value for each dimension.
170
+
171
+ For each dimension, returns the first coordinate value. If a coordinate is
172
+ missing, falls back to `0.0` with a warning. If a coordinate is non-numeric
173
+ (e.g. string-based), falls back to `0.0` without a warning.
174
+
175
+ Parameters
176
+ ----------
177
+ data : xarray.DataArray
178
+ DataArray whose coordinate origins to compute.
179
+
180
+ Returns
181
+ -------
182
+ dict[str, float]
183
+ Origin per dimension in DataArray dimension order.
184
+ """
185
+ result: dict[str, float] = {}
186
+ for dim in (str(d) for d in data.dims):
187
+ if dim not in data.coords:
188
+ warnings.warn(
189
+ f"Dimension '{dim}' has no coordinate; origin defaults to 0.0.",
190
+ stacklevel=2,
191
+ )
192
+ result[dim] = 0.0
193
+ else:
194
+ coord = data.coords[dim]
195
+ if not np.issubdtype(coord.dtype, np.integer) and not np.issubdtype(
196
+ coord.dtype, np.floating
197
+ ):
198
+ result[dim] = 0.0
199
+ else:
200
+ result[dim] = float(coord.values.flat[0])
201
+ return result
voxeldata/_dims.py ADDED
@@ -0,0 +1,21 @@
1
+ """VoxelData dimension name conventions.
2
+
3
+ VoxelData arrays have trailing spatial dimensions and arbitrary leading nonspatial
4
+ dimensions. This module names the spatial and special nonspatial dimensions so that
5
+ IO, processing, validation, and UI code can share their conventions.
6
+ """
7
+
8
+ WORLD_DIMS: tuple[str, ...] = ("z", "y", "x")
9
+ """World spatial dimension names in VoxelData order."""
10
+
11
+ VOXEL_DIMS: tuple[str, ...] = ("k", "j", "i")
12
+ """Native voxel-space dimension names in VoxelData order."""
13
+
14
+ GRID_DIM: str = "grid"
15
+ """Dimension selecting one voxel-to-world mapping per spatial sampling grid."""
16
+
17
+ TIME_DIM: str = "time"
18
+ """Temporal sample dimension name."""
19
+
20
+ CORE_DIMS: tuple[str, ...] = (GRID_DIM, TIME_DIM, *VOXEL_DIMS)
21
+ """Core dimension names in canonical order, not a required nonspatial order."""