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 +24 -0
- mudm/layout.py +276 -0
- mudm/model.py +255 -0
- mudm/provenance.py +46 -0
- mudm/tilemodel.py +235 -0
- mudm/transforms.py +201 -0
- mudm-0.5.0.dist-info/METADATA +68 -0
- mudm-0.5.0.dist-info/RECORD +11 -0
- mudm-0.5.0.dist-info/WHEEL +5 -0
- mudm-0.5.0.dist-info/licenses/LICENSE +21 -0
- mudm-0.5.0.dist-info/top_level.txt +1 -0
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,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
|