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 +43 -0
- ioimage/_omexml.py +504 -0
- ioimage/_omezarr.py +800 -0
- ioimage/_pyramid.py +79 -0
- ioimage/_source.py +99 -0
- ioimage/_tiff.py +731 -0
- ioimage/cli.py +128 -0
- ioimage/errors.py +14 -0
- ioimage/image.py +401 -0
- ioimage/metadata.py +611 -0
- ioimage-0.1.2.dist-info/METADATA +153 -0
- ioimage-0.1.2.dist-info/RECORD +15 -0
- ioimage-0.1.2.dist-info/WHEEL +4 -0
- ioimage-0.1.2.dist-info/entry_points.txt +2 -0
- ioimage-0.1.2.dist-info/licenses/LICENSE +674 -0
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
|