ome-arrow 0.0.4__py3-none-any.whl → 0.0.6__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.
ome_arrow/__init__.py CHANGED
@@ -4,10 +4,17 @@ Init file for ome_arrow package.
4
4
 
5
5
  from ome_arrow._version import version as ome_arrow_version
6
6
  from ome_arrow.core import OMEArrow
7
- from ome_arrow.export import to_numpy, to_ome_parquet, to_ome_tiff, to_ome_zarr
7
+ from ome_arrow.export import (
8
+ to_numpy,
9
+ to_ome_parquet,
10
+ to_ome_tiff,
11
+ to_ome_vortex,
12
+ to_ome_zarr,
13
+ )
8
14
  from ome_arrow.ingest import (
9
15
  from_numpy,
10
16
  from_ome_parquet,
17
+ from_ome_vortex,
11
18
  from_ome_zarr,
12
19
  from_tiff,
13
20
  to_ome_arrow,
ome_arrow/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.0.4'
32
- __version_tuple__ = version_tuple = (0, 0, 4)
31
+ __version__ = version = '0.0.6'
32
+ __version_tuple__ = version_tuple = (0, 0, 6)
33
33
 
34
34
  __commit_id__ = commit_id = None
ome_arrow/core.py CHANGED
@@ -11,10 +11,17 @@ import matplotlib
11
11
  import numpy as np
12
12
  import pyarrow as pa
13
13
 
14
- from ome_arrow.export import to_numpy, to_ome_parquet, to_ome_tiff, to_ome_zarr
14
+ from ome_arrow.export import (
15
+ to_numpy,
16
+ to_ome_parquet,
17
+ to_ome_tiff,
18
+ to_ome_vortex,
19
+ to_ome_zarr,
20
+ )
15
21
  from ome_arrow.ingest import (
16
22
  from_numpy,
17
23
  from_ome_parquet,
24
+ from_ome_vortex,
18
25
  from_ome_zarr,
19
26
  from_stack_pattern_path,
20
27
  from_tiff,
@@ -52,6 +59,7 @@ class OMEArrow:
52
59
  tcz: Tuple[int, int, int] = (0, 0, 0),
53
60
  column_name: str = "ome_arrow",
54
61
  row_index: int = 0,
62
+ image_type: str | None = None,
55
63
  ) -> None:
56
64
  """
57
65
  Construct an OMEArrow from:
@@ -59,10 +67,12 @@ class OMEArrow:
59
67
  - a path/URL to an OME-TIFF (.tif/.tiff)
60
68
  - a path/URL to an OME-Zarr store (.zarr / .ome.zarr)
61
69
  - a path/URL to an OME-Parquet file (.parquet / .pq)
70
+ - a path/URL to a Vortex file (.vortex)
62
71
  - a NumPy ndarray (2D-5D; interpreted
63
72
  with from_numpy defaults)
64
73
  - a dict already matching the OME-Arrow schema
65
74
  - a pa.StructScalar already typed to OME_ARROW_STRUCT
75
+ - optionally override/set image_type metadata on ingest
66
76
  """
67
77
 
68
78
  # set the tcz for viewing
@@ -75,6 +85,7 @@ class OMEArrow:
75
85
  default_dim_for_unspecified="C",
76
86
  map_series_to="T",
77
87
  clamp_to_uint16=True,
88
+ image_type=image_type,
78
89
  )
79
90
 
80
91
  # --- 2) String path/URL: OME-Zarr / OME-Parquet / OME-TIFF ---------------
@@ -90,6 +101,8 @@ class OMEArrow:
90
101
  or (path.exists() and path.is_dir() and path.suffix.lower() == ".zarr")
91
102
  ):
92
103
  self.data = from_ome_zarr(s)
104
+ if image_type is not None:
105
+ self.data = self._wrap_with_image_type(self.data, image_type)
93
106
 
94
107
  # OME-Parquet
