ioimage 0.1.2__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.

Potentially problematic release.


This version of ioimage might be problematic. Click here for more details.

ioimage/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """ioimage: read and write microscopy images (TIFF, OME-TIFF, OME-Zarr) with one API.
2
+
3
+ Lazy region reads, pyramids, sharding, and a format-independent metadata model that keeps the
4
+ original metadata blocks verbatim (see `docs/design.md`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version(__name__)
13
+ except PackageNotFoundError: # running from a source tree that is not installed
14
+ __version__ = "0.0.0+unknown"
15
+
16
+ from .errors import FormatError, MetadataDropped
17
+ from .image import (
18
+ Image,
19
+ convert,
20
+ create_memmap,
21
+ open, # noqa: A004 - `ioimage.open` mirrors the builtin on purpose
22
+ read,
23
+ write,
24
+ )
25
+ from .metadata import Acquisition, Channel, ImageMetadata, MetadataError, Objective, RawBlocks
26
+
27
+ __all__ = (
28
+ "Acquisition",
29
+ "Channel",
30
+ "FormatError",
31
+ "Image",
32
+ "ImageMetadata",
33
+ "MetadataDropped",
34
+ "MetadataError",
35
+ "Objective",
36
+ "RawBlocks",
37
+ "__version__",
38
+ "convert",
39
+ "create_memmap",
40
+ "open",
41
+ "read",
42
+ "write",
43
+ )
ioimage/_omexml.py ADDED
@@ -0,0 +1,504 @@
1
+ """OME-XML: parse the subset ioimage models and generate OME-XML for OME-TIFF and OME-Zarr.
2
+
3
+ Only the standard library is used (`xml.etree.ElementTree`). The original OME-XML of a source
4
+ file is always kept verbatim in `ImageMetadata.raw.ome_xml`; this module extracts structured
5
+ fields from it and writes new OME-XML (schema 2016-06) whose element order follows the schema:
6
+ Instrument before Image, then StructuredAnnotations; inside Image: AcquisitionDate, Description,
7
+ InstrumentRef, ObjectiveSettings, Pixels (Channel*, TiffData*, Plane*), AnnotationRef*.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import uuid
14
+ import xml.etree.ElementTree as ET
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+
19
+ from .metadata import (
20
+ OME_UNITS,
21
+ Acquisition,
22
+ Channel,
23
+ ImageMetadata,
24
+ Objective,
25
+ normalize_length_unit,
26
+ normalize_time_unit,
27
+ )
28
+
29
+ OME_NS = "http://www.openmicroscopy.org/Schemas/OME/2016-06"
30
+ XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
31
+ SCHEMA_LOCATION = f"{OME_NS} {OME_NS}/ome.xsd"
32
+ IOIMAGE_NAMESPACE = "ioimage"
33
+
34
+ _IMMERSIONS = {"oil", "water", "waterdipping", "air", "multi", "glycerol", "other"}
35
+ _IMMERSION_SPELLING = {
36
+ "oil": "Oil",
37
+ "water": "Water",
38
+ "waterdipping": "WaterDipping",
39
+ "air": "Air",
40
+ "multi": "Multi",
41
+ "glycerol": "Glycerol",
42
+ "other": "Other",
43
+ }
44
+
45
+ _OME_PIXEL_TYPES: dict[str, str] = {
46
+ "int8": "int8",
47
+ "int16": "int16",
48
+ "int32": "int32",
49
+ "uint8": "uint8",
50
+ "uint16": "uint16",
51
+ "uint32": "uint32",
52
+ "float32": "float",
53
+ "float64": "double",
54
+ "complex64": "complex",
55
+ "complex128": "double-complex",
56
+ "bool": "bit",
57
+ }
58
+ _NUMPY_FROM_OME: dict[str, str] = {v: k for k, v in _OME_PIXEL_TYPES.items()}
59
+
60
+
61
+ def _q(tag: str) -> str:
62
+ return f"{{{OME_NS}}}{tag}"
63
+
64
+
65
+ def _strip_ns(tag: str) -> str:
66
+ return tag.rsplit("}", 1)[-1]
67
+
68
+
69
+ def _float(value: str | None) -> float | None:
70
+ if value is None or value == "":
71
+ return None
72
+ try:
73
+ return float(value)
74
+ except ValueError:
75
+ return None
76
+
77
+
78
+ def ome_color_to_hex(value: str | None) -> str | None:
79
+ """OME `Color` (signed 32-bit RGBA integer) -> 'RRGGBBAA'."""
80
+ if value is None:
81
+ return None
82
+ try:
83
+ i = int(value)
84
+ except ValueError:
85
+ return None
86
+ return f"{i & 0xFFFFFFFF:08X}"
87
+
88
+
89
+ def hex_to_ome_color(color: str) -> int:
90
+ """'RRGGBBAA' -> OME signed 32-bit integer."""
91
+ u = int(color, 16)
92
+ return u - (1 << 32) if u >= 1 << 31 else u
93
+
94
+
95
+ # -- parsing -----------------------------------------------------------------------------------
96
+
97
+
98
+ def parse_ome_xml(xml: str, series: int = 0) -> dict[str, Any]:
99
+ """Extract the fields of the `series`-th Image of an OME-XML document.
100
+
101
+ Returns a dict with keys usable as `ImageMetadata` fields (`spacing`, `spacing_unit`, `origin`,
102
+ `time_interval`, `time_unit`, `timestamps`, `channels`, `objective`, `acquisition`,
103
+ `significant_bits`, `name`) plus `dimension_order`, `sizes` (dict letter -> int), `dtype`,
104
+ `n_images`, and `annotations` (dict of ioimage MapAnnotations, keyed without the prefix).
105
+ """
106
+ root = ET.fromstring(xml) # noqa: S314 - local files written by microscopes and by ioimage; defusedxml would be a new dependency
107
+ images = [el for el in root if _strip_ns(el.tag) == "Image"]
108
+ if not images:
109
+ raise ValueError("OME-XML without an Image element")
110
+ if series >= len(images):
111
+ raise ValueError(f"OME-XML has {len(images)} Image elements, series {series} requested")
112
+ image = images[series]
113
+ out: dict[str, Any] = {"n_images": len(images), "name": image.get("Name")}
114
+
115
+ pixels = next((el for el in image if _strip_ns(el.tag) == "Pixels"), None)
116
+ if pixels is None:
117
+ raise ValueError("OME-XML Image without Pixels")
118
+ out["dimension_order"] = pixels.get("DimensionOrder", "XYZCT")
119
+ out["sizes"] = {a: int(pixels.get(f"Size{a}", 1)) for a in "XYZCT"}
120
+ out["dtype"] = _NUMPY_FROM_OME.get(pixels.get("Type", ""))
121
+ bits = pixels.get("SignificantBits")
122
+ out["significant_bits"] = int(bits) if bits else None
123
+
124
+ # spacing on the space axes present (z, y, x order), all or nothing
125
+ phys = {a: _float(pixels.get(f"PhysicalSize{a}")) for a in "XYZ"}
126
+ units = {a: pixels.get(f"PhysicalSize{a}Unit", "µm") for a in "XYZ"}
127
+ has_z = out["sizes"]["Z"] > 1
128
+ needed = "ZYX" if has_z else "YX"
129
+ if all(phys[a] is not None for a in needed):
130
+ unit0 = normalize_length_unit(units[needed[0]])
131
+ spacing = []
132
+ for a in needed:
133
+ u = normalize_length_unit(units[a])
134
+ v = phys[a]
135
+ if v is None: # pragma: no cover - excluded by the `all(...)` test above
136
+ continue
137
+ if u != unit0:
138
+ v = _convert_length(v, u, unit0)
139
+ spacing.append(v)
140
+ out["spacing"] = tuple(spacing)
141
+ out["spacing_unit"] = unit0
142
+ else:
143
+ out["spacing"] = None
144
+ out["spacing_unit"] = None
145
+
146
+ ti = _float(pixels.get("TimeIncrement"))
147
+ out["time_interval"] = ti
148
+ out["time_unit"] = normalize_time_unit(pixels.get("TimeIncrementUnit", "s")) if ti is not None else None
149
+
150
+ channels: list[Channel] = []
151
+ for ch in (el for el in pixels if _strip_ns(el.tag) == "Channel"):
152
+ ex, em = _float(ch.get("ExcitationWavelength")), _float(ch.get("EmissionWavelength"))
153
+ wunit = ch.get("EmissionWavelengthUnit") or ch.get("ExcitationWavelengthUnit") or ("nm" if ex or em else None)
154
+ channels.append(
155
+ Channel(
156
+ name=ch.get("Name"),
157
+ color=ome_color_to_hex(ch.get("Color")),
158
+ excitation_wavelength=ex,
159
+ emission_wavelength=em,
160
+ wavelength_unit=normalize_length_unit(wunit) if wunit else None,
161
+ fluorophore=ch.get("Fluor"),
162
+ )
163
+ )
164
+ out["channels"] = tuple(channels)
165
+
166
+ # planes: origin from the first plane's position, timestamps from DeltaT of the first (z, c) per t
167
+ planes = [el for el in pixels if _strip_ns(el.tag) == "Plane"]
168
+ origin = None
169
+ timestamps: dict[int, float] = {}
170
+ exposure = None
171
+ exposure_unit = None
172
+ delta_unit = None
173
+ for pl in planes:
174
+ if origin is None:
175
+ pos = [_float(pl.get(f"Position{a}")) for a in ("Z", "Y", "X")]
176
+ if has_z and all(p is not None for p in pos):
177
+ punit = normalize_length_unit(pl.get("PositionXUnit", "µm"))
178
+ origin = (tuple(float(p) for p in pos if p is not None), punit)
179
+ elif not has_z and all(p is not None for p in pos[1:]):
180
+ punit = normalize_length_unit(pl.get("PositionXUnit", "µm"))
181
+ origin = (tuple(float(p) for p in pos[1:] if p is not None), punit)
182
+ t = int(pl.get("TheT", 0))
183
+ dt = _float(pl.get("DeltaT"))
184
+ if dt is not None and t not in timestamps and int(pl.get("TheZ", 0)) == 0 and int(pl.get("TheC", 0)) == 0:
185
+ timestamps[t] = dt
186
+ delta_unit = pl.get("DeltaTUnit", "s")
187
+ if exposure is None and pl.get("ExposureTime") is not None:
188
+ exposure = _float(pl.get("ExposureTime"))
189
+ exposure_unit = pl.get("ExposureTimeUnit", "s")
190
+ if origin is not None and out["spacing"] is not None:
191
+ values, punit = origin
192
+ if punit != out["spacing_unit"]:
193
+ values = tuple(_convert_length(v, punit, out["spacing_unit"]) for v in values)
194
+ out["origin"] = values
195
+ else:
196
+ out["origin"] = None
197
+ if timestamps and len(timestamps) == out["sizes"]["T"]:
198
+ out["timestamps"] = tuple(timestamps[t] for t in range(out["sizes"]["T"]))
199
+ if out["time_unit"] is None:
200
+ out["time_unit"] = normalize_time_unit(delta_unit or "s")
201
+ else:
202
+ out["timestamps"] = None
203
+
204
+ # instrument / objective
205
+ objective = None
206
+ acquisition_fields: dict[str, Any] = {}
207
+ settings: list[tuple[str, str]] = []
208
+ obj_settings = next((el for el in image if _strip_ns(el.tag) == "ObjectiveSettings"), None)
209
+ instrument_ref = next((el for el in image if _strip_ns(el.tag) == "InstrumentRef"), None)
210
+ instruments = [el for el in root if _strip_ns(el.tag) == "Instrument"]
211
+ instrument = None
212
+ if instrument_ref is not None:
213
+ instrument = next((i for i in instruments if i.get("ID") == instrument_ref.get("ID")), None)
214
+ if instrument is None and len(instruments) == 1:
215
+ instrument = instruments[0]
216
+ if instrument is not None:
217
+ micro = next((el for el in instrument if _strip_ns(el.tag) == "Microscope"), None)
218
+ if micro is not None:
219
+ parts = [micro.get("Manufacturer"), micro.get("Model")]
220
+ acquisition_fields["instrument"] = " ".join(p for p in parts if p) or None
221
+ if micro.get("Type"):
222
+ settings.append(("Microscope.Type", str(micro.get("Type"))))
223
+ det = next((el for el in instrument if _strip_ns(el.tag) == "Detector"), None)
224
+ if det is not None:
225
+ parts = [det.get("Manufacturer"), det.get("Model")]
226
+ acquisition_fields["detector"] = " ".join(p for p in parts if p) or None
227
+ objectives = [el for el in instrument if _strip_ns(el.tag) == "Objective"]
228
+ obj_el = None
229
+ if obj_settings is not None:
230
+ obj_el = next((o for o in objectives if o.get("ID") == obj_settings.get("ID")), None)
231
+ if obj_el is None and len(objectives) == 1:
232
+ obj_el = objectives[0]
233
+ if obj_el is not None:
234
+ wd = _float(obj_el.get("WorkingDistance"))
235
+ objective = Objective(
236
+ magnification=_float(obj_el.get("NominalMagnification")),
237
+ numerical_aperture=_float(obj_el.get("LensNA")),
238
+ immersion=obj_el.get("Immersion"),
239
+ refractive_index=_float(obj_settings.get("RefractiveIndex")) if obj_settings is not None else None,
240
+ working_distance=wd,
241
+ working_distance_unit=normalize_length_unit(obj_el.get("WorkingDistanceUnit", "µm")) if wd else None,
242
+ model=obj_el.get("Model"),
243
+ manufacturer=obj_el.get("Manufacturer"),
244
+ )
245
+ elif obj_settings is not None and obj_settings.get("RefractiveIndex"):
246
+ objective = Objective(refractive_index=_float(obj_settings.get("RefractiveIndex")))
247
+ out["objective"] = objective
248
+ date = next((el.text for el in image if _strip_ns(el.tag) == "AcquisitionDate"), None)
249
+ acquisition_fields["date"] = date
250
+ acquisition_fields["exposure_time"] = exposure
251
+ acquisition_fields["exposure_time_unit"] = normalize_time_unit(exposure_unit) if exposure is not None else None
252
+
253
+ # structured annotations written by ioimage (and the software name when present)
254
+ annotations: dict[str, Any] = {}
255
+ sa = next((el for el in root if _strip_ns(el.tag) == "StructuredAnnotations"), None)
256
+ if sa is not None:
257
+ for ann in sa:
258
+ if _strip_ns(ann.tag) != "MapAnnotation":
259
+ continue
260
+ if ann.get("Namespace") != IOIMAGE_NAMESPACE:
261
+ continue
262
+ key = (ann.get("ID") or "").rsplit(":", 1)[-1]
263
+ value_el = next((el for el in ann if _strip_ns(el.tag) == "Value"), None)
264
+ if value_el is None:
265
+ continue
266
+ pairs = {m.get("K"): (m.text or "") for m in value_el if _strip_ns(m.tag) == "M"}
267
+ if "json" in pairs:
268
+ try:
269
+ annotations[key] = json.loads(pairs["json"])
270
+ except json.JSONDecodeError:
271
+ annotations[key] = pairs["json"]
272
+ elif "text" in pairs:
273
+ annotations[key] = pairs["text"]
274
+ else:
275
+ annotations[key] = pairs
276
+ out["annotations"] = annotations
277
+ if "acquisition_settings" in annotations and isinstance(annotations["acquisition_settings"], dict):
278
+ acq = annotations["acquisition_settings"]
279
+ acquisition_fields.setdefault("software", acq.get("software"))
280
+ if acq.get("instrument") and not acquisition_fields.get("instrument"):
281
+ acquisition_fields["instrument"] = acq["instrument"]
282
+ if acq.get("detector") and not acquisition_fields.get("detector"):
283
+ acquisition_fields["detector"] = acq["detector"]
284
+ settings.extend((str(k), str(v)) for k, v in (acq.get("settings") or []))
285
+ acquisition = Acquisition(settings=tuple(settings), **acquisition_fields)
286
+ out["acquisition"] = None if acquisition.is_empty() else acquisition
287
+ return out
288
+
289
+
290
+ _LENGTH_IN_METERS: dict[str, float] = {
291
+ "angstrom": 1e-10,
292
+ "nanometer": 1e-9,
293
+ "micrometer": 1e-6,
294
+ "millimeter": 1e-3,
295
+ "centimeter": 1e-2,
296
+ "meter": 1.0,
297
+ "inch": 0.0254,
298
+ }
299
+
300
+
301
+ def _convert_length(value: float, unit_from: str | None, unit_to: str | None) -> float:
302
+ if unit_from == unit_to or unit_from is None or unit_to is None:
303
+ return value
304
+ if unit_from not in _LENGTH_IN_METERS or unit_to not in _LENGTH_IN_METERS:
305
+ raise ValueError(f"cannot convert {unit_from} to {unit_to}")
306
+ return value * _LENGTH_IN_METERS[unit_from] / _LENGTH_IN_METERS[unit_to]
307
+
308
+
309
+ # -- generation --------------------------------------------------------------------------------
310
+
311
+
312
+ def build_ome_xml(
313
+ meta: ImageMetadata,
314
+ *,
315
+ file_name: str | None = None,
316
+ tiff_data: bool = True,
317
+ extra_annotations: dict[str, Any] | None = None,
318
+ ) -> tuple[str, list[str]]:
319
+ """Generate OME-XML for `meta`.
320
+
321
+ Returns the XML string and the list of structured fields that OME-XML could not hold
322
+ (currently none; raw blocks and the frame keys go to `MapAnnotation`s in the `ioimage` namespace).
323
+ With `tiff_data=True` a single `<TiffData IFD="0" PlaneCount="n"/>` describes an OME-TIFF whose
324
+ pages are ordered t, c, z (DimensionOrder XYZCT); with False, `<MetadataOnly/>` is written (OME-Zarr).
325
+ """
326
+ dropped: list[str] = []
327
+ ET.register_namespace("", OME_NS)
328
+ ET.register_namespace("xsi", XSI_NS)
329
+ root = ET.Element(_q("OME"), {f"{{{XSI_NS}}}schemaLocation": SCHEMA_LOCATION, "UUID": f"urn:uuid:{uuid.uuid4()}"})
330
+
331
+ sizes = dict.fromkeys("XYZCT", 1)
332
+ for a, n in zip(meta.axes, meta.shape, strict=True):
333
+ sizes[a.upper()] = n
334
+
335
+ # Instrument (before Image)
336
+ instrument_id = None
337
+ objective_id = None
338
+ if meta.objective is not None or (
339
+ meta.acquisition is not None and (meta.acquisition.instrument or meta.acquisition.detector)
340
+ ):
341
+ instrument_id = "Instrument:0"
342
+ inst = ET.SubElement(root, _q("Instrument"), {"ID": instrument_id})
343
+ if meta.acquisition is not None and meta.acquisition.instrument:
344
+ ET.SubElement(inst, _q("Microscope"), {"Model": meta.acquisition.instrument})
345
+ if meta.acquisition is not None and meta.acquisition.detector:
346
+ ET.SubElement(inst, _q("Detector"), {"ID": "Detector:0", "Model": meta.acquisition.detector})
347
+ if meta.objective is not None:
348
+ objective_id = "Objective:0"
349
+ attrs: dict[str, str] = {"ID": objective_id}
350
+ o = meta.objective
351
+ if o.manufacturer:
352
+ attrs["Manufacturer"] = o.manufacturer
353
+ if o.model:
354
+ attrs["Model"] = o.model
355
+ if o.numerical_aperture is not None:
356
+ attrs["LensNA"] = repr(float(o.numerical_aperture))
357
+ if o.magnification is not None:
358
+ attrs["NominalMagnification"] = repr(float(o.magnification))
359
+ if o.immersion:
360
+ key = o.immersion.replace(" ", "").replace("-", "").lower()
361
+ attrs["Immersion"] = _IMMERSION_SPELLING.get(key, "Other")
362
+ if o.working_distance is not None:
363
+ attrs["WorkingDistance"] = repr(float(o.working_distance))
364
+ attrs["WorkingDistanceUnit"] = OME_UNITS.get(o.working_distance_unit or "micrometer", "µm")
365
+ ET.SubElement(inst, _q("Objective"), attrs)
366
+
367
+ image = ET.SubElement(root, _q("Image"), {"ID": "Image:0", "Name": meta.name or (file_name or "image")})
368
+ if meta.acquisition is not None and meta.acquisition.date:
369
+ ET.SubElement(image, _q("AcquisitionDate")).text = meta.acquisition.date
370
+ if instrument_id is not None:
371
+ ET.SubElement(image, _q("InstrumentRef"), {"ID": instrument_id})
372
+ if meta.objective is not None:
373
+ os_attrs = {"ID": objective_id or "Objective:0"}
374
+ if meta.objective.refractive_index is not None:
375
+ os_attrs["RefractiveIndex"] = repr(float(meta.objective.refractive_index))
376
+ ET.SubElement(image, _q("ObjectiveSettings"), os_attrs)
377
+
378
+ pixels_attrs: dict[str, str] = {
379
+ "ID": "Pixels:0",
380
+ "DimensionOrder": "XYZCT",
381
+ "Type": _OME_PIXEL_TYPES[np.dtype(meta.dtype).name],
382
+ "BigEndian": "false",
383
+ "Interleaved": "false",
384
+ }
385
+ for a in "XYZCT":
386
+ pixels_attrs[f"Size{a}"] = str(sizes[a])
387
+ if meta.significant_bits is not None:
388
+ pixels_attrs["SignificantBits"] = str(meta.significant_bits)
389
+ if meta.spacing is not None:
390
+ unit = OME_UNITS[meta.spacing_unit or "micrometer"]
391
+ for a in meta.space_axes:
392
+ s = meta.spacing_of(a)
393
+ if s is None: # pragma: no cover - spacing is complete or None
394
+ continue
395
+ pixels_attrs[f"PhysicalSize{a.upper()}"] = repr(float(s))
396
+ pixels_attrs[f"PhysicalSize{a.upper()}Unit"] = unit
397
+ if meta.time_interval is not None:
398
+ pixels_attrs["TimeIncrement"] = repr(float(meta.time_interval))
399
+ pixels_attrs["TimeIncrementUnit"] = OME_UNITS[meta.time_unit or "second"]
400
+ pixels = ET.SubElement(image, _q("Pixels"), pixels_attrs)
401
+
402
+ n_c = sizes["C"]
403
+ channels = meta.channels or tuple(Channel() for _ in range(n_c))
404
+ for i, ch in enumerate(channels):
405
+ ch_attrs: dict[str, str] = {"ID": f"Channel:0:{i}", "SamplesPerPixel": "1"}
406
+ if ch.name:
407
+ ch_attrs["Name"] = ch.name
408
+ if ch.color:
409
+ ch_attrs["Color"] = str(hex_to_ome_color(ch.color))
410
+ if ch.fluorophore:
411
+ ch_attrs["Fluor"] = ch.fluorophore
412
+ wunit = OME_UNITS.get(ch.wavelength_unit or "nanometer", "nm")
413
+ if ch.excitation_wavelength is not None:
414
+ ch_attrs["ExcitationWavelength"] = repr(float(ch.excitation_wavelength))
415
+ ch_attrs["ExcitationWavelengthUnit"] = wunit
416
+ if ch.emission_wavelength is not None:
417
+ ch_attrs["EmissionWavelength"] = repr(float(ch.emission_wavelength))
418
+ ch_attrs["EmissionWavelengthUnit"] = wunit
419
+ ET.SubElement(pixels, _q("Channel"), ch_attrs)
420
+
421
+ n_planes = sizes["Z"] * sizes["C"] * sizes["T"]
422
+ if tiff_data:
423
+ ET.SubElement(pixels, _q("TiffData"), {"IFD": "0", "PlaneCount": str(n_planes)})
424
+ else:
425
+ ET.SubElement(pixels, _q("MetadataOnly"))
426
+
427
+ want_planes = (
428
+ meta.origin is not None
429
+ or meta.timestamps is not None
430
+ or (meta.acquisition is not None and meta.acquisition.exposure_time is not None)
431
+ )
432
+ if want_planes:
433
+ if n_planes > 65536:
434
+ dropped.append("origin/timestamps (too many planes for OME Plane elements)")
435
+ else:
436
+ unit = OME_UNITS[meta.spacing_unit or "micrometer"]
437
+ tunit = OME_UNITS[meta.time_unit or "second"]
438
+ for t in range(sizes["T"]):
439
+ for c in range(sizes["C"]):
440
+ for z in range(sizes["Z"]):
441
+ pl: dict[str, str] = {"TheZ": str(z), "TheC": str(c), "TheT": str(t)}
442
+ if meta.origin is not None:
443
+ for a, o in zip(meta.space_axes, meta.origin, strict=True):
444
+ pl[f"Position{a.upper()}"] = repr(float(o))
445
+ pl[f"Position{a.upper()}Unit"] = unit
446
+ if meta.timestamps is not None:
447
+ pl["DeltaT"] = repr(float(meta.timestamps[t]))
448
+ pl["DeltaTUnit"] = tunit
449
+ if meta.acquisition is not None and meta.acquisition.exposure_time is not None:
450
+ pl["ExposureTime"] = repr(float(meta.acquisition.exposure_time))
451
+ pl["ExposureTimeUnit"] = OME_UNITS[meta.acquisition.exposure_time_unit or "second"]
452
+ ET.SubElement(pixels, _q("Plane"), pl)
453
+
454
+ # ioimage annotations: frame keys, raw blocks, dropped fields, provenance, acquisition extras
455
+ annotations: dict[str, Any] = {}
456
+ frame = {
457
+ "coordinate_frame": meta.coordinate_frame,
458
+ "axis_order": meta.space_axes,
459
+ "axes": meta.axes,
460
+ "source_shape": meta.source_shape,
461
+ "crop_offset": meta.crop_offset,
462
+ }
463
+ annotations["frame"] = frame
464
+ if meta.acquisition is not None and (meta.acquisition.software or meta.acquisition.settings):
465
+ annotations["acquisition_settings"] = {
466
+ "software": meta.acquisition.software,
467
+ "settings": [list(p) for p in meta.acquisition.settings],
468
+ }
469
+ if meta.raw.imagej is not None:
470
+ annotations["imagej"] = meta.raw.imagej
471
+ if meta.raw.tiff_tags is not None:
472
+ annotations["tiff_tags"] = meta.raw.tiff_tags
473
+ if meta.raw.vendor is not None:
474
+ annotations["vendor"] = meta.raw.vendor
475
+ if meta.raw.ngff is not None:
476
+ annotations["ngff"] = meta.raw.ngff
477
+ if meta.raw.extra is not None:
478
+ annotations["extra"] = meta.raw.extra
479
+ if meta.raw.ome_xml is not None:
480
+ annotations["original_ome_xml"] = meta.raw.ome_xml
481
+ if meta.dropped_fields or dropped:
482
+ annotations["dropped_fields"] = list(meta.dropped_fields) + dropped
483
+ if meta.provenance:
484
+ annotations["provenance"] = list(meta.provenance)
485
+ if meta.kind is not None:
486
+ annotations["kind"] = meta.kind
487
+ if extra_annotations:
488
+ annotations.update(extra_annotations)
489
+
490
+ sa = ET.SubElement(root, _q("StructuredAnnotations"))
491
+ for key, value in annotations.items():
492
+ ann_id = f"Annotation:{IOIMAGE_NAMESPACE}:{key}"
493
+ ann = ET.SubElement(sa, _q("MapAnnotation"), {"ID": ann_id, "Namespace": IOIMAGE_NAMESPACE})
494
+ val = ET.SubElement(ann, _q("Value"))
495
+ if isinstance(value, str):
496
+ ET.SubElement(val, _q("M"), {"K": "text"}).text = value
497
+ else:
498
+ ET.SubElement(val, _q("M"), {"K": "json"}).text = json.dumps(value, ensure_ascii=False)
499
+ ET.SubElement(image, _q("AnnotationRef"), {"ID": ann_id})
500
+ if len(sa) == 0:
501
+ root.remove(sa)
502
+
503
+ xml = ET.tostring(root, encoding="unicode")
504
+ return '<?xml version="1.0" encoding="UTF-8"?>' + xml, dropped