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.
- omeify/__init__.py +58 -0
- omeify/_calibration.py +388 -0
- omeify/_metadata.py +439 -0
- omeify/_version.py +65 -0
- omeify/cli.py +899 -0
- omeify/cli_logging.py +92 -0
- omeify/cli_reporting.py +76 -0
- omeify/conversion.py +227 -0
- omeify/cropping.py +182 -0
- omeify/dtype_mutation.py +886 -0
- omeify/inspection.py +1325 -0
- omeify/intelligence.py +551 -0
- omeify/io/__init__.py +69 -0
- omeify/io/_selection.py +29 -0
- omeify/io/_tiff_sources/__init__.py +1 -0
- omeify/io/_tiff_sources/base.py +147 -0
- omeify/io/_tiff_sources/ome.py +150 -0
- omeify/io/_tiff_sources/vendor.py +377 -0
- omeify/io/_writer/__init__.py +33 -0
- omeify/io/_writer/configuration.py +323 -0
- omeify/io/_writer/engine.py +166 -0
- omeify/io/_writer/model.py +43 -0
- omeify/io/_writer/precision.py +136 -0
- omeify/io/_writer/preparation.py +190 -0
- omeify/io/_writer/pyramid.py +531 -0
- omeify/io/_writer/single_verification.py +172 -0
- omeify/io/_writer/verification.py +518 -0
- omeify/io/_writer/writing.py +181 -0
- omeify/io/akoya_qptiff.py +194 -0
- omeify/io/array_source.py +87 -0
- omeify/io/base.py +317 -0
- omeify/io/channel.py +103 -0
- omeify/io/file_image.py +56 -0
- omeify/io/image_metadata.py +241 -0
- omeify/io/image_planes.py +76 -0
- omeify/io/image_source.py +164 -0
- omeify/io/image_views.py +153 -0
- omeify/io/indica_mif.py +319 -0
- omeify/io/ome_multi_series_writer/__init__.py +9 -0
- omeify/io/ome_multi_series_writer/image_series.py +39 -0
- omeify/io/ome_multi_series_writer/ome_multi_series_writer.py +264 -0
- omeify/io/ome_multi_series_writer/verification.py +257 -0
- omeify/io/ome_tiff_reader.py +30 -0
- omeify/io/ome_tiff_writer.py +268 -0
- omeify/io/pixel_size.py +298 -0
- omeify/io/source_reader.py +94 -0
- omeify/io/spec.py +209 -0
- omeify/io/tiff.py +260 -0
- omeify/io/vendor_tiff_readers.py +103 -0
- omeify/mutation.py +380 -0
- omeify/path_safety.py +64 -0
- omeify/preview.py +337 -0
- omeify/progress.py +150 -0
- omeify/provenance.py +9 -0
- omeify/regions.py +223 -0
- omeify/reports.py +62 -0
- omeify/schemas/__init__.py +1 -0
- omeify/schemas/calibration.schema.json +343 -0
- omeify/schemas/conversion_report.schema.json +55 -0
- omeify/schemas/crop_provenance.schema.json +54 -0
- omeify/schemas/crop_report.schema.json +72 -0
- omeify/schemas/metadata_intelligence.schema.json +691 -0
- omeify/schemas/miti_ome_tiff_header.schema.json +162 -0
- omeify/schemas/multi_series_report.schema.json +68 -0
- omeify/schemas/mutation_report.schema.json +1119 -0
- omeify/schemas/preview.schema.json +364 -0
- omeify/schemas/region_intelligence.schema.json +97 -0
- omeify/schemas/report_common.schema.json +1812 -0
- omeify/schemas/tiff_inspection.schema.json +918 -0
- omeify/schemas/visual_answer.schema.json +127 -0
- omeify/schemas/visual_regions.schema.json +221 -0
- omeify/schemas/write_report.schema.json +43 -0
- omeify/terminal.py +11 -0
- omeify/utils/__init__.py +8 -0
- omeify/utils/generate_ome_xml.py +240 -0
- omeify/utils/miti_header_validator.py +539 -0
- omeify/utils/ome_schema_validator.py +45 -0
- omeify/visual_intelligence.py +328 -0
- omeify/workflow.py +260 -0
- omeify-0.21.0.dist-info/METADATA +585 -0
- omeify-0.21.0.dist-info/RECORD +84 -0
- omeify-0.21.0.dist-info/WHEEL +4 -0
- omeify-0.21.0.dist-info/entry_points.txt +2 -0
- 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
|