mudm 0.5.0__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.
mudm-0.5.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 NovaGen Research Fund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mudm-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: mudm
3
+ Version: 0.5.0
4
+ Summary: muDM (micro Data Model) — a GeoJSON-inspired data model for microscopy spatial data.
5
+ Author-email: Bengt Ljungquist <bengt.ljungquist@nih.gov>
6
+ License: MIT
7
+ Project-URL: Documentation, https://github.com/NovagenResearch/mudm
8
+ Project-URL: Source, https://github.com/NovagenResearch/mudm
9
+ Keywords: json,microscopy,mudm,spatial-data,geojson
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: <3.14,>=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: pydantic>=2.3.0
17
+ Requires-Dist: geojson-pydantic>=1.2.0
18
+ Dynamic: license-file
19
+
20
+ # muDM
21
+
22
+ muDM (micro Data Model) is a GeoJSON-inspired data model for encoding microscopy spatial data — annotations, regions of interest, coordinate systems, and 3D mesh surfaces.
23
+
24
+ This is the **core data model package**. It provides Pydantic models for validation and serialization with minimal dependencies. For tiling pipelines, format converters, and Rust-accelerated processing, see [mudm-tools](https://github.com/NovagenResearch/mudm-tools).
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install mudm
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```python
35
+ from mudm import MuDM, MuDMFeature, GeoJSON
36
+
37
+ # Validate muDM data
38
+ data = {
39
+ "type": "FeatureCollection",
40
+ "features": [{
41
+ "type": "Feature",
42
+ "geometry": {"type": "Point", "coordinates": [10, 20]},
43
+ "properties": {"label": "nucleus"},
44
+ }],
45
+ }
46
+ obj = MuDM.model_validate(data)
47
+
48
+ # Any GeoJSON is valid muDM
49
+ geojson = GeoJSON.model_validate(data)
50
+
51
+ # Coordinate transforms
52
+ from mudm import AffineTransform
53
+
54
+ # Tile metadata
55
+ from mudm import TileJSON, TileModel
56
+ ```
57
+
58
+ ## What's included
59
+
60
+ - **Model validation**: MuDM, MuDMFeature, MuDMFeatureCollection, GeoJSON
61
+ - **3D geometry types**: TIN, PolyhedralSurface, TiledGeometry
62
+ - **Tile metadata**: TileJSON, TileModel, TileLayer, PyramidJSON
63
+ - **Coordinate transforms**: AffineTransform, VoxelCoordinateSystem
64
+ - **Provenance tracking**: Workflow, Artifact, MuDMLink
65
+
66
+ ## License
67
+
68
+ MIT
mudm-0.5.0/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # muDM
2
+
3
+ muDM (micro Data Model) is a GeoJSON-inspired data model for encoding microscopy spatial data — annotations, regions of interest, coordinate systems, and 3D mesh surfaces.
4
+
5
+ This is the **core data model package**. It provides Pydantic models for validation and serialization with minimal dependencies. For tiling pipelines, format converters, and Rust-accelerated processing, see [mudm-tools](https://github.com/NovagenResearch/mudm-tools).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install mudm
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from mudm import MuDM, MuDMFeature, GeoJSON
17
+
18
+ # Validate muDM data
19
+ data = {
20
+ "type": "FeatureCollection",
21
+ "features": [{
22
+ "type": "Feature",
23
+ "geometry": {"type": "Point", "coordinates": [10, 20]},
24
+ "properties": {"label": "nucleus"},
25
+ }],
26
+ }
27
+ obj = MuDM.model_validate(data)
28
+
29
+ # Any GeoJSON is valid muDM
30
+ geojson = GeoJSON.model_validate(data)
31
+
32
+ # Coordinate transforms
33
+ from mudm import AffineTransform
34
+
35
+ # Tile metadata
36
+ from mudm import TileJSON, TileModel
37
+ ```
38
+
39
+ ## What's included
40
+
41
+ - **Model validation**: MuDM, MuDMFeature, MuDMFeatureCollection, GeoJSON
42
+ - **3D geometry types**: TIN, PolyhedralSurface, TiledGeometry
43
+ - **Tile metadata**: TileJSON, TileModel, TileLayer, PyramidJSON
44
+ - **Coordinate transforms**: AffineTransform, VoxelCoordinateSystem
45
+ - **Provenance tracking**: Workflow, Artifact, MuDMLink
46
+
47
+ ## License
48
+
49
+ MIT
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mudm"
7
+ version = "0.5.0"
8
+ description = "muDM (micro Data Model) — a GeoJSON-inspired data model for microscopy spatial data."
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "Bengt Ljungquist", email = "bengt.ljungquist@nih.gov" },
12
+ ]
13
+ license = { text = "MIT" }
14
+ requires-python = ">=3.11,<3.14"
15
+ classifiers = [
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python",
18
+ "Programming Language :: Python :: 3",
19
+ ]
20
+ keywords = ["json", "microscopy", "mudm", "spatial-data", "geojson"]
21
+ dependencies = [
22
+ "pydantic>=2.3.0",
23
+ "geojson-pydantic>=1.2.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Documentation = "https://github.com/NovagenResearch/mudm"
28
+ Source = "https://github.com/NovagenResearch/mudm"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests/"]
35
+ pythonpath = ["src/"]
mudm-0.5.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ """muDM (micro Data Model) — a GeoJSON-inspired data model for microscopy."""
2
+
3
+ from .model import MuDM, GeoJSON # noqa: F401
4
+ from .model import MuDMFeature, MuDMFeatureCollection # noqa: F401
5
+ from .model import ( # noqa: F401
6
+ TiledGeometry,
7
+ PolyhedralSurface,
8
+ TIN,
9
+ OntologyTerm,
10
+ Vocabulary,
11
+ )
12
+ from .tilemodel import TileJSON, TileModel, TileLayer # noqa: F401
13
+ from .tilemodel import PyramidEntry, PyramidJSON # noqa: F401
14
+ from .transforms import ( # noqa: F401
15
+ AffineTransform,
16
+ VoxelCoordinateSystem,
17
+ apply_transform,
18
+ translate_geometry,
19
+ voxel_to_physical,
20
+ physical_to_voxel,
21
+ )
22
+ from .layout import geometry_bounds, apply_layout # noqa: F401
23
+
24
+ __version__ = "0.5.0"
@@ -0,0 +1,276 @@
1
+ """Shared layout module for MuDM feature collections.
2
+
3
+ Computes spatial offsets (row or grid) and applies them by translating
4
+ MuDM geometry coordinates directly. This makes layout reusable
5
+ across all exporters (glTF, Neuroglancer, Arrow/Parquet).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ from typing import Any
12
+
13
+ from geojson_pydantic import Polygon, MultiPolygon
14
+
15
+ from .model import (
16
+ MuDMFeature,
17
+ MuDMFeatureCollection,
18
+ )
19
+ from .transforms import translate_geometry
20
+
21
+ # (xmin, ymin, zmin, xmax, ymax, zmax) — OGC/GeoJSON bbox ordering
22
+ Bounds = tuple[float, float, float, float, float, float]
23
+
24
+ # Translation offset
25
+ Offset3 = tuple[float, float, float]
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Bounding box computation
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def _collect_xyz_from_coords(
33
+ coords: Any,
34
+ xs: list[float],
35
+ ys: list[float],
36
+ zs: list[float],
37
+ ) -> None:
38
+ """Recursively extract X/Y/Z from nested GeoJSON coordinate arrays."""
39
+ if not coords:
40
+ return
41
+ if isinstance(coords[0], (int, float)):
42
+ xs.append(float(coords[0]))
43
+ ys.append(float(coords[1]) if len(coords) > 1 else 0.0)
44
+ zs.append(float(coords[2]) if len(coords) > 2 else 0.0)
45
+ else:
46
+ for item in coords:
47
+ _collect_xyz_from_coords(item, xs, ys, zs)
48
+
49
+
50
+ def geometry_bounds(geom: Any) -> Bounds | None:
51
+ """Return 3-D bounding box as (xmin, ymin, zmin, xmax, ymax, zmax).
52
+
53
+ Uses ``.bbox3d()`` for 3D types (TIN,
54
+ PolyhedralSurface) and recursive coordinate extraction
55
+ for GeoJSON types.
56
+
57
+ Returns None if the geometry is empty or None.
58
+ """
59
+ if geom is None:
60
+ return None
61
+
62
+ # 3D types with bbox3d() — returns (xmin, ymin, zmin, xmax, ymax, zmax)
63
+ if hasattr(geom, "bbox3d"):
64
+ try:
65
+ return geom.bbox3d()
66
+ except (ValueError, IndexError):
67
+ return None
68
+
69
+ # GeoJSON types with .coordinates
70
+ if not hasattr(geom, "coordinates"):
71
+ return None
72
+
73
+ xs: list[float] = []
74
+ ys: list[float] = []
75
+ zs: list[float] = []
76
+ _collect_xyz_from_coords(geom.coordinates, xs, ys, zs)
77
+
78
+ if not xs:
79
+ return None
80
+ return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Layout algorithms
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def _row_layout(
88
+ bounds: list[Bounds | None],
89
+ spacing: float,
90
+ ) -> list[Offset3]:
91
+ """Place features side-by-side along X."""
92
+ n = len(bounds)
93
+ widths = [(b[3] - b[0]) if b else 0.0 for b in bounds] # xmax - xmin
94
+ max_width = max(widths) if widths else 0.0
95
+ gap = spacing if spacing > 0 else max_width * 0.2
96
+
97
+ offsets: list[Offset3] = [(0.0, 0.0, 0.0)] * n
98
+ cursor = bounds[0][3] if bounds[0] else gap # xmax of first feature
99
+
100
+ for i in range(1, n):
101
+ b = bounds[i]
102
+ if b is None:
103
+ dx = cursor + gap
104
+ offsets[i] = (dx, 0.0, 0.0)
105
+ cursor = dx
106
+ continue
107
+ dx = (cursor + gap) - b[0] # shift so xmin aligns to cursor + gap
108
+ offsets[i] = (dx, 0.0, 0.0)
109
+ cursor = b[3] + dx # new xmax position
110
+
111
+ return offsets
112
+
113
+
114
+ def _grid_layout(
115
+ bounds: list[Bounds | None],
116
+ spacing: float,
117
+ grid_max_x: int | None,
118
+ grid_max_y: int | None,
119
+ grid_max_z: int | None,
120
+ n: int,
121
+ ) -> list[Offset3]:
122
+ """Place features on a uniform grid that wraps X -> Y -> Z.
123
+
124
+ ``grid_max_x/y/z`` give the number of cells per axis directly.
125
+ """
126
+ # Bounds format: (xmin, ymin, zmin, xmax, ymax, zmax)
127
+ widths = [(b[3] - b[0]) if b else 0.0 for b in bounds]
128
+ heights = [(b[4] - b[1]) if b else 0.0 for b in bounds]
129
+ depths = [(b[5] - b[2]) if b else 0.0 for b in bounds]
130
+
131
+ max_w = max(widths) if widths else 0.0
132
+ max_h = max(heights) if heights else 0.0
133
+ max_d = max(depths) if depths else 0.0
134
+
135
+ max_extent = max(max_w, max_h, max_d)
136
+
137
+ if spacing > 0:
138
+ # Fixed cell size — center-to-center distance, ignores extent
139
+ cell_x = spacing
140
+ cell_y = spacing
141
+ cell_z = spacing
142
+ else:
143
+ # Auto: 20% gap on top of max extent per axis
144
+ gap = max_extent * 0.2 if max_extent > 0 else 1.0
145
+ cell_x = max_w + gap
146
+ cell_y = max_h + gap
147
+ cell_z = max_d + gap
148
+
149
+ # Grid dimensions — values are cell counts, unlimited when None
150
+ cols = grid_max_x if grid_max_x is not None else n
151
+ rows = (
152
+ grid_max_y
153
+ if grid_max_y is not None
154
+ else max(1, -(-n // cols))
155
+ )
156
+ layers = (
157
+ grid_max_z
158
+ if grid_max_z is not None
159
+ else max(1, -(-n // (cols * rows)))
160
+ )
161
+
162
+ capacity = cols * rows * layers
163
+ if n > capacity:
164
+ raise ValueError(
165
+ f"Cannot fit {n} features in grid "
166
+ f"({cols} cols \u00d7 {rows} rows \u00d7 {layers} layers "
167
+ f"= {capacity} cells). "
168
+ f"Increase grid_max_x/y/z or reduce feature count."
169
+ )
170
+
171
+ # Feature 0 is the reference point; others are placed relative to it
172
+ ref = bounds[0]
173
+ ref_cx = (ref[0] + ref[3]) / 2 if ref else 0.0 # (xmin + xmax) / 2
174
+ ref_cy = (ref[1] + ref[4]) / 2 if ref else 0.0 # (ymin + ymax) / 2
175
+ ref_cz = (ref[2] + ref[5]) / 2 if ref else 0.0 # (zmin + zmax) / 2
176
+
177
+ offsets: list[Offset3] = [(0.0, 0.0, 0.0)]
178
+
179
+ for i in range(1, n):
180
+ col = i % cols
181
+ row = (i // cols) % rows
182
+ layer = i // (cols * rows)
183
+
184
+ target_cx = ref_cx + col * cell_x
185
+ target_cy = ref_cy + row * cell_y
186
+ target_cz = ref_cz + layer * cell_z
187
+
188
+ b = bounds[i]
189
+ feat_cx = (b[0] + b[3]) / 2 if b else 0.0
190
+ feat_cy = (b[1] + b[4]) / 2 if b else 0.0
191
+ feat_cz = (b[2] + b[5]) / 2 if b else 0.0
192
+
193
+ offsets.append((
194
+ target_cx - feat_cx,
195
+ target_cy - feat_cy,
196
+ target_cz - feat_cz,
197
+ ))
198
+
199
+ return offsets
200
+
201
+
202
+ def compute_collection_offsets(
203
+ features: list[MuDMFeature],
204
+ spacing: float | None = None,
205
+ grid_max_x: int | None = None,
206
+ grid_max_y: int | None = None,
207
+ grid_max_z: int | None = None,
208
+ ) -> list[Offset3]:
209
+ """Compute 3-D translation for each feature in a collection.
210
+
211
+ Returns a list of (dx, dy, dz) offsets in source coordinates.
212
+ The first feature always stays at its original position.
213
+
214
+ When *spacing* is ``None`` and no grid parameters are set, all
215
+ offsets are zero (coordinates are kept as-is).
216
+ """
217
+ n = len(features)
218
+ if n <= 1:
219
+ return [(0.0, 0.0, 0.0)] * n
220
+
221
+ has_grid = (
222
+ grid_max_x is not None
223
+ or grid_max_y is not None
224
+ or grid_max_z is not None
225
+ )
226
+
227
+ # No layout requested — keep coordinates as-is
228
+ if spacing is None and not has_grid:
229
+ return [(0.0, 0.0, 0.0)] * n
230
+
231
+ effective_spacing = spacing if spacing is not None else 0.0
232
+
233
+ bounds = [
234
+ geometry_bounds(f.geometry) if f.geometry else None
235
+ for f in features
236
+ ]
237
+
238
+ if has_grid:
239
+ return _grid_layout(bounds, effective_spacing, grid_max_x, grid_max_y, grid_max_z, n)
240
+ return _row_layout(bounds, effective_spacing)
241
+
242
+
243
+ def apply_layout(
244
+ collection: MuDMFeatureCollection,
245
+ spacing: float | None = None,
246
+ grid_max_x: int | None = None,
247
+ grid_max_y: int | None = None,
248
+ grid_max_z: int | None = None,
249
+ ) -> MuDMFeatureCollection:
250
+ """Apply spatial layout to a collection, returning a new collection.
251
+
252
+ Computes offsets and translates each feature's geometry coordinates
253
+ directly. The returned collection is a copy — the original is not
254
+ modified.
255
+ """
256
+ features = list(collection.features)
257
+ offsets = compute_collection_offsets(
258
+ features, spacing, grid_max_x, grid_max_y, grid_max_z,
259
+ )
260
+
261
+ new_features = []
262
+ for feat, (dx, dy, dz) in zip(features, offsets):
263
+ if abs(dx) < 1e-12 and abs(dy) < 1e-12 and abs(dz) < 1e-12:
264
+ new_features.append(feat)
265
+ continue
266
+
267
+ if feat.geometry is None:
268
+ new_features.append(feat)
269
+ continue
270
+
271
+ new_geom = translate_geometry(feat.geometry, dx, dy, dz)
272
+ # Build a new feature with translated geometry
273
+ new_feat = feat.model_copy(update={"geometry": new_geom})
274
+ new_features.append(new_feat)
275
+
276
+ return collection.model_copy(update={"features": new_features})