omeify 0.21.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.
Files changed (84) hide show
  1. omeify/__init__.py +58 -0
  2. omeify/_calibration.py +388 -0
  3. omeify/_metadata.py +439 -0
  4. omeify/_version.py +65 -0
  5. omeify/cli.py +899 -0
  6. omeify/cli_logging.py +92 -0
  7. omeify/cli_reporting.py +76 -0
  8. omeify/conversion.py +227 -0
  9. omeify/cropping.py +182 -0
  10. omeify/dtype_mutation.py +886 -0
  11. omeify/inspection.py +1325 -0
  12. omeify/intelligence.py +551 -0
  13. omeify/io/__init__.py +69 -0
  14. omeify/io/_selection.py +29 -0
  15. omeify/io/_tiff_sources/__init__.py +1 -0
  16. omeify/io/_tiff_sources/base.py +147 -0
  17. omeify/io/_tiff_sources/ome.py +150 -0
  18. omeify/io/_tiff_sources/vendor.py +377 -0
  19. omeify/io/_writer/__init__.py +33 -0
  20. omeify/io/_writer/configuration.py +323 -0
  21. omeify/io/_writer/engine.py +166 -0
  22. omeify/io/_writer/model.py +43 -0
  23. omeify/io/_writer/precision.py +136 -0
  24. omeify/io/_writer/preparation.py +190 -0
  25. omeify/io/_writer/pyramid.py +531 -0
  26. omeify/io/_writer/single_verification.py +172 -0
  27. omeify/io/_writer/verification.py +518 -0
  28. omeify/io/_writer/writing.py +181 -0
  29. omeify/io/akoya_qptiff.py +194 -0
  30. omeify/io/array_source.py +87 -0
  31. omeify/io/base.py +317 -0
  32. omeify/io/channel.py +103 -0
  33. omeify/io/file_image.py +56 -0
  34. omeify/io/image_metadata.py +241 -0
  35. omeify/io/image_planes.py +76 -0
  36. omeify/io/image_source.py +164 -0
  37. omeify/io/image_views.py +153 -0
  38. omeify/io/indica_mif.py +319 -0
  39. omeify/io/ome_multi_series_writer/__init__.py +9 -0
  40. omeify/io/ome_multi_series_writer/image_series.py +39 -0
  41. omeify/io/ome_multi_series_writer/ome_multi_series_writer.py +264 -0
  42. omeify/io/ome_multi_series_writer/verification.py +257 -0
  43. omeify/io/ome_tiff_reader.py +30 -0
  44. omeify/io/ome_tiff_writer.py +268 -0
  45. omeify/io/pixel_size.py +298 -0
  46. omeify/io/source_reader.py +94 -0
  47. omeify/io/spec.py +209 -0
  48. omeify/io/tiff.py +260 -0
  49. omeify/io/vendor_tiff_readers.py +103 -0
  50. omeify/mutation.py +380 -0
  51. omeify/path_safety.py +64 -0
  52. omeify/preview.py +337 -0
  53. omeify/progress.py +150 -0
  54. omeify/provenance.py +9 -0
  55. omeify/regions.py +223 -0
  56. omeify/reports.py +62 -0
  57. omeify/schemas/__init__.py +1 -0
  58. omeify/schemas/calibration.schema.json +343 -0
  59. omeify/schemas/conversion_report.schema.json +55 -0
  60. omeify/schemas/crop_provenance.schema.json +54 -0
  61. omeify/schemas/crop_report.schema.json +72 -0
  62. omeify/schemas/metadata_intelligence.schema.json +691 -0
  63. omeify/schemas/miti_ome_tiff_header.schema.json +162 -0
  64. omeify/schemas/multi_series_report.schema.json +68 -0
  65. omeify/schemas/mutation_report.schema.json +1119 -0
  66. omeify/schemas/preview.schema.json +364 -0
  67. omeify/schemas/region_intelligence.schema.json +97 -0
  68. omeify/schemas/report_common.schema.json +1812 -0
  69. omeify/schemas/tiff_inspection.schema.json +918 -0
  70. omeify/schemas/visual_answer.schema.json +127 -0
  71. omeify/schemas/visual_regions.schema.json +221 -0
  72. omeify/schemas/write_report.schema.json +43 -0
  73. omeify/terminal.py +11 -0
  74. omeify/utils/__init__.py +8 -0
  75. omeify/utils/generate_ome_xml.py +240 -0
  76. omeify/utils/miti_header_validator.py +539 -0
  77. omeify/utils/ome_schema_validator.py +45 -0
  78. omeify/visual_intelligence.py +328 -0
  79. omeify/workflow.py +260 -0
  80. omeify-0.21.0.dist-info/METADATA +585 -0
  81. omeify-0.21.0.dist-info/RECORD +84 -0
  82. omeify-0.21.0.dist-info/WHEEL +4 -0
  83. omeify-0.21.0.dist-info/entry_points.txt +2 -0
  84. omeify-0.21.0.dist-info/licenses/LICENSE +201 -0