95
108
  elif s.lower().endswith((".parquet", ".pq")) or path.suffix.lower() in {
@@ -99,12 +112,24 @@ class OMEArrow:
99
112
  self.data = from_ome_parquet(
100
113
  s, column_name=column_name, row_index=row_index
101
114
  )
115
+ if image_type is not None:
116
+ self.data = self._wrap_with_image_type(self.data, image_type)
117
+
118
+ # Vortex
119
+ elif s.lower().endswith(".vortex") or path.suffix.lower() == ".vortex":
120
+ self.data = from_ome_vortex(
121
+ s, column_name=column_name, row_index=row_index
122
+ )
123
+ if image_type is not None:
124
+ self.data = self._wrap_with_image_type(self.data, image_type)
102
125
 
103
126
  # TIFF
104
127
  elif path.suffix.lower() in {".tif", ".tiff"} or s.lower().endswith(
105
128
  (".tif", ".tiff")
106
129
  ):
107
130
  self.data = from_tiff(s)
131
+ if image_type is not None:
132
+ self.data = self._wrap_with_image_type(self.data, image_type)
108
133
 
109
134
  elif path.exists() and path.is_dir():
110
135
  raise ValueError(
@@ -117,6 +142,7 @@ class OMEArrow:
117
142
  " • Bio-Formats pattern string (contains '<', '>' or '*')\n"
118
143
  " • OME-Zarr path/URL ending with '.zarr' or '.ome.zarr'\n"
119
144
  " • OME-Parquet file ending with '.parquet' or '.pq'\n"
145
+ " • Vortex file ending with '.vortex'\n"
120
146
  " • OME-TIFF path/URL ending with '.tif' or '.tiff'"
121
147
  )
122
148
 
@@ -125,15 +151,20 @@ class OMEArrow:
125
151
  # Uses from_numpy defaults: dim_order="TCZYX", clamp_to_uint16=True, etc.
126
152
  # If the array is YX/ZYX/CYX/etc.,
127
153
  # from_numpy will expand/reorder accordingly.
128
- self.data = from_numpy(data)
154
+ self.data = from_numpy(data, image_type=image_type)
129
155
 
130
156
  # --- 4) Already-typed Arrow scalar ---------------------------------------
131
157
  elif isinstance(data, pa.StructScalar):
132
158
  self.data = data
159
+ if image_type is not None:
160
+ self.data = self._wrap_with_image_type(self.data, image_type)
133
161
 
134
162
  # --- 5) Plain dict matching the schema -----------------------------------
135
163
  elif isinstance(data, dict):
136
- self.data = pa.scalar(data, type=OME_ARROW_STRUCT)
164
+ record = {f.name: data.get(f.name) for f in OME_ARROW_STRUCT}
165
+ self.data = pa.scalar(record, type=OME_ARROW_STRUCT)
166
+ if image_type is not None:
167
+ self.data = self._wrap_with_image_type(self.data, image_type)
137
168
 
138
169
  # --- otherwise ------------------------------------------------------------
139
170
  else:
@@ -141,7 +172,19 @@ class OMEArrow:
141
172
  "input data must be str, dict, pa.StructScalar, or numpy.ndarray"
142
173
  )
143
174
 
