mudm 0.5.0__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.
mudm/__init__.py ADDED
@@ -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"
mudm/layout.py ADDED
@@ -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})
mudm/model.py ADDED
@@ -0,0 +1,255 @@
1
+ """MuDM and GeoJSON models, defined manually using pydantic."""
2
+
3
+ from typing import Any, List, Literal, Optional, Tuple, Union, Dict
4
+ from pydantic import BaseModel, StrictInt, StrictStr, RootModel, field_validator, model_validator
5
+ from .provenance import Workflow
6
+ from .provenance import WorkflowCollection
7
+ from .provenance import Artifact
8
+ from .provenance import ArtifactCollection
9
+ from geojson_pydantic import Feature, FeatureCollection, GeometryCollection
10
+ from geojson_pydantic import Point, MultiPoint, LineString, MultiLineString
11
+ from geojson_pydantic import Polygon, MultiPolygon
12
+ from geojson_pydantic.types import (
13
+ Position,
14
+ LinearRing,
15
+ PolygonCoords,
16
+ MultiPolygonCoords,
17
+ )
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Helper: extract all 3D positions from nested coordinate structures
22
+ # ---------------------------------------------------------------------------
23
+
24
+ def _iter_positions(coords: list) -> list[Position]:
25
+ """Recursively flatten nested coordinate arrays to a list of positions."""
26
+ if not coords:
27
+ return []
28
+ # If the first element is a number, this is a single position
29
+ if isinstance(coords[0], (int, float)):
30
+ return [tuple(coords)] # type: ignore[return-value]
31
+ # If the first element is a tuple/namedtuple, this is a list of positions
32
+ if isinstance(coords[0], tuple) and isinstance(coords[0][0], (int, float)):
33
+ return list(coords) # type: ignore[return-value]
34
+ # Otherwise, recurse
35
+ result: list[Position] = []
36
+ for item in coords:
37
+ result.extend(_iter_positions(item))
38
+ return result
39
+
40
+
41
+ def _bbox3d(coords: list) -> Tuple[float, float, float, float, float, float]:
42
+ """Compute 3D bounding box from nested coordinate arrays."""
43
+ positions = _iter_positions(coords)
44
+ xs = [p[0] for p in positions]
45
+ ys = [p[1] for p in positions]
46
+ zs = [p[2] for p in positions]
47
+ return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
48
+
49
+
50
+ def _centroid3d(coords: list) -> Tuple[float, float, float]:
51
+ """Compute centroid of unique 3D positions from nested coordinate arrays."""
52
+ positions = _iter_positions(coords)
53
+ unique = list({(float(p[0]), float(p[1]), float(p[2])) for p in positions})
54
+ n = len(unique)
55
+ return (
56
+ sum(p[0] for p in unique) / n,
57
+ sum(p[1] for p in unique) / n,
58
+ sum(p[2] for p in unique) / n,
59
+ )
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # 3D Geometry Types
64
+ # ---------------------------------------------------------------------------
65
+
66
+ class TiledGeometry(BaseModel):
67
+ """Base for geometry types that reference external tiled data.
68
+
69
+ When data is stored in external tiles, coordinates may be empty and
70
+ the tiles field lists the spatial tile identifiers.
71
+ """
72
+ tiles: Optional[List[str]] = None
73
+
74
+
75
+ class PolyhedralSurface(TiledGeometry):
76
+ """A closed surface mesh consisting of polygonal faces (ISO 19107).
77
+
78
+ Each face has the same structure as a Polygon: a list of linear rings,
79
+ where each ring is a list of 3D positions.
80
+ """
81
+
82
+ type: Literal["PolyhedralSurface"]
83
+ coordinates: List[PolygonCoords] = []
84
+
85
+ @field_validator("coordinates")
86
+ @classmethod
87
+ def _validate_faces(cls, v: List[PolygonCoords]) -> List[PolygonCoords]:
88
+ return v # allow empty when tiles present
89
+
90
+ @model_validator(mode="after")
91
+ def _require_data_source(self):
92
+ if not self.coordinates and not self.tiles:
93
+ raise ValueError("PolyhedralSurface requires either coordinates or tiles")
94
+ return self
95
+
96
+ def bbox3d(self) -> Optional[Tuple[float, float, float, float, float, float]]:
97
+ if not self.coordinates:
98
+ return None
99
+ return _bbox3d(self.coordinates)
100
+
101
+ def centroid3d(self) -> Optional[Tuple[float, float, float]]:
102
+ if not self.coordinates:
103
+ return None
104
+ return _centroid3d(self.coordinates)
105
+
106
+
107
+ class TIN(TiledGeometry):
108
+ """A Triangulated Irregular Network — triangle mesh surface (ISO 19107).
109
+
110
+ Each face must be a single closed ring of exactly 4 positions
111
+ (3 vertices + repeated first vertex).
112
+ """
113
+
114
+ type: Literal["TIN"]
115
+ coordinates: List[PolygonCoords] = []
116
+
117
+ @field_validator("coordinates")
118
+ @classmethod
119
+ def _validate_triangles(cls, v: List[PolygonCoords]) -> List[PolygonCoords]:
120
+ if len(v) == 0:
121
+ return v # empty OK when tiles are present
122
+ for i, face in enumerate(v):
123
+ if len(face) != 1:
124
+ raise ValueError(
125
+ f"TIN face {i} must have exactly one ring, got {len(face)}"
126
+ )
127
+ ring = face[0]
128
+ if len(ring) != 4:
129
+ raise ValueError(
130
+ f"TIN face {i} ring must have exactly 4 positions "
131
+ f"(closed triangle), got {len(ring)}"
132
+ )
133
+ return v
134
+
135
+ @model_validator(mode="after")
136
+ def _require_data_source(self):
137
+ if not self.coordinates and not self.tiles:
138
+ raise ValueError("TIN requires either coordinates or tiles")
139
+ return self
140
+
141
+ def bbox3d(self) -> Optional[Tuple[float, float, float, float, float, float]]:
142
+ if not self.coordinates:
143
+ return None
144
+ return _bbox3d(self.coordinates)
145
+
146
+ def centroid3d(self) -> Optional[Tuple[float, float, float]]:
147
+ if not self.coordinates:
148
+ return None
149
+ return _centroid3d(self.coordinates)
150
+
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Ontology Vocabulary Support
155
+ # ---------------------------------------------------------------------------
156
+
157
+ class OntologyTerm(BaseModel):
158
+ """A reference to a formal ontology term.
159
+
160
+ Attributes:
161
+ uri: Full URI of the ontology term (e.g. "http://purl.obolibrary.org/obo/CL_0000598").
162
+ label: Human-readable label (e.g. "pyramidal neuron").
163
+ description: Optional longer description of the term.
164
+ """
165
+ uri: str
166
+ label: Optional[str] = None
167
+ description: Optional[str] = None
168
+
169
+
170
+ class Vocabulary(BaseModel):
171
+ """Maps property values to formal ontology terms.
172
+
173
+ Attributes:
174
+ namespace: Common URI prefix for the ontology (e.g. "http://purl.obolibrary.org/obo/CL_").
175
+ description: Optional description of this vocabulary.
176
+ terms: Mapping from property values to ontology terms.
177
+ """
178
+ namespace: Optional[str] = None
179
+ description: Optional[str] = None
180
+ terms: Dict[str, OntologyTerm]
181
+
182
+
183
+ GeometryType = Union[ # type: ignore
184
+ Point,
185
+ MultiPoint,
186
+ LineString,
187
+ MultiLineString,
188
+ Polygon,
189
+ MultiPolygon,
190
+ GeometryCollection,
191
+ PolyhedralSurface,
192
+ TIN,
193
+ type(None),
194
+ ]
195
+
196
+
197
+ class GeoJSON(RootModel):
198
+ """The root object of a GeoJSON file"""
199
+
200
+ root: Union[Feature, FeatureCollection, GeometryType] # type: ignore
201
+
202
+
203
+ class MuDMFeature(Feature):
204
+ """A MuDM feature, which is a GeoJSON feature with additional
205
+ metadata
206
+
207
+ Args:
208
+ geometry (Optional[GeometryType]): Extended geometry supporting 3D types
209
+ multiscale (Optional[Multiscale]): The coordinate system of the feature
210
+ ref (Optional[Union[StrictStr, StrictInt]]):
211
+ A reference to the parent feature
212
+ parentId (Optional[Union[StrictStr, StrictInt]]):
213
+ A reference to the parent feature
214
+ featureClass (Optional[str]): The class of the feature
215
+ """
216
+
217
+ # Override geometry to accept MuDM 3D types in addition to GeoJSON types
218
+ geometry: Union[GeometryType, None] # type: ignore[assignment]
219
+ ref: Optional[Union[StrictStr, StrictInt]] = None
220
+ # reference to the parent feature
221
+ parentId: Optional[Union[StrictStr, StrictInt]] = None
222
+ # for now, only string feature class is supported
223
+ # in the future, it may be expanded with a class registry
224
+ featureClass: Optional[str] = None
225
+ vocabularies: Optional[Union[Dict[str, Vocabulary], str]] = None
226
+
227
+
228
+ class MuDMFeatureCollection(FeatureCollection):
229
+ """A MuDM feature collection, which is a GeoJSON feature
230
+ collection with additional metadata.
231
+
232
+ Args:
233
+ features (List[MuDMFeature]): Features with extended 3D geometry support
234
+ properties (Optional[Props]): The properties of the feature collection
235
+ id (Optional[Union[StrictStr, StrictInt]]): The ID of the feature coll.
236
+ provenance (Optional[Union[Workflow,
237
+ WorkflowCollection,
238
+ Artifact,
239
+ ArtifactCollection]]): The provenance of the feature collection
240
+ """
241
+
242
+ # Override features to use MuDMFeature (supports 3D geometry types)
243
+ features: List[MuDMFeature] # type: ignore[assignment]
244
+ properties: Optional[Dict[str, Any]] = None
245
+ id: Optional[Union[StrictStr, StrictInt]] = None
246
+ provenance: Optional[
247
+ Union[Workflow, WorkflowCollection, Artifact, ArtifactCollection]
248
+ ] = None
249
+ vocabularies: Optional[Union[Dict[str, Vocabulary], str]] = None
250
+
251
+
252
+ class MuDM(RootModel):
253
+ """The root object of a MuDM file"""
254
+
255
+ root: Union[MuDMFeature, MuDMFeatureCollection, GeometryType] # type: ignore
mudm/provenance.py ADDED
@@ -0,0 +1,46 @@
1
+ from pydantic import BaseModel
2
+ from typing import List, Optional, Dict, Union, Literal
3
+
4
+
5
+ class MuDMLink(BaseModel):
6
+ """A link to a MuDM object"""
7
+ mudmId: Union[str, List[str]]
8
+ mudmField: Optional[str] = None
9
+
10
+
11
+ class Artifact(BaseModel):
12
+ """Artifact object representing a single file or directory"""
13
+ id: Optional[str] = None
14
+ type: Literal["Artifact"]
15
+ uri: str
16
+ properties: Optional[Dict[str, Union[str, float, int]]] = None
17
+ mudmLinks: List['MuDMLink']
18
+
19
+
20
+ class ArtifactCollection(BaseModel):
21
+ """ArtifactCollection object representing a collection of files or
22
+ directories"""
23
+ type: Literal["ArtifactCollection"]
24
+ artifacts: List[Artifact]
25
+
26
+
27
+ class Workflow(BaseModel):
28
+ """Workflow object representing a single workflow"""
29
+ id: Optional[str] = None
30
+ type: Literal["Workflow"]
31
+ properties: Optional[Dict[str, Union[str, float, int]]] = None
32
+ subWorkflows: Optional[List['Workflow']] = None
33
+ workflowProvenance: Optional['WorkflowProvenance'] = None
34
+
35
+
36
+ class WorkflowProvenance(BaseModel):
37
+ """WorkflowProvenance object representing an execution of a workflow"""
38
+ type: Literal["WorkflowProvenance"]
39
+ properties: Optional[Dict[str, Union[str, float, int]]] = None
40
+ outputArtifacts: Optional[Union[Artifact, ArtifactCollection]] = None
41
+
42
+
43
+ class WorkflowCollection(BaseModel):
44
+ """WorkflowCollection object representing a collection of workflows"""
45
+ type: Literal["WorkflowCollection"]
46
+ workflows: List[Workflow]
mudm/tilemodel.py ADDED
@@ -0,0 +1,235 @@
1
+ from typing import List, Optional, Union, Dict, Literal
2
+ from enum import StrEnum
3
+ from pydantic import BaseModel, AnyUrl, conlist, RootModel
4
+ from pydantic import StrictStr
5
+ from pathlib import Path
6
+
7
+ from .model import Vocabulary
8
+
9
+
10
+ class TileLayer(BaseModel):
11
+ """A vector layer in a TileJSON file.
12
+
13
+ Args:
14
+ id (str): The unique identifier for the layer.
15
+ fields (Union[None, Dict[str, str]]): The fields in the layer.
16
+ minzoom (Optional[int]): The minimum zoom level for the layer.
17
+ maxzoom (Optional[int]): The maximum zoom level for the layer.
18
+ description (Optional[str]): A description of the layer.
19
+ fieldranges (Optional[Dict[str, List[Union[int, float, str]]]]):
20
+ The ranges of the fields.
21
+ fieldenums (Optional[Dict[str, List[str]]]):
22
+ The enums of the fields.
23
+ fielddescriptions (Optional[Dict[str, str]]):
24
+ The descriptions of the fields.
25
+ vocabularies (Optional[Dict[str, Vocabulary]]):
26
+ Ontology vocabularies mapping property values to formal terms.
27
+ """
28
+
29
+ id: str
30
+ fields: Union[None, Dict[str, str]] = None
31
+ minzoom: Optional[int] = 0
32
+ maxzoom: Optional[int] = 22
33
+ description: Optional[str] = None
34
+ fieldranges: Optional[Dict[str, List[Union[int, float, str]]]] = None
35
+ fieldenums: Optional[Dict[str, List[str]]] = None
36
+ fielddescriptions: Optional[Dict[str, str]] = None
37
+ vocabularies: Optional[Dict[str, Vocabulary]] = None
38
+
39
+
40
+ class Unit(StrEnum):
41
+ """A unit of measurement"""
42
+
43
+ ANGSTROM = "angstrom"
44
+ ATTOMETER = "attometer"
45
+ CENTIMETER = "centimeter"
46
+ DECIMETER = "decimeter"
47
+ EXAMETER = "exameter"
48
+ FEMTOMETER = "femtometer"
49
+ FOOT = "foot"
50
+ GIGAMETER = "gigameter"
51
+ HECTOMETER = "hectometer"
52
+ INCH = "inch"
53
+ KILOMETER = "kilometer"
54
+ MEGAMETER = "megameter"
55
+ METER = "meter"
56
+ MICROMETER = "micrometer"
57
+ MILE = "mile"
58
+ MILLIMETER = "millimeter"
59
+ NANOMETER = "nanometer"
60
+ PARSEC = "parsec"
61
+ PETAMETER = "petameter"
62
+ PICOMETER = "picometer"
63
+ TERAMETER = "terameter"
64
+ YARD = "yard"
65
+ YOCTOMETER = "yoctometer"
66
+ YOTTAMETER = "yottameter"
67
+ ZEPTOMETER = "zeptometer"
68
+ ZETTAMETER = "zettameter"
69
+ PIXEL = "pixel"
70
+ RADIAN = "radian"
71
+ DEGREE = "degree"
72
+
73
+
74
+ class AxisType(StrEnum):
75
+ """The type of an axis"""
76
+
77
+ SPACE = "space"
78
+ TIME = "time"
79
+ CHANNEL = "channel"
80
+
81
+
82
+ class Axis(BaseModel):
83
+ """An axis of a coordinate system
84
+
85
+ Args:
86
+ name (StrictStr): The name of the axis
87
+ type (Optional[AxisType]): The type of the axis
88
+ unit (Optional[Unit]): The unit of the axis
89
+ description (Optional[str]): A description of the axis
90
+ """
91
+
92
+ name: StrictStr
93
+ type: Optional[AxisType] = None
94
+ unit: Optional[Unit] = None
95
+ description: Optional[str] = None
96
+
97
+
98
+ class CoordinateTransformation(BaseModel):
99
+ """Coordinate transformation abstract class
100
+ harmonized with the OME model"""
101
+
102
+
103
+ class Identity(CoordinateTransformation):
104
+ """Identity transformation for coordinates
105
+
106
+ Args:
107
+ type (Literal["identity"]): The type of the transformation
108
+ """
109
+
110
+ type: Literal["identity"] = "identity"
111
+
112
+
113
+ class Translation(CoordinateTransformation):
114
+ """Translation transformation
115
+
116
+ Args:
117
+ type (Literal["translation"]): The type of the transformation
118
+ translation (List[float]): The translation vector
119
+ """
120
+
121
+ type: Literal["translation"] = "translation"
122
+ translation: List[float]
123
+
124
+
125
+ class Scale(CoordinateTransformation):
126
+ """Scale transformation
127
+
128
+ Args:
129
+ type (Literal["scale"]): The type of the transformation
130
+ scale (List[float]): The scale vector
131
+ """
132
+
133
+ type: Literal["scale"] = "scale"
134
+ scale: List[float]
135
+
136
+
137
+ class Multiscale(BaseModel):
138
+ """A coordinate system for MuDM coordinates
139
+
140
+ Args:
141
+ axes (List[Axis]): The axes of the coordinate system
142
+ coordinateTransformations (Optional[List[CoordinateTransformation]]):
143
+ A list of coordinate transformations
144
+ transformationMatrix (Optional[List[List[float]]):
145
+ The transformation matrix
146
+ """
147
+
148
+ axes: List[Axis]
149
+ coordinateTransformations: Optional[List[CoordinateTransformation]] = None
150
+ transformationMatrix: Optional[List[List[float]]] = None
151
+
152
+
153
+ class TileModel(BaseModel):
154
+ """A TileJSON object.
155
+
156
+ Args:
157
+ tilejson (str): The TileJSON version.
158
+ tiles (List[Union[Path, AnyUrl]]): The list of tile URLs.
159
+ name (Optional[str]): The name of the tileset.
160
+ description (Optional[str]): The description of the tileset.
161
+ version (Optional[str]): The version of the tileset.
162
+ attribution (Optional[str]): The attribution of the tileset.
163
+ template (Optional[str]): The template of the tileset.
164
+ legend (Optional[str]): The legend of the tileset.
165
+ scheme (Optional[str]): The scheme of the tileset.
166
+ grids (Optional[Union[Path, AnyUrl]]): The grids of the tileset.
167
+ data (Optional[Union[Path, AnyUrl]]): The data of the tileset.
168
+ minzoom (Optional[int]): The minimum zoom level of the tileset.
169
+ maxzoom (Optional[int]): The maximum zoom level of the tileset.
170
+ bounds (Optional[conlist(float, min_length=4, max_length=10)]):
171
+ The bounds of the tileset.
172
+ center (Optional[conlist(float, min_length=3, max_length=6)]):
173
+ The center of the tileset.
174
+ fillzoom (Optional[int]): The fill zoom level of the tileset.
175
+ vector_layers (List[TileLayer]): The vector layers of the tileset.
176
+
177
+ """
178
+
179
+ tilejson: str
180
+ tiles: List[Union[Path, AnyUrl]]
181
+ name: Optional[str] = None
182
+ description: Optional[str] = None
183
+ version: Optional[str] = None
184
+ attribution: Optional[str] = None
185
+ template: Optional[str] = None
186
+ legend: Optional[str] = None
187
+ scheme: Optional[str] = None
188
+ grids: Optional[Union[Path, AnyUrl]] = None
189
+ data: Optional[Union[Path, AnyUrl]] = None
190
+ minzoom: Optional[int] = 0
191
+ maxzoom: Optional[int] = 22
192
+ bounds: Optional[conlist(float, min_length=4, max_length=10)] = None # type: ignore
193
+ center: Optional[conlist(float, min_length=3, max_length=6)] = None # type: ignore
194
+ fillzoom: Optional[int] = None
195
+ vector_layers: List[TileLayer]
196
+ multiscale: Optional[Multiscale] = None
197
+ scale_factor: Optional[float] = None
198
+
199
+
200
+ class TileJSON(RootModel):
201
+ """The root object of a TileJSON file."""
202
+
203
+ root: TileModel
204
+
205
+
206
+ class PyramidEntry(BaseModel):
207
+ """One pyramid in a PyramidJSON manifest.
208
+
209
+ Attributes:
210
+ id: Unique identifier for this pyramid (used as directory name).
211
+ label: Human-readable display label.
212
+ tilejson: Relative path to TileModel3D metadata file.
213
+ features: Relative path to MuDM FeatureCollection.
214
+ tiles: Total number of tile files.
215
+ feature_count: Number of unique features.
216
+ size_bytes: Total size on disk in bytes.
217
+ """
218
+ id: str
219
+ label: Optional[str] = None
220
+ tilejson: str = "tilejson3d.json"
221
+ features: Optional[str] = "features.json"
222
+ tiles: Optional[int] = None
223
+ feature_count: Optional[int] = None
224
+ size_bytes: Optional[int] = None
225
+
226
+
227
+ class PyramidJSON(BaseModel):
228
+ """Root manifest listing all available pyramids.
229
+
230
+ Attributes:
231
+ version: Manifest format version.
232
+ pyramids: List of pyramid entries.
233
+ """
234
+ version: str = "1.0"
235
+ pyramids: List[PyramidEntry]
mudm/transforms.py ADDED
@@ -0,0 +1,201 @@
1
+ """3D coordinate transforms for MuDM.
2
+
3
+ Provides AffineTransform (4x4 matrix), VoxelCoordinateSystem, and helper
4
+ functions for coordinate conversion. Extends the existing transformation
5
+ patterns in tilemodel.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import List, Literal, Tuple, Union
11
+
12
+ from pydantic import BaseModel, field_validator
13
+ from geojson_pydantic import (
14
+ Point,
15
+ MultiPoint,
16
+ LineString,
17
+ MultiLineString,
18
+ Polygon,
19
+ MultiPolygon,
20
+ )
21
+
22
+ from .tilemodel import CoordinateTransformation
23
+ from .model import (
24
+ TIN,
25
+ PolyhedralSurface,
26
+ )
27
+
28
+
29
+ class AffineTransform(CoordinateTransformation):
30
+ """A 4x4 affine transformation matrix for 3D coordinates.
31
+
32
+ The matrix is stored in row-major order:
33
+ [[r00, r01, r02, tx],
34
+ [r10, r11, r12, ty],
35
+ [r20, r21, r22, tz],
36
+ [0, 0, 0, 1 ]]
37
+ """
38
+
39
+ type: Literal["affine"] = "affine"
40
+ matrix: List[List[float]]
41
+
42
+ @field_validator("matrix")
43
+ @classmethod
44
+ def _validate_4x4(cls, v: List[List[float]]) -> List[List[float]]:
45
+ if len(v) != 4:
46
+ raise ValueError(f"Affine matrix must have 4 rows, got {len(v)}")
47
+ for i, row in enumerate(v):
48
+ if len(row) != 4:
49
+ raise ValueError(
50
+ f"Affine matrix row {i} must have 4 columns, got {len(row)}"
51
+ )
52
+ return v
53
+
54
+
55
+ class VoxelCoordinateSystem(BaseModel):
56
+ """Defines voxel-to-physical coordinate mapping.
57
+
58
+ Physical coordinate = voxel * resolution + origin
59
+ """
60
+
61
+ axes: List[str]
62
+ units: List[str]
63
+ resolution: List[float]
64
+ origin: List[float] = [0.0, 0.0, 0.0]
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Helper functions
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def _transform_position(
72
+ pos: tuple, matrix: List[List[float]]
73
+ ) -> tuple:
74
+ """Apply a 4x4 affine matrix to a single position (2D or 3D)."""
75
+ x = float(pos[0])
76
+ y = float(pos[1]) if len(pos) > 1 else 0.0
77
+ z = float(pos[2]) if len(pos) > 2 else 0.0
78
+ nx = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z + matrix[0][3]
79
+ ny = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z + matrix[1][3]
80
+ nz = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z + matrix[2][3]
81
+ if len(pos) <= 2:
82
+ return (nx, ny)
83
+ return (nx, ny, nz)
84
+
85
+
86
+ def _transform_coords(coords, matrix: List[List[float]]):
87
+ """Recursively transform nested coordinate arrays."""
88
+ if not coords:
89
+ return coords
90
+ # Single position (tuple of numbers)
91
+ if isinstance(coords[0], (int, float)):
92
+ return _transform_position(tuple(coords), matrix)
93
+ # List of positions or deeper nesting
94
+ return [_transform_coords(c, matrix) for c in coords]
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # 3D type transform helpers
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def _transform_tin(geom: TIN, matrix: List[List[float]]) -> TIN:
102
+ """Transform nested polygon coordinates in a TIN."""
103
+ new_coords = _transform_coords(list(geom.coordinates), matrix)
104
+ return TIN(type="TIN", coordinates=new_coords)
105
+
106
+
107
+ def _transform_polyhedral(
108
+ geom: PolyhedralSurface, matrix: List[List[float]]
109
+ ) -> PolyhedralSurface:
110
+ """Transform nested polygon coordinates in a PolyhedralSurface."""
111
+ new_coords = _transform_coords(list(geom.coordinates), matrix)
112
+ return PolyhedralSurface(type="PolyhedralSurface", coordinates=new_coords)
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Geometry union + dispatch
117
+ # ---------------------------------------------------------------------------
118
+
119
+ Geometry = Union[
120
+ Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon,
121
+ TIN, PolyhedralSurface,
122
+ ]
123
+
124
+
125
+ def apply_transform(geometry: Geometry, transform: AffineTransform) -> Geometry:
126
+ """Apply an affine transform to a geometry, returning a new geometry.
127
+
128
+ Supports all GeoJSON geometry types plus MuDM 3D types
129
+ (TIN, PolyhedralSurface).
130
+
131
+ Args:
132
+ geometry: A geometry with 3D coordinates.
133
+ transform: A 4x4 affine transformation.
134
+
135
+ Returns:
136
+ A new geometry of the same type with transformed coordinates.
137
+ """
138
+ matrix = transform.matrix
139
+
140
+ if isinstance(geometry, TIN):
141
+ return _transform_tin(geometry, matrix)
142
+ if isinstance(geometry, PolyhedralSurface):
143
+ return _transform_polyhedral(geometry, matrix)
144
+
145
+ # GeoJSON types — all have .coordinates
146
+ new_coords = _transform_coords(list(geometry.coordinates), matrix)
147
+ return type(geometry)(type=geometry.type, coordinates=new_coords)
148
+
149
+
150
+ def translate_geometry(
151
+ geometry: Geometry, dx: float, dy: float, dz: float
152
+ ) -> Geometry:
153
+ """Translate a geometry by (dx, dy, dz).
154
+
155
+ Convenience wrapper that builds a translation matrix and calls
156
+ ``apply_transform()``.
157
+ """
158
+ t = AffineTransform(
159
+ type="affine",
160
+ matrix=[
161
+ [1, 0, 0, dx],
162
+ [0, 1, 0, dy],
163
+ [0, 0, 1, dz],
164
+ [0, 0, 0, 1],
165
+ ],
166
+ )
167
+ return apply_transform(geometry, t)
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Coordinate system conversions
172
+ # ---------------------------------------------------------------------------
173
+
174
+ def voxel_to_physical(
175
+ coords: Tuple[float, float, float],
176
+ vcs: VoxelCoordinateSystem,
177
+ ) -> Tuple[float, float, float]:
178
+ """Convert voxel coordinates to physical coordinates.
179
+
180
+ Physical = voxel * resolution + origin
181
+ """
182
+ return (
183
+ coords[0] * vcs.resolution[0] + vcs.origin[0],
184
+ coords[1] * vcs.resolution[1] + vcs.origin[1],
185
+ coords[2] * vcs.resolution[2] + vcs.origin[2],
186
+ )
187
+
188
+
189
+ def physical_to_voxel(
190
+ coords: Tuple[float, float, float],
191
+ vcs: VoxelCoordinateSystem,
192
+ ) -> Tuple[float, float, float]:
193
+ """Convert physical coordinates to voxel coordinates.
194
+
195
+ Voxel = (physical - origin) / resolution
196
+ """
197
+ return (
198
+ (coords[0] - vcs.origin[0]) / vcs.resolution[0],
199
+ (coords[1] - vcs.origin[1]) / vcs.resolution[1],
200
+ (coords[2] - vcs.origin[2]) / vcs.resolution[2],
201
+ )
@@ -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
@@ -0,0 +1,11 @@
1
+ mudm/__init__.py,sha256=ruXm9Av8zEyUqTL8t-wiEmoCYV1GyONlqJkLNLjZA3g,717
2
+ mudm/layout.py,sha256=JmEObvGDBHJgU5u1cmi7SYXGAHzZWF815A-g6up8NGg,8434
3
+ mudm/model.py,sha256=HnzwMlYELVHrRdkeSgnQRPU7BtgmzzfmkasEA1qqWYk,9099
4
+ mudm/provenance.py,sha256=wEHENfWwvO1qFfRafFVg1-3r3rQeMP2KqiAKFLsiqtQ,1492
5
+ mudm/tilemodel.py,sha256=_Ecc_lLTeScs0sSrHt1svRXgPD_nsS7gg9zVNYoKepY,7435
6
+ mudm/transforms.py,sha256=Loq3Wktyxx_9E1xyPzf5ey7h2marseaObn6vdrTZcWg,6229
7
+ mudm-0.5.0.dist-info/licenses/LICENSE,sha256=7KWudZy89D-fI8yqUKFp5e4YTpzpac2Hv-3jCDUwhiQ,1078
8
+ mudm-0.5.0.dist-info/METADATA,sha256=euxy-x_Y-UjsWxjUOBp0oUlidBzQ6i702Yuag-JCcjc,2089
9
+ mudm-0.5.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ mudm-0.5.0.dist-info/top_level.txt,sha256=wvZfgiJvmL_ITRXDFkafSwnLREBTZOqN8j6H0FkeI8U,5
11
+ mudm-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ mudm