omeify/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ from ._version import __version__, get_version_info
2
+ from .conversion import convert
3
+ from .cropping import crop
4
+ from .inspection import TiffInspector
5
+ from .io import (
6
+ AkoyaComponentTiffReader,
7
+ AkoyaFusionQPTiffReader,
8
+ AkoyaHEQPTiffReader,
9
+ AkoyaMIFQPTiffReader,
10
+ AperioSVSReader,
11
+ Channel,
12
+ Image,
13
+ ImageLevel,
14
+ ImageMetadata,
15
+ ImageSource,
16
+ IndicaMIFTiffReader,
17
+ LabelImage,
18
+ MultichannelImage,
19
+ OMEImageSeries,
20
+ OMEMultiSeriesWriter,
21
+ OMETiffLabelReader,
22
+ OMETiffReader,
23
+ OMETiffWriter,
24
+ PixelSize,
25
+ RGBImage,
26
+ TemporaryOMETiffWriter,
27
+ )
28
+ from .mutation import mutate
29
+
30
+ __all__ = [
31
+ "AkoyaComponentTiffReader",
32
+ "AkoyaFusionQPTiffReader",
33
+ "AkoyaHEQPTiffReader",
34
+ "AkoyaMIFQPTiffReader",
35
+ "AperioSVSReader",
36
+ "Channel",
37
+ "Image",
38
+ "ImageLevel",
39
+ "ImageMetadata",
40
+ "ImageSource",
41
+ "IndicaMIFTiffReader",
42
+ "LabelImage",
43
+ "MultichannelImage",
44
+ "OMEImageSeries",
45
+ "OMEMultiSeriesWriter",
46
+ "OMETiffLabelReader",
47
+ "OMETiffReader",
48
+ "OMETiffWriter",
49
+ "PixelSize",
50
+ "RGBImage",
51
+ "TemporaryOMETiffWriter",
52
+ "TiffInspector",
53
+ "__version__",
54
+ "convert",
55
+ "crop",
56
+ "get_version_info",
57
+ "mutate",
58
+ ]
omeify/_calibration.py ADDED
@@ -0,0 +1,388 @@
1
+ """Metadata-only X/Y calibration arithmetic and conservative OME/IFD matching.
2
+
3
+ Inspection compares only full-resolution planes: OME PhysicalSizeX/Y describes
4
+ those planes, not every SubIFD. Writer verification can additionally compare its
5
+ known pyramid scales. No reader calibration policy or stored units are changed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from collections.abc import Sequence
13
+ from numbers import Integral
14
+ from typing import Any
15
+
16
+ import tifffile
17
+ from lxml import etree
18
+
19
+ # Ten parts per million accommodates six-significant-digit source normalization.
20
+ # This is an encoding-consistency tolerance, not scanner measurement uncertainty.
21
+ CALIBRATION_REL_TOL = 1e-5
22
+ CALIBRATION_ABS_TOL_UM = 0.0
23
+ _MAX_PAGES = 4096
24
+ _MAX_OME_CHARS = 8_388_608
25
+ _OME_2016 = "http://www.openmicroscopy.org/Schemas/OME/2016-06"
26
+ _ENCODING = re.compile(r"(<\?xml\b[^>]*?)\s+encoding=(['\"])[^'\"]+\2", re.I)
27
+
28
+
29
+ def _positive(value: Any) -> float | None:
30
+ try:
31
+ result = float(value)
32
+ except (TypeError, ValueError, OverflowError):
33
+ return None
34
+ return result if math.isfinite(result) and result > 0 else None
35
+
36
+
37
+ def _integer(value: Any, default: int | None = None) -> int | None:
38
+ if value is None:
39
+ return default
40
+ try:
41
+ return int(value)
42
+ except (TypeError, ValueError, OverflowError):
43
+ return None
44
+
45
+
46
+ def _children(node: Any, name: str) -> list[Any]:
47
+ if node is None:
48
+ return []
49
+ namespace = etree.QName(node).namespace
50
+ tag = f"{{{namespace}}}{name}" if namespace else name
51
+ return [child for child in node if child.tag == tag]
52
+
53
+
54
+ def _tag(page: Any, name: str) -> tuple[Any, str | None]:
55
+ try:
56
+ tag = page.tags.get(name)
57
+ if tag is None:
58
+ return None, "missing_tag"
59
+ return tag.value, None
60
+ except Exception:
61
+ # Provider/file exception text may contain identifying source content.
62
+ return None, "unreadable_tag"
63
+
64
+
65
+ def tiff_calibration(page: Any) -> dict[str, Any]:
66
+ """Read actual TIFF rational tags without reader/display rounding.
67
+
68
+ An absent ResolutionUnit uses TIFF's inch default, explicitly reported as a
69
+ default, never inherited from another page. Missing resolution values are not
70
+ filled from another axis, page, or OME. Unit 1 cannot supply an absolute scale.
71
+ """
72
+
73
+ raw_unit, unit_issue = _tag(page, "ResolutionUnit")
74
+ unit = _integer(raw_unit)
75
+ defaulted = unit_issue == "missing_tag"
76
+ effective = 2 if defaulted else unit
77
+ if unit_issue == "unreadable_tag":
78
+ problem = unit_issue
79
+ elif effective == 1:
80
+ problem = "nonphysical_unit"
81
+ elif effective not in (2, 3):
82
+ problem = "unsupported_unit"
83
+ else:
84
+ problem = None
85
+ result: dict[str, Any] = {
86
+ "resolution_unit": unit, "effective_resolution_unit": effective,
87
+ "unit_defaulted": defaulted,
88
+ }
89
+ for axis in ("x", "y"):
90
+ raw, issue = _tag(page, axis.upper() + "Resolution")
91
+ rational = None
92
+ density = None
93
+ if issue is None:
94
+ if isinstance(raw, (tuple, list)) and len(raw) == 2:
95
+ numerator, denominator = raw
96
+ if all(isinstance(v, Integral) and not isinstance(v, bool) for v in raw):
97
+ rational = [int(numerator), int(denominator)]
98
+ if numerator > 0 and denominator > 0:
99
+ density = _positive(numerator / denominator)
100
+ else:
101
+ density = _positive(raw)
102
+ if density is None:
103
+ issue = "invalid_resolution"
104
+ issue = issue or problem
105
+ size = None if issue else _positive((10000.0 if effective == 3 else 25400.0) / density)
106
+ if issue is None and size is None:
107
+ issue = "invalid_resolution"
108
+ result[axis] = {
109
+ "rational": rational, "pixels_per_unit": density,
110
+ "pixel_size_um": size, "issue": issue,
111
+ }
112
+ return result
113
+
114
+
115
+ def ome_calibration(pixels: Any) -> dict[str, Any]:
116
+ """Normalize independent OME X/Y lengths, retaining explicit/defaulted units."""
117
+
118
+ # Lazy import avoids the inspection -> io -> inspection import cycle.
119
+ from omeify.io.pixel_size import PixelSize, canonical_length_unit
120
+
121
+ result = {}
122
+ modern = pixels is not None and etree.QName(pixels).namespace == _OME_2016
123
+ for axis in ("x", "y"):
124
+ attribute = "PhysicalSize" + axis.upper()
125
+ raw = None if pixels is None else pixels.get(attribute)
126
+ unit = None if pixels is None else pixels.get(attribute + "Unit")
127
+ value = _positive(raw)
128
+ defaulted = raw is not None and unit is None and modern
129
+ effective = "µm" if defaulted else unit
130
+ size = None
131
+ if raw is None:
132
+ issue = "missing_value"
133
+ elif value is None:
134
+ issue = "invalid_value"
135
+ elif effective is None:
136
+ issue = "missing_unit"
137
+ elif effective in ("pixel", "reference frame"):
138
+ issue = "nonphysical_unit"
139
+ else:
140
+ try:
141
+ size = PixelSize(value, value, effective).converted_to("µm").x
142
+ effective = canonical_length_unit(effective)
143
+ issue = None
144
+ except (ValueError, OverflowError):
145
+ issue = "unsupported_unit"
146
+ result[axis] = {
147
+ "value": value, "unit": unit, "effective_unit": effective,
148
+ "unit_defaulted": defaulted, "pixel_size_um": size, "issue": issue,
149
+ }
150
+ return result
151
+
152
+
153
+ def compare_axis(actual: float | None, expected: float | None) -> dict[str, Any]:
154
+ """Compare positive, normalized lengths with a symmetric relative tolerance."""
155
+
156
+ if actual is None or expected is None:
157
+ return {"status": "not_comparable", "relative_difference": None}
158
+ difference = abs(actual - expected) / max(actual, expected)
159
+ matches = math.isclose(
160
+ actual, expected, rel_tol=CALIBRATION_REL_TOL, abs_tol=CALIBRATION_ABS_TOL_UM,
161
+ )
162
+ return {
163
+ "status": "consistent" if matches else "mismatch",
164
+ "relative_difference": difference,
165
+ }
166
+
167
+
168
+ def _status(values: Sequence[str], *, complete: bool = True) -> str:
169
+ if "mismatch" in values:
170
+ return "mismatch"
171
+ if values and all(value == "consistent" for value in values) and complete:
172
+ return "consistent"
173
+ if any(value in ("consistent", "partial") for value in values):
174
+ return "partial"
175
+ return "not_comparable"
176
+
177
+
178
+ def _ome_images(tiff: Any) -> tuple[list[dict[str, Any]], str]:
179
+ """Read local TiffData ranges without opening UUID/FileName companions."""
180
+
181
+ try:
182
+ xml = tiff.ome_metadata
183
+ if not xml:
184
+ return [], "no_ome"
185
+ if len(xml) > _MAX_OME_CHARS:
186
+ return [], "ome_scan_limit"
187
+ if isinstance(xml, str):
188
+ xml = _ENCODING.sub(r"\1", xml, count=1).encode("utf-8")
189
+ parser = etree.XMLParser(resolve_entities=False, load_dtd=False, no_network=True)
190
+ root = etree.fromstring(xml, parser)
191
+ if root.getroottree().docinfo.doctype:
192
+ return [], "unsafe_ome"
193
+ if etree.QName(root).localname != "OME":
194
+ return [], "unreadable_ome"
195
+ except Exception:
196
+ return [], "unreadable_ome"
197
+ images = []
198
+ for index, image in enumerate(_children(root, "Image")):
199
+ pixels_elements = _children(image, "Pixels")
200
+ pixels = pixels_elements[0] if len(pixels_elements) == 1 else None
201
+ ranges = []
202
+ unresolved = False
203
+ if pixels is not None:
204
+ channels = _children(pixels, "Channel")
205
+ size_c = _integer(pixels.get("SizeC"))
206
+ samples = [_integer(channel.get("SamplesPerPixel"), 1) for channel in channels]
207
+ valid_samples = bool(samples) and all(v is not None and v > 0 for v in samples)
208
+ logical_c = len(channels) if valid_samples and sum(samples) == size_c else size_c
209
+ z, t = _integer(pixels.get("SizeZ")), _integer(pixels.get("SizeT"))
210
+ plane_count = (
211
+ logical_c * z * t
212
+ if all(v is not None and v > 0 for v in (logical_c, z, t)) else None
213
+ )
214
+ for data in _children(pixels, "TiffData"):
215
+ uuid = _children(data, "UUID")
216
+ if uuid and (
217
+ len(uuid) != 1 or not root.get("UUID")
218
+ or (uuid[0].text or "").strip() != root.get("UUID")
219
+ ):
220
+ unresolved = True
221
+ continue # No filename guessing, companion reads, or directory searches.
222
+ start = _integer(data.get("IFD"), 0)
223
+ count = _integer(data.get("PlaneCount"), 1 if "IFD" in data.attrib else None)
224
+ if count is None and "PlaneCount" not in data.attrib:
225
+ # A bare TiffData maps the image's planes, not unrelated tail IFDs.
226
+ if any(_integer(data.get("First" + a), 0) != 0 for a in ("C", "Z", "T")):
227
+ unresolved = True
228
+ continue
229
+ count = min(len(tiff.pages), plane_count) if plane_count is not None else None
230
+ if (
231
+ start is None or start < 0 or count is None or count < 0
232
+ or start + count > len(tiff.pages)
233
+ ):
234
+ unresolved = True
235
+ continue
236
+ if count:
237
+ ranges.append((start, start + count))
238
+ images.append({
239
+ "index": index, "pixels": pixels, "ranges": ranges,
240
+ "unresolved": unresolved,
241
+ })
242
+ return images, "parsed_ome"
243
+
244
+
245
+ def _local_ifd(tiff: Any, page: Any) -> int | None:
246
+ """Confirm a top-level physical IFD; SubIFDs may reuse integer page indices."""
247
+
248
+ index = page.index
249
+ if not isinstance(index, Integral) or page.parent is not tiff:
250
+ return None
251
+ index = int(index)
252
+ if not 0 <= index < len(tiff.pages):
253
+ return None
254
+ return index if int(tiff.pages[index].offset) == int(page.offset) else None
255
+
256
+
257
+ def _match_image(
258
+ pages: list[tuple[int, Any]], images: list[dict[str, Any]], *, complete: bool,
259
+ ) -> tuple[dict[str, Any] | None, str]:
260
+ if not complete or not pages:
261
+ return None, "incomplete_local_pages"
262
+ candidates = []
263
+ for index, page in pages:
264
+ owners = [
265
+ image for image in images
266
+ if any(start <= index < end for start, end in image["ranges"])
267
+ ]
268
+ if len(owners) > 1:
269
+ return None, "ambiguous_tiff_data"
270
+ if not owners:
271
+ return None, "unmapped_ifd"
272
+ candidates.append(owners[0])
273
+ chosen = candidates[0]
274
+ if any(image is not chosen for image in candidates) or chosen["unresolved"]:
275
+ return None, "incomplete_tiff_data"
276
+ pixels = chosen["pixels"]
277
+ if any(
278
+ int(page.imagewidth) != _integer(pixels.get("SizeX"))
279
+ or int(page.imagelength) != _integer(pixels.get("SizeY"))
280
+ or bool(page.is_reduced)
281
+ for _, page in pages
282
+ ):
283
+ return None, "not_full_resolution_geometry"
284
+ return chosen, "local_tiff_data"
285
+
286
+
287
+ def inspect_calibration(
288
+ tiff: tifffile.TiffFile, series: Sequence[tifffile.TiffPageSeries],
289
+ ) -> dict[str, Any]:
290
+ """Compare raw TIFF tags to OME on proven local full-resolution plane mappings.
291
+
292
+ Inspect every base-plane entry, including real directories behind frames, up
293
+ to a file-wide 4,096-entry limit. Never pair images by ordinal, name, or shape
294
+ alone. Missing/ambiguous data remains not-comparable or partial, not a pass.
295
+ Does not enumerate ``tiff.series`` itself (that can open companion files).
296
+ """
297
+
298
+ images, ome_state = _ome_images(tiff)
299
+ remaining = _MAX_PAGES
300
+ results = []
301
+ for series_index, item in enumerate(series):
302
+ local_pages = []
303
+ try:
304
+ count = len(item.pages)
305
+ except Exception:
306
+ count = 0
307
+ attempted = min(count, remaining)
308
+ remaining -= attempted
309
+ for position in range(attempted):
310
+ try:
311
+ page = item.pages[position].aspage()
312
+ index = _local_ifd(tiff, page)
313
+ if index is not None:
314
+ local_pages.append((index, page))
315
+ except Exception:
316
+ continue
317
+ complete = count > 0 and len(local_pages) == count
318
+ image, reason = _match_image(local_pages, images, complete=complete)
319
+ if ome_state != "parsed_ome":
320
+ reason = ome_state
321
+ declared = ome_calibration(image["pixels"]) if image is not None else None
322
+ checks = []
323
+ for ifd, page in local_pages:
324
+ actual = tiff_calibration(page)
325
+ axes = {
326
+ axis: compare_axis(
327
+ actual[axis]["pixel_size_um"],
328
+ declared[axis]["pixel_size_um"] if declared else None,
329
+ ) for axis in ("x", "y")
330
+ }
331
+ checks.append({
332
+ "ifd": ifd, "tiff": actual, "axes": axes,
333
+ "status": _status([axes[a]["status"] for a in ("x", "y")]),
334
+ })
335
+ results.append({
336
+ "series_index": series_index,
337
+ "ome_image_index": None if image is None else image["index"],
338
+ "mapping_reason": reason,
339
+ "status": _status([check["status"] for check in checks], complete=complete),
340
+ "ome": declared, "pages_total": count, "pages_checked": len(checks),
341
+ "scan_limited": attempted < count, "checks": checks,
342
+ })
343
+ return {
344
+ "schema_version": "1.0", "scope": "full_resolution_xy", "comparison_unit": "µm",
345
+ "relative_tolerance": CALIBRATION_REL_TOL, "absolute_tolerance_um": CALIBRATION_ABS_TOL_UM,
346
+ "max_page_entries": _MAX_PAGES, "series": results,
347
+ }
348
+
349
+
350
+ def calibration_context(calibration: dict[str, Any]) -> list[str]:
351
+ """Create bounded, numeric-only derived evidence, never arbitrary file prose."""
352
+
353
+ result = []
354
+ for item in calibration["series"]:
355
+ index, status = item["series_index"], item["status"]
356
+ parts = [
357
+ f"Computed calibration check for TIFF series {index}, "
358
+ f"full-resolution X/Y only: {status}.",
359
+ f"OME Image index: {item['ome_image_index']}; mapping: {item['mapping_reason']}.",
360
+ f"Read {item['pages_checked']}/{item['pages_total']} local base-plane entries.",
361
+ f"Numerical tolerance: relative {calibration['relative_tolerance']:g}, "
362
+ f"absolute {calibration['absolute_tolerance_um']:g} µm.",
363
+ ]
364
+ counts = {s: sum(c["status"] == s for c in item["checks"])
365
+ for s in ("consistent", "mismatch", "partial", "not_comparable")}
366
+ parts.append("Plane results: " + ", ".join(f"{key}={n}" for key, n in counts.items()) + ".")
367
+ if item["ome"]:
368
+ for axis in ("x", "y"):
369
+ declared = item["ome"][axis]
370
+ parts.append(f"OME {axis.upper()}={declared['pixel_size_um']} µm/pixel "
371
+ f"(unit defaulted={declared['unit_defaulted']}).")
372
+ # Include one representative worst case, not thousands of page records.
373
+ if item["checks"]:
374
+ priority = {"mismatch": 0, "partial": 1, "not_comparable": 2, "consistent": 3}
375
+ check = min(item["checks"], key=lambda c: priority[c["status"]])
376
+ actual = check["tiff"]
377
+ parts.append(f"Example IFD {check['ifd']}: effective TIFF ResolutionUnit="
378
+ f"{actual['effective_resolution_unit']} "
379
+ f"(defaulted={actual['unit_defaulted']}).")
380
+ for axis in ("x", "y"):
381
+ parts.append(f"TIFF {axis.upper()}={actual[axis]['pixel_size_um']} µm/pixel; "
382
+ f"comparison={check['axes'][axis]['status']}; "
383
+ f"relative difference={check['axes'][axis]['relative_difference']}; "
384
+ f"tag issue={actual[axis]['issue']}.")
385
+ parts.append("SubIFDs were not compared to base OME sizes. "
386
+ "Numerical agreement does not establish acquisition accuracy or compliance.")
387
+ result.append(" ".join(parts))
388
+ return result