144
- def export(
175
+ @staticmethod
176
+ def _wrap_with_image_type(
177
+ data: pa.StructScalar, image_type: str
178
+ ) -> pa.StructScalar:
179
+ return pa.scalar(
180
+ {
181
+ **data.as_py(),
182
+ "image_type": str(image_type),
183
+ },
184
+ type=OME_ARROW_STRUCT,
185
+ )
186
+
187
+ def export( # noqa: PLR0911
145
188
  self,
146
189
  how: str = "numpy",
147
190
  dtype: np.dtype = np.uint16,
@@ -165,6 +208,8 @@ class OMEArrow:
165
208
  parquet_column_name: str = "ome_arrow",
166
209
  parquet_compression: str | None = "zstd",
167
210
  parquet_metadata: dict[str, str] | None = None,
211
+ vortex_column_name: str = "ome_arrow",
212
+ vortex_metadata: dict[str, str] | None = None,
168
213
  ) -> np.array | dict | pa.StructScalar | str:
169
214
  """
170
215
  Export the OME-Arrow content in a chosen representation.
@@ -178,6 +223,7 @@ class OMEArrow:
178
223
  "ome-tiff" → write OME-TIFF via BioIO
179
224
  "ome-zarr" → write OME-Zarr (OME-NGFF) via BioIO
180
225
  "parquet" → write a single-row Parquet with one struct column
226
+ "vortex" → write a single-row Vortex file with one struct column
181
227
  dtype:
182
228
  Target dtype for "numpy"/writers (default: np.uint16).
183
229
  strict:
@@ -194,11 +240,14 @@ class OMEArrow:
194
240
  compression / compression_level / tile:
195
241
  OME-TIFF options (passed through to tifffile via BioIO).
196
242
  chunks / zarr_compressor / zarr_level :
197
- OME-Zarr options (chunk shape, compressor hint, level).
243
+ OME-Zarr options (chunk shape, compressor hint, level). If chunks is
244
+ None, a TCZYX default is chosen (1,1,<=4,<=512,<=512).
198
245
  use_channel_colors:
199
246
  Try to embed per-channel display colors when safe; otherwise omitted.
200
247
  parquet_*:
201
248
  Options for Parquet export (column name, compression, file metadata).
249
+ vortex_*:
250
+ Options for Vortex export (column name, file metadata).
202
251
 
203
252
  Returns
204
253
  -------
@@ -209,6 +258,7 @@ class OMEArrow:
209
258
  - "ome-tiff": output path (str)
210
259
  - "ome-zarr": output path (str)
211
260
  - "parquet": output path (str)
261
+ - "vortex": output path (str)
212
262
 
213
263
  Raises
214
264
  ------
@@ -271,6 +321,18 @@ class OMEArrow:
271
321
  )
272
322
  return out
273
323
 
324
+ # Vortex (single row, single struct column)
325
+ if mode in {"ome-vortex", "omevortex", "vortex"}:
326
+ if not out:
327
+ raise ValueError("export(how='vortex') requires 'out' path.")
328
+ to_ome_vortex(
329
+ data=self.data,
330
+ out_path=out,
331
+ column_name=vortex_column_name,
332
+ file_metadata=vortex_metadata,
333
+ )
334
+ return out
335
+
274
336
  raise ValueError(f"Unknown export method: {how}")
275
337
 
276
338
  def info(self) -> Dict[str, Any]:
@@ -299,7 +361,7 @@ class OMEArrow:
299
361
  opacity: str | float = "sigmoid",
300
362
  clim: tuple[float, float] | None = None,
301
363
  show_axes: bool = True,
302
- scaling_values: tuple[float, float, float] | None = (1.0, 0.1, 0.1),
364
+ scaling_values: tuple[float, float, float] | None = None,
303
365
  ) -> matplotlib.figure.Figure | "pyvista.Plotter":
304
366
  """
305
367
  Render an OME-Arrow record using Matplotlib or PyVista.
@@ -337,7 +399,10 @@ class OMEArrow:
337
399
  clim: Contrast limits (``(low, high)``) for PyVista rendering.
338
400
  show_axes: If ``True``, display axes in the PyVista scene.
339
401
  scaling_values: Physical scale multipliers for the (x, y, z) axes used by
340
- PyVista, typically to express anisotropy. Defaults to ``(1.0, 0.1, 0.1)``.
402
+ PyVista, typically to express anisotropy. If ``None``, uses metadata
403
+ scaling from the OME-Arrow record (pixels_meta.physical_size_x/y/z).
404
+ These scaling values will default to 1µm if metadata is missing in
405
+ source image metadata.
341
406
 
342
407
  Returns:
343
408
  matplotlib.figure.Figure | pyvista.Plotter:
ome_arrow/export.py CHANGED
@@ -21,7 +21,8 @@ def to_numpy(
21
21
  Convert an OME-Arrow record into a NumPy array shaped (T,C,Z,Y,X).
22
22
 
23
23
  The OME-Arrow "planes" are flattened YX slices indexed by (z, t, c).
24
- This function reconstitutes them into a dense TCZYX ndarray.
24
+ When chunks are present, this function reconstitutes the dense TCZYX array
25
+ from chunked pixels instead of planes.
25
26
 
26
27
  Args:
27
28
  data:
@@ -58,7 +59,7 @@ def to_numpy(
58
59
  if sx <= 0 or sy <= 0 or sz <= 0 or sc <= 0 or st <= 0:
59
60
  raise ValueError("All size_* fields must be positive integers.")
60
61
 
61
- expected_len = sx * sy
62
+ expected_plane_len = sx * sy
62
63
 
63
64
  # Prepare target array (T,C,Z,Y,X), zero-filled by default.
64
65
  out = np.zeros((st, sc, sz, sy, sx), dtype=dtype)
@@ -78,6 +79,70 @@ def to_numpy(
78
79
  a = np.clip(a, lo, hi)
79
80
  return a.astype(dtype, copy=False)
80
81
 
82
+ chunks = data.get("chunks") or []
83
+ if chunks:
84
+ chunk_grid = data.get("chunk_grid") or {}
85
+ chunk_order = str(chunk_grid.get("chunk_order") or "ZYX").upper()
86
+ if chunk_order != "ZYX":
87
+ raise ValueError("Only chunk_order='ZYX' is supported for now.")
88
+
89
+ for i, ch in enumerate(chunks):
90
+ # Chunk coordinates include time/channel plus spatial indices.
91
+ t = int(ch["t"])
92
+ c = int(ch["c"])
93
+ z = int(ch["z"])
94
+ y = int(ch["y"])
95
+ x = int(ch["x"])
96
+ # Chunk shape is only spatial (Z, Y, X).
97
+ shape_z = int(ch["shape_z"])
98
+ shape_y = int(ch["shape_y"])
99
+ shape_x = int(ch["shape_x"])
100
+
101
+ # Validate chunk indices and extents within the full 5D array.
102
+ if not (0 <= t < st and 0 <= c < sc and 0 <= z < sz):
103
+ raise ValueError(
104
+ f"chunks[{i}] index out of range: (t,c,z)=({t},{c},{z})"
105
+ )
106
+ if y < 0 or x < 0 or shape_z <= 0 or shape_y <= 0 or shape_x <= 0:
107
+ raise ValueError(f"chunks[{i}] has invalid shape or origin.")
108
+ if z + shape_z > sz:
109
+ raise ValueError(
110
+ f"chunks[{i}] extent out of range: z+shape_z={z + shape_z} "
111
+ f"> sz={sz}"
112
+ )
113
+ if y + shape_y > sy:
114
+ raise ValueError(
115
+ f"chunks[{i}] extent out of range: y+shape_y={y + shape_y} "
116
+ f"> sy={sy}"
117
+ )
118
+ if x + shape_x > sx:
119
+ raise ValueError(
120
+ f"chunks[{i}] extent out of range: x+shape_x={x + shape_x} "
121
+ f"> sx={sx}"
122
+ )
123
+
124
+ pix = ch["pixels"]
125
+ try:
126
+ n = len(pix)
127
+ except Exception as e:
128
+ raise ValueError(f"chunks[{i}].pixels is not a sequence") from e
129
+
130
+ expected_len = shape_z * shape_y * shape_x
131
+ if n != expected_len:
132
+ if strict:
133
+ raise ValueError(
134
+ f"chunks[{i}].pixels length {n} != expected {expected_len}"
135
+ )
136
+ if n > expected_len:
137
+ pix = pix[:expected_len]
138
+ else:
139
+ pix = list(pix) + [0] * (expected_len - n)
140
+
141
+ arr3d = np.asarray(pix).reshape(shape_z, shape_y, shape_x)
142
+ arr3d = _cast_plane(arr3d)
143
+ out[t, c, z : z + shape_z, y : y + shape_y, x : x + shape_x] = arr3d
144
+ return out
145
+
81
146
  # Fill planes.
82
147
  for i, p in enumerate(data.get("planes", [])):
83
148
  z = int(p["z"])
@@ -94,16 +159,17 @@ def to_numpy(
94
159
  except Exception as e:
95
160
  raise ValueError(f"planes[{i}].pixels is not a sequence") from e
96
161
 
97
- if n != expected_len:
162
+ if n != expected_plane_len:
98
163
  if strict:
99
164
  raise ValueError(
100
- f"planes[{i}].pixels length {n} != size_x*size_y {expected_len}"
165
+ f"planes[{i}].pixels length {n} != size_x*size_y "
166
+ f"{expected_plane_len}"
101
167
  )
102
168
  # Lenient mode: fix length by truncation or zero-pad.
103
- if n > expected_len:
104
- pix = pix[:expected_len]
169
+ if n > expected_plane_len:
170
+ pix = pix[:expected_plane_len]
105
171
  else:
106
- pix = list(pix) + [0] * (expected_len - n)
172
+ pix = list(pix) + [0] * (expected_plane_len - n)
107
173
 
108
174
  # Reshape to (Y,X) and cast.
109
175
  arr2d = np.asarray(pix).reshape(sy, sx)
@@ -113,6 +179,162 @@ def to_numpy(
113
179
  return out
114
180
 
115
181
 
182
+ # Note: x/y are implicit because this returns the full XY plane for (t, c, z).
183
+ def plane_from_chunks(
184
+ data: Dict[str, Any] | pa.StructScalar,
185
+ *,
186
+ t: int,
187
+ c: int,
188
+ z: int,
189
+ dtype: np.dtype = np.uint16,
190
+ strict: bool = True,
191
+ clamp: bool = False,
192
+ ) -> np.ndarray:
193
+ """Extract a single (t, c, z) plane using chunked pixels when available.
194
+
195
+ Args:
196
+ data: OME-Arrow data as a Python dict or a `pa.StructScalar`.
197
+ t: Time index for the plane.
198
+ c: Channel index for the plane.
199
+ z: Z index for the plane.
200
+ dtype: Output dtype (default: np.uint16).
201
+ strict: When True, raise if chunk pixels are malformed.
202
+ clamp: If True, clamp values to the valid range of the target dtype.
203
+
204
+ Returns:
205
+ np.ndarray: 2D array with shape (Y, X).
206
+
207
+ Raises:
208
+ KeyError: If required OME-Arrow fields are missing.
209
+ ValueError: If indices are out of range or pixels are malformed.
210
+ """
211
+ # The plane spans full X/Y for the given (t, c, z); x/y are implicit.
212
+ if isinstance(data, pa.StructScalar):
213
+ data = data.as_py()
214
+
215
+ # Read pixel metadata and validate requested plane indices.
216
+ pm = data["pixels_meta"]
217
+ sx, sy = int(pm["size_x"]), int(pm["size_y"])
218
+ sz, sc, st = int(pm["size_z"]), int(pm["size_c"]), int(pm["size_t"])
219
+ if not (0 <= t < st and 0 <= c < sc and 0 <= z < sz):
220
+ raise ValueError(f"Requested plane (t={t}, c={c}, z={z}) out of range.")
221
+
222
+ # Prepare dtype conversion (optional clamping for integer outputs).
223
+ if np.issubdtype(dtype, np.integer):
224
+ info = np.iinfo(dtype)
225
+ lo, hi = info.min, info.max
226
+ elif np.issubdtype(dtype, np.floating):
227
+ lo, hi = -np.inf, np.inf
228
+ else:
229
+ lo, hi = -np.inf, np.inf
230
+
231
+ def _cast_plane(a: np.ndarray) -> np.ndarray:
232
+ if clamp:
233
+ a = np.clip(a, lo, hi)
234
+ return a.astype(dtype, copy=False)
235
+
236
+ # Prefer chunked pixels if present, assembling the requested Z plane.
237
+ chunks = data.get("chunks") or []
238
+ if chunks:
239
+ chunk_grid = data.get("chunk_grid") or {}
240
+ chunk_order = str(chunk_grid.get("chunk_order") or "ZYX").upper()
241
+ if chunk_order != "ZYX":
242
+ raise ValueError("Only chunk_order='ZYX' is supported for now.")
243
+
244
+ # Allocate an empty XY plane; fill in tiles from matching chunks.
245
+ plane = np.zeros((sy, sx), dtype=dtype)
246
+ any_chunk_matched = False
247
+ for i, ch in enumerate(chunks):
248
+ # Skip chunks from other (t, c) positions.
249
+ if int(ch["t"]) != t or int(ch["c"]) != c:
250
+ continue
251
+ z0 = int(ch["z"])
252
+ szc = int(ch["shape_z"])
253
+ # Skip chunks whose Z slab does not cover the target plane.
254
+ if not (z0 <= z < z0 + szc):
255
+ continue
256
+ y0 = int(ch["y"])
257
+ x0 = int(ch["x"])
258
+ syc = int(ch["shape_y"])
259
+ sxc = int(ch["shape_x"])
260
+ # Validate chunk bounds (strict mode can fail fast).
261
+ if z0 < 0 or y0 < 0 or x0 < 0:
262
+ msg = f"chunks[{i}] has negative origin: (z,y,x)=({z0},{y0},{x0})"
263
+ if strict:
264
+ raise ValueError(msg)
265
+ continue
266
+ if z0 + szc > sz:
267
+ msg = f"chunks[{i}] extent out of range: z+shape_z={z0 + szc} > sz={sz}"
268
+ if strict:
269
+ raise ValueError(msg)
270
+ continue
271
+ if y0 + syc > sy:
272
+ msg = f"chunks[{i}] extent out of range: y+shape_y={y0 + syc} > sy={sy}"
273
+ if strict:
274
+ raise ValueError(msg)
275
+ continue
276
+ if x0 + sxc > sx:
277
+ msg = f"chunks[{i}] extent out of range: x+shape_x={x0 + sxc} > sx={sx}"
278
+ if strict:
279
+ raise ValueError(msg)
280
+ continue
281
+ pix = ch["pixels"]
282
+ try:
283
+ n = len(pix)
284
+ except Exception as e:
285
+ raise ValueError(f"chunks[{i}].pixels is not a sequence") from e
286
+ expected_len = szc * syc * sxc
287
+ if n != expected_len:
288
+ if strict:
289
+ raise ValueError(
290
+ f"chunks[{i}].pixels length {n} != expected {expected_len}"
291
+ )
292
+ # Lenient mode: truncate or zero-pad to match the expected size.
293
+ if n > expected_len:
294
+ pix = pix[:expected_len]
295
+ else:
296
+ pix = list(pix) + [0] * (expected_len - n)
297
+
298
+ # Convert to a Z/Y/X slab and copy the requested Z slice into the plane.
299
+ slab = np.asarray(pix).reshape(szc, syc, sxc)
300
+ slab = _cast_plane(slab)
301
+ zi = z - z0
302
+ plane[y0 : y0 + syc, x0 : x0 + sxc] = slab[zi]
303
+ any_chunk_matched = True
304
+
305
+ if any_chunk_matched:
306
+ return plane
307
+
308
+ # Fallback to planes list if chunks are absent.
309
+ target = next(
310
+ (
311
+ p
312
+ for p in data.get("planes", [])
313
+ if int(p["t"]) == t and int(p["c"]) == c and int(p["z"]) == z
314
+ ),
315
+ None,
316
+ )
317
+ if target is None:
318
+ raise ValueError(f"plane (t={t}, c={c}, z={z}) not found")
319
+
320
+ pix = target["pixels"]
321
+ try:
322
+ n = len(pix)
323
+ except Exception as e:
324
+ raise ValueError("plane pixels is not a sequence") from e
325
+ expected_len = sx * sy
326
+ if n != expected_len:
327
+ if strict:
328
+ raise ValueError(f"plane pixels length {n} != size_x*size_y {expected_len}")
329
+ if n > expected_len:
330
+ pix = pix[:expected_len]
331
+ else:
332
+ pix = list(pix) + [0] * (expected_len - n)
333
+
334
+ arr2d = np.asarray(pix).reshape(sy, sx)
335
+ return _cast_plane(arr2d)
336
+
337
+
116
338
  def to_ome_tiff(
117
339
  data: Dict[str, Any] | pa.StructScalar,
118
340
  out_path: str,
@@ -255,6 +477,7 @@ def to_ome_zarr(
255
477
  - Creates level shapes for a multiscale pyramid (if multiscale_levels>1).
256
478
  - Chooses Blosc codec compatible with zarr_format (v2 vs v3).
257
479
  - Populates axes names/types/units and physical pixel sizes from pixels_meta.
480
+ - Uses default TCZYX chunks if none are provided.
258
481
  """
259
482
  # --- local import to avoid hard deps at module import time
260
483
  # Use the class you showed
@@ -317,6 +540,15 @@ def to_ome_zarr(
317
540
  def _down(a: int, f: int) -> int:
318
541
  return max(1, a // f)
319
542
 
543
+ def _default_chunks_tcxyz(
544
+ shape: Tuple[int, int, int, int, int],
545
+ ) -> Tuple[int, int, int, int, int]:
546
+ _t, _c, z, y, x = shape
547
+ cz = min(z, 4) if z > 1 else 1
548
+ cy = min(y, 512)
549
+ cx = min(x, 512)
550
+ return (1, 1, cz, cy, cx)
551
+
320
552
  def _level_shapes_tcxyz(levels: int) -> List[Tuple[int, int, int, int, int]]:
321
553
  shapes = [(st, sc, sz, sy, sx)]
322
554
  for _ in range(levels - 1):
@@ -340,6 +572,8 @@ def to_ome_zarr(
340
572
  # 5) Chunking / shards (can be single-shape or per-level;
341
573
  # we pass single-shape if provided)
342
574
  chunk_shape: Optional[List[Tuple[int, ...]]] = None
575
+ if chunks is None:
576
+ chunks = _default_chunks_tcxyz((st, sc, sz, sy, sx))
343
577
  if chunks is not None:
344
578
  chunk_shape = [tuple(int(v) for v in chunks)] * multiscale_levels
345
579
 
@@ -393,7 +627,8 @@ def to_ome_parquet(
393
627
  record_dict = data.as_py()
394
628
  else:
395
629
  # Validate by round-tripping through a typed scalar, then back to dict.
396
- record_dict = pa.scalar(data, type=OME_ARROW_STRUCT).as_py()
630
+ record_dict = {f.name: data.get(f.name) for f in OME_ARROW_STRUCT}
631
+ record_dict = pa.scalar(record_dict, type=OME_ARROW_STRUCT).as_py()
397
632
 
398
633
  # 2) Build a single-row struct array from the dict, explicitly passing the schema
399
634
  struct_array = pa.array([record_dict], type=OME_ARROW_STRUCT) # len=1
@@ -420,3 +655,62 @@ def to_ome_parquet(
420
655
  compression=compression,
421
656
  row_group_size=row_group_size,
422
657
  )
658
+
659
+
660
+ def to_ome_vortex(
661
+ data: Dict[str, Any] | pa.StructScalar,
662
+ out_path: str,
663
+ column_name: str = "image",
664
+ file_metadata: Optional[Dict[str, str]] = None,
665
+ ) -> None:
666
+ """Export an OME-Arrow record to a Vortex file.
667
+
668
+ The file is written as a single-row, single-column Arrow table where the
669
+ column holds a struct with the OME-Arrow schema.
670
+
671
+ Args:
672
+ data: OME-Arrow dict or StructScalar.
673
+ out_path: Output path for the Vortex file.
674
+ column_name: Column name to store the struct.
675
+ file_metadata: Optional file-level metadata to attach.
676
+
677
+ Raises:
678
+ ImportError: If the optional `vortex-data` dependency is missing.
679
+ """
680
+
681
+ try:
682
+ import vortex.io as vxio
683
+ except ImportError as exc:
684
+ raise ImportError(
685
+ "Vortex export requires the optional 'vortex-data' dependency."
686
+ ) from exc
687
+
688
+ # 1) Normalize to a plain Python dict (works better with pyarrow builders,
689
+ # especially when the struct has a `null`-typed field like "masks").
690
+ if isinstance(data, pa.StructScalar):
691
+ record_dict = data.as_py()
692
+ else:
693
+ # Validate by round-tripping through a typed scalar, then back to dict.
694
+ record_dict = {f.name: data.get(f.name) for f in OME_ARROW_STRUCT}
695
+ record_dict = pa.scalar(record_dict, type=OME_ARROW_STRUCT).as_py()
696
+
697
+ # 2) Build a single-row struct array from the dict, explicitly passing the schema
698
+ struct_array = pa.array([record_dict], type=OME_ARROW_STRUCT) # len=1
699
+
700
+ # 3) Wrap into a one-column table
701
+ table = pa.table({column_name: struct_array})
702
+
703
+ # 4) Attach optional file-level metadata
704
+ meta: Dict[bytes, bytes] = dict(table.schema.metadata or {})
705
+ try:
706
+ meta[b"ome.arrow.type"] = str(OME_ARROW_TAG_TYPE).encode("utf-8")
707
+ meta[b"ome.arrow.version"] = str(OME_ARROW_TAG_VERSION).encode("utf-8")
708
+ except Exception:
709
+ pass
710
+ if file_metadata:
711
+ for k, v in file_metadata.items():
712
+ meta[str(k).encode("utf-8")] = str(v).encode("utf-8")
713
+ table = table.replace_schema_metadata(meta)
714
+
715
+ # 5) Write Vortex (single row, single column)
716
+ vxio.write(table, str(out_path))