micro-reader 0.0.1__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.
- micro_reader/__init__.py +23 -0
- micro_reader/_axes.py +196 -0
- micro_reader/_base.py +268 -0
- micro_reader/_codecs.py +439 -0
- micro_reader/_errors.py +14 -0
- micro_reader/_index.py +53 -0
- micro_reader/_io.py +83 -0
- micro_reader/_jpeg_restarts.py +174 -0
- micro_reader/_memory.py +161 -0
- micro_reader/_metadata.py +163 -0
- micro_reader/_pool.py +85 -0
- micro_reader/_registry.py +104 -0
- micro_reader/_stream.py +317 -0
- micro_reader/backends/__init__.py +1 -0
- micro_reader/backends/_czi_metadata.py +188 -0
- micro_reader/backends/_lif_metadata.py +125 -0
- micro_reader/backends/_nd2_legacy.py +370 -0
- micro_reader/backends/_nd2_metadata.py +224 -0
- micro_reader/backends/_nd2_structure.py +280 -0
- micro_reader/backends/_tiff_metadata.py +287 -0
- micro_reader/backends/czi.py +948 -0
- micro_reader/backends/ims.py +409 -0
- micro_reader/backends/lif.py +713 -0
- micro_reader/backends/mrc.py +184 -0
- micro_reader/backends/nd2.py +264 -0
- micro_reader/backends/oir.py +439 -0
- micro_reader/backends/raster.py +568 -0
- micro_reader/backends/tiff.py +858 -0
- micro_reader-0.0.1.dist-info/METADATA +741 -0
- micro_reader-0.0.1.dist-info/RECORD +32 -0
- micro_reader-0.0.1.dist-info/WHEEL +4 -0
- micro_reader-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Rows of a JPEG too large to decode whole, decoded alone through its
|
|
2
|
+
restart markers (docs/PLAN.md, "memory-bounded reads of huge compressed
|
|
3
|
+
blocks", step 4).
|
|
4
|
+
|
|
5
|
+
A sequential JPEG with a restart interval (DRI) resets its entropy coder
|
|
6
|
+
every *Ri* MCUs and marks the place (RST0..RST7). The intervals covering
|
|
7
|
+
some MCU rows, put behind the JPEG's own header with the frame height
|
|
8
|
+
changed, make a JPEG of those rows alone: any decoder decodes it, and only
|
|
9
|
+
it is held in memory. Pixels are the same as a whole decode: blocks decode
|
|
10
|
+
alone, and one MCU row more above and below is decoded and dropped where
|
|
11
|
+
chroma is subsampled (upsampling looks at the neighbouring rows).
|
|
12
|
+
|
|
13
|
+
Cut only:
|
|
14
|
+
- baseline / extended sequential JPEG (SOF0, SOF1), one scan of all
|
|
15
|
+
components;
|
|
16
|
+
- with intervals that start on MCU rows often enough: every
|
|
17
|
+
lcm(Ri, MCUs per row) / MCUs per row MCU rows (``group``).
|
|
18
|
+
|
|
19
|
+
Progressive JPEG (its refinement scans hold the whole image), lossless and
|
|
20
|
+
JPEG without restart markers return None from ``RestartIndex.build`` and are
|
|
21
|
+
decoded whole, within the memory limit.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
import struct
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
#: RST0..RST7 and EOI in entropy-coded data (0xFF 0x00 is a stuffed 0xFF)
|
|
33
|
+
_MARKERS = re.compile(rb"\xff[\xd0-\xd7\xd9]")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RestartIndex:
|
|
37
|
+
"""Where the restart intervals of one sequential JPEG start and end, in
|
|
38
|
+
bytes of its codestream; ``build`` it from the codestream."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, header: bytes, sof: int, height: int, width: int,
|
|
41
|
+
mcu_rows: int, mcu_height: int, group: int, starts, ends, subsampled: bool,
|
|
42
|
+
intervals_per_group: int):
|
|
43
|
+
self.header = header # SOI .. the scan header, kept
|
|
44
|
+
self._sof = sof # offset of the frame height in *header*
|
|
45
|
+
self.height, self.width = height, width
|
|
46
|
+
self.mcu_rows, self.mcu_height = mcu_rows, mcu_height
|
|
47
|
+
#: MCU rows between interval starts that fall on an MCU row
|
|
48
|
+
self.group = group
|
|
49
|
+
self._per_group = intervals_per_group
|
|
50
|
+
self._starts, self._ends = starts, ends
|
|
51
|
+
self._overlap = 1 if subsampled else 0 # groups decoded above / below and dropped
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def build(cls, data) -> "RestartIndex | None":
|
|
55
|
+
"""The index of the JPEG codestream *data*, or None when it cannot
|
|
56
|
+
be cut (see the module docstring)."""
|
|
57
|
+
data = memoryview(data).cast("B")
|
|
58
|
+
if bytes(data[:2]) != b"\xff\xd8":
|
|
59
|
+
return None
|
|
60
|
+
pos, sof, frame, interval, scan = 2, None, None, 0, None
|
|
61
|
+
while pos + 4 <= len(data):
|
|
62
|
+
if data[pos] != 0xFF:
|
|
63
|
+
return None
|
|
64
|
+
marker = data[pos + 1]
|
|
65
|
+
if marker == 0xFF: # fill byte
|
|
66
|
+
pos += 1
|
|
67
|
+
continue
|
|
68
|
+
length = struct.unpack(">H", data[pos + 2:pos + 4])[0]
|
|
69
|
+
body = bytes(data[pos + 4:pos + 2 + length])
|
|
70
|
+
if marker in (0xC0, 0xC1):
|
|
71
|
+
if frame is not None:
|
|
72
|
+
return None
|
|
73
|
+
sof = pos + 5
|
|
74
|
+
_bits, height, width, n = struct.unpack(">BHHB", body[:6])
|
|
75
|
+
frame = (height, width, [(body[7 + 3 * k] >> 4, body[7 + 3 * k] & 15)
|
|
76
|
+
for k in range(n)])
|
|
77
|
+
elif 0xC2 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
|
78
|
+
return None # progressive, lossless, arithmetic
|
|
79
|
+
elif marker == 0xDD:
|
|
80
|
+
interval = struct.unpack(">H", body[:2])[0]
|
|
81
|
+
elif marker == 0xDA:
|
|
82
|
+
if frame is None or body[0] != len(frame[2]):
|
|
83
|
+
return None # a scan of only some components
|
|
84
|
+
scan = pos + 2 + length
|
|
85
|
+
break
|
|
86
|
+
pos += 2 + length
|
|
87
|
+
if scan is None or interval == 0 or frame[0] == 0:
|
|
88
|
+
return None
|
|
89
|
+
height, width, sampling = frame
|
|
90
|
+
if len(sampling) == 1: # one component: 8 x 8 MCUs
|
|
91
|
+
mcu_w = mcu_h = 8
|
|
92
|
+
else:
|
|
93
|
+
mcu_w, mcu_h = 8 * max(h for h, _ in sampling), 8 * max(v for _, v in sampling)
|
|
94
|
+
across = -(-width // mcu_w)
|
|
95
|
+
mcu_rows = -(-height // mcu_h)
|
|
96
|
+
group = math.lcm(interval, across) // across
|
|
97
|
+
markers = [(m.start(), data[m.start() + 1]) for m in _MARKERS.finditer(data, scan)]
|
|
98
|
+
end = next((p for p, kind in markers if kind == 0xD9), len(data))
|
|
99
|
+
rst = [p for p, kind in markers if kind != 0xD9 and p < end]
|
|
100
|
+
starts = np.array([scan] + [p + 2 for p in rst], np.int64)
|
|
101
|
+
ends = np.array(rst + [end], np.int64)
|
|
102
|
+
if len(starts) != -(-across * mcu_rows // interval):
|
|
103
|
+
return None # markers missing or extra: decode whole
|
|
104
|
+
subsampled = len({s for s in sampling}) > 1
|
|
105
|
+
return cls(bytes(data[:scan]), sof, height, width, mcu_rows, mcu_h, group,
|
|
106
|
+
starts, ends, subsampled, group * across // interval)
|
|
107
|
+
|
|
108
|
+
def group_bytes(self, row_bytes: int) -> int:
|
|
109
|
+
"""Decoded bytes of one group of MCU rows, *row_bytes* per image row."""
|
|
110
|
+
return self.group * self.mcu_height * row_bytes
|
|
111
|
+
|
|
112
|
+
def smallest_band(self, row_bytes: int) -> int:
|
|
113
|
+
"""Memory of the smallest band ``read_rows`` decodes: one group, the
|
|
114
|
+
overlap above and below, and the decoder's own planes."""
|
|
115
|
+
return 2 * (1 + 2 * self._overlap) * self.group_bytes(row_bytes)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def bands(self, y0: int, y1: int, groups: int):
|
|
119
|
+
"""(g0, g1): the groups of MCU rows covering image rows *y0*..*y1*,
|
|
120
|
+
*groups* at a time (``codestream`` adds the overlap)."""
|
|
121
|
+
rows_per_group = self.group * self.mcu_height
|
|
122
|
+
g0, g1 = y0 // rows_per_group, -(-y1 // rows_per_group)
|
|
123
|
+
for g in range(g0, g1, groups):
|
|
124
|
+
yield g, min(g1, g + groups)
|
|
125
|
+
|
|
126
|
+
def codestream(self, read, g0: int, g1: int):
|
|
127
|
+
"""The JPEG of groups *g0*..*g1* (plus the overlap) and the image row
|
|
128
|
+
it starts at. *read(start, stop)*: bytes start..stop of the
|
|
129
|
+
original codestream."""
|
|
130
|
+
groups = -(-self.mcu_rows // self.group)
|
|
131
|
+
g0, g1 = max(0, g0 - self._overlap), min(groups, g1 + self._overlap)
|
|
132
|
+
j0, j1 = g0 * self._per_group, min(len(self._starts), g1 * self._per_group)
|
|
133
|
+
first = g0 * self.group * self.mcu_height
|
|
134
|
+
stop = min(self.height, g1 * self.group * self.mcu_height)
|
|
135
|
+
header = bytearray(self.header)
|
|
136
|
+
header[self._sof:self._sof + 2] = struct.pack(">H", stop - first)
|
|
137
|
+
raw = read(int(self._starts[j0]), int(self._ends[j1 - 1]))
|
|
138
|
+
base = int(self._starts[j0])
|
|
139
|
+
parts = [bytes(header)]
|
|
140
|
+
for j in range(j0, j1):
|
|
141
|
+
parts.append(raw[self._starts[j] - base:self._ends[j] - base])
|
|
142
|
+
if j < j1 - 1:
|
|
143
|
+
parts.append(bytes((0xFF, 0xD0 + (j - j0) % 8)))
|
|
144
|
+
parts.append(b"\xff\xd9")
|
|
145
|
+
return b"".join(parts), first, stop
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def usable(index: "RestartIndex | None", row_bytes: int, fits) -> bool:
|
|
149
|
+
"""Whether *index* cuts its JPEG in bands that *fits(nbytes)* allows:
|
|
150
|
+
intervals that seldom start on an MCU row make every band large."""
|
|
151
|
+
return index is not None and fits(index.smallest_band(row_bytes))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def read_rows(index: RestartIndex, read, y0: int, y1: int, decode, shape_tail, dtype,
|
|
155
|
+
piece_bytes: int, reserve):
|
|
156
|
+
"""Image rows *y0*..*y1* of a cut JPEG, in bands: yields (a, b, rows),
|
|
157
|
+
rows *a*..*b* of the image as (b - a, width, *shape_tail*). *decode(data,
|
|
158
|
+
out)* decodes a JPEG into *out*; *reserve(nbytes)* holds the memory
|
|
159
|
+
budget while a band is decoded (the band and the decoder's own planes)."""
|
|
160
|
+
row_bytes = index.width * int(np.prod(shape_tail, dtype=np.int64)) * np.dtype(dtype).itemsize
|
|
161
|
+
groups = max(1, piece_bytes // max(1, index.group_bytes(row_bytes)))
|
|
162
|
+
for g0, g1 in index.bands(y0, y1, groups):
|
|
163
|
+
data, first, stop = index.codestream(read, g0, g1)
|
|
164
|
+
band = np.empty((stop - first, index.width) + tuple(shape_tail), dtype)
|
|
165
|
+
with reserve(2 * band.nbytes): # the band and the decoder's
|
|
166
|
+
got = decode(data, band)
|
|
167
|
+
if (got[1], got[0]) != band.shape[:2]:
|
|
168
|
+
raise ValueError(f"a cut JPEG decodes to {got[1]} x {got[0]}, "
|
|
169
|
+
f"expected {band.shape[0]} x {band.shape[1]}")
|
|
170
|
+
rows_per_group = index.group * index.mcu_height # the band's own rows,
|
|
171
|
+
a = max(y0, g0 * rows_per_group) # not the overlap
|
|
172
|
+
b = min(y1, g1 * rows_per_group, index.height)
|
|
173
|
+
if a < b:
|
|
174
|
+
yield a, b, band[a - first:b - first]
|
micro_reader/_memory.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The memory budget for decoding (docs/PLAN.md, "memory-bounded reads of huge
|
|
2
|
+
compressed blocks").
|
|
3
|
+
|
|
4
|
+
Compressed data is decoded per block (a TIFF strip or tile, a CZI subblock,
|
|
5
|
+
an ND2 "lossless" frame, a whole PNG ...).
|
|
6
|
+
|
|
7
|
+
- A block too large for the decoded-block caches (``STREAM_THRESHOLD``) is
|
|
8
|
+
streamed where its codec allows it: decoded from its start, keeping only
|
|
9
|
+
the rows a read needs. Decoding it whole would buy nothing -- it cannot
|
|
10
|
+
be kept for the next read -- and costs the whole block for every read.
|
|
11
|
+
- Every other block is decoded whole, and reserves its size (``reserve``)
|
|
12
|
+
while it is being decoded: the blocks in flight never exceed
|
|
13
|
+
``memory_limit()`` together, whichever threads decode them (a caller's
|
|
14
|
+
own threads as well as the decode pool); a decode that does not fit
|
|
15
|
+
waits until others finish. A single block above the limit raises
|
|
16
|
+
``MemoryLimitError``.
|
|
17
|
+
|
|
18
|
+
Size: ``set_memory_limit(n)`` (bytes, or a string such as ``"8GB"``), else
|
|
19
|
+
the ``MICRO_READER_MEMORY_LIMIT`` environment variable, else a quarter of
|
|
20
|
+
the computer's memory. The limit is per process: each worker process of a
|
|
21
|
+
caller such as eubi-bridge sets its own.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import threading
|
|
28
|
+
from typing import Optional, Union
|
|
29
|
+
|
|
30
|
+
from ._errors import MicroReaderError
|
|
31
|
+
|
|
32
|
+
_lock = threading.Lock()
|
|
33
|
+
_limit: Optional[int] = None
|
|
34
|
+
#: when the computer's memory cannot be found
|
|
35
|
+
FALLBACK_LIMIT = 4 << 30
|
|
36
|
+
#: never below this: a budget smaller than one camera frame helps nobody
|
|
37
|
+
MINIMUM_LIMIT = 64 << 20
|
|
38
|
+
_UNITS = {"": 1, "b": 1, "k": 1 << 10, "kb": 1 << 10, "kib": 1 << 10, "m": 1 << 20,
|
|
39
|
+
"mb": 1 << 20, "mib": 1 << 20, "g": 1 << 30, "gb": 1 << 30, "gib": 1 << 30,
|
|
40
|
+
"t": 1 << 40, "tb": 1 << 40, "tib": 1 << 40}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MemoryLimitError(MicroReaderError):
|
|
44
|
+
"""A block that must be decoded whole does not fit the memory budget."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_size(value: Union[int, float, str]) -> int:
|
|
48
|
+
"""Bytes from 4294967296, "4GB", "512 MB", "1.5g" (binary units)."""
|
|
49
|
+
if isinstance(value, (int, float)):
|
|
50
|
+
return int(value)
|
|
51
|
+
match = re.fullmatch(r"\s*([0-9]*\.?[0-9]+)\s*([a-zA-Z]*)\s*", str(value))
|
|
52
|
+
if not match or match.group(2).lower() not in _UNITS:
|
|
53
|
+
raise ValueError(f"memory size {value!r}: a number of bytes or e.g. '8GB'")
|
|
54
|
+
return int(float(match.group(1)) * _UNITS[match.group(2).lower()])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def physical_memory() -> Optional[int]:
|
|
58
|
+
"""The computer's memory in bytes, or None if it cannot be found."""
|
|
59
|
+
try:
|
|
60
|
+
if os.name == "nt":
|
|
61
|
+
import ctypes
|
|
62
|
+
|
|
63
|
+
class _Status(ctypes.Structure):
|
|
64
|
+
_fields_ = [("length", ctypes.c_ulong), ("load", ctypes.c_ulong),
|
|
65
|
+
("total", ctypes.c_ulonglong), ("free", ctypes.c_ulonglong),
|
|
66
|
+
("page_total", ctypes.c_ulonglong), ("page_free", ctypes.c_ulonglong),
|
|
67
|
+
("virtual_total", ctypes.c_ulonglong),
|
|
68
|
+
("virtual_free", ctypes.c_ulonglong), ("extended", ctypes.c_ulonglong)]
|
|
69
|
+
|
|
70
|
+
status = _Status()
|
|
71
|
+
status.length = ctypes.sizeof(_Status)
|
|
72
|
+
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
|
|
73
|
+
return int(status.total)
|
|
74
|
+
return None
|
|
75
|
+
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
|
|
76
|
+
except (AttributeError, OSError, ValueError):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def memory_limit() -> int:
|
|
81
|
+
"""The decoding memory budget of this process, in bytes."""
|
|
82
|
+
global _limit
|
|
83
|
+
if _limit is None:
|
|
84
|
+
env = os.environ.get("MICRO_READER_MEMORY_LIMIT")
|
|
85
|
+
if env:
|
|
86
|
+
_limit = max(MINIMUM_LIMIT, parse_size(env))
|
|
87
|
+
else:
|
|
88
|
+
total = physical_memory()
|
|
89
|
+
_limit = max(MINIMUM_LIMIT, total // 4) if total else FALLBACK_LIMIT
|
|
90
|
+
return _limit
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def set_memory_limit(limit: Union[int, float, str, None]) -> None:
|
|
94
|
+
"""Set the decoding memory budget (bytes or e.g. "8GB"); None restores
|
|
95
|
+
the default (environment variable, else a quarter of the memory)."""
|
|
96
|
+
global _limit
|
|
97
|
+
with _lock:
|
|
98
|
+
_limit = None if limit is None else max(MINIMUM_LIMIT, parse_size(limit))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
#: blocks larger than this are streamed where their codec allows: the
|
|
102
|
+
#: decoded-block caches keep blocks up to a quarter of their 64 MB
|
|
103
|
+
STREAM_THRESHOLD = 16 << 20
|
|
104
|
+
|
|
105
|
+
_reserved = 0
|
|
106
|
+
_peak_reserved = 0
|
|
107
|
+
_room = threading.Condition()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def should_stream(nbytes: int) -> bool:
|
|
111
|
+
"""Whether a block of *nbytes* is better streamed than decoded whole
|
|
112
|
+
(when its codec can be streamed)."""
|
|
113
|
+
return nbytes > STREAM_THRESHOLD
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def fits(nbytes: int) -> bool:
|
|
117
|
+
"""Whether a block of *nbytes* can ever be decoded whole."""
|
|
118
|
+
return nbytes <= memory_limit()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class reserve:
|
|
122
|
+
"""``with reserve(nbytes):`` -- hold *nbytes* of the budget while a block
|
|
123
|
+
is decoded whole; waits while the blocks in flight leave too little."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, nbytes: int, what: str = "a block"):
|
|
126
|
+
self.nbytes, self.what = int(nbytes), what
|
|
127
|
+
|
|
128
|
+
def __enter__(self):
|
|
129
|
+
global _reserved, _peak_reserved
|
|
130
|
+
if not fits(self.nbytes):
|
|
131
|
+
raise too_large(self.what, self.nbytes)
|
|
132
|
+
with _room:
|
|
133
|
+
while _reserved and _reserved + self.nbytes > memory_limit():
|
|
134
|
+
_room.wait()
|
|
135
|
+
_reserved += self.nbytes
|
|
136
|
+
_peak_reserved = max(_peak_reserved, _reserved)
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(self, *exc):
|
|
140
|
+
global _reserved
|
|
141
|
+
with _room:
|
|
142
|
+
_reserved -= self.nbytes
|
|
143
|
+
_room.notify_all()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def peak_reserved(reset: bool = False) -> int:
|
|
147
|
+
"""The most bytes reserved at once (for tests and diagnostics)."""
|
|
148
|
+
global _peak_reserved
|
|
149
|
+
with _room:
|
|
150
|
+
peak = _peak_reserved
|
|
151
|
+
if reset:
|
|
152
|
+
_peak_reserved = _reserved
|
|
153
|
+
return peak
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def too_large(what: str, nbytes: int) -> MemoryLimitError:
|
|
157
|
+
"""The error for a block that cannot be decoded within the budget."""
|
|
158
|
+
return MemoryLimitError(
|
|
159
|
+
f"{what} must be decoded whole ({nbytes / 2**20:,.0f} MB), more than the memory "
|
|
160
|
+
f"limit ({memory_limit() / 2**20:,.0f} MB). Raise it with "
|
|
161
|
+
"micro_reader.set_memory_limit(...) or MICRO_READER_MEMORY_LIMIT")
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Image metadata: the minimal model of docs/METADATA.md.
|
|
2
|
+
|
|
3
|
+
Pixel sizes per axis, stage position, channel names / colours /
|
|
4
|
+
wavelengths, and the physical value of the image's non-frame indices (the
|
|
5
|
+
wavelength of an emission / excitation index, the time of a lifetime bin) --
|
|
6
|
+
each
|
|
7
|
+
physical value a ``Quantity(value, unit)`` in the file's own unit when
|
|
8
|
+
OME-Zarr has a name for it (D1), ``None`` / absent when the file does not
|
|
9
|
+
say (D2). Backends fill one ``Metadata`` per image; ``image.metadata``.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field, replace
|
|
16
|
+
from typing import NamedTuple, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Quantity(NamedTuple):
|
|
20
|
+
value: float
|
|
21
|
+
unit: str
|
|
22
|
+
|
|
23
|
+
def to(self, unit: str) -> "Quantity":
|
|
24
|
+
"""This quantity in another unit of the same kind."""
|
|
25
|
+
if unit == self.unit:
|
|
26
|
+
return self
|
|
27
|
+
return Quantity(self.value * _FACTORS[self.unit] / _FACTORS[unit], unit)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
#: OME-Zarr's units (UDUNITS-2 names), with each one's size in metres or
|
|
31
|
+
#: seconds -- the factors convert between units of one kind.
|
|
32
|
+
SPACE_UNITS = {
|
|
33
|
+
"angstrom": 1e-10, "attometer": 1e-18, "centimeter": 1e-2, "decimeter": 1e-1,
|
|
34
|
+
"exameter": 1e18, "femtometer": 1e-15, "foot": 0.3048, "gigameter": 1e9,
|
|
35
|
+
"hectometer": 1e2, "inch": 0.0254, "kilometer": 1e3, "megameter": 1e6, "meter": 1.0,
|
|
36
|
+
"micrometer": 1e-6, "mile": 1609.344, "millimeter": 1e-3, "nanometer": 1e-9,
|
|
37
|
+
"parsec": 3.0857e16, "petameter": 1e15, "picometer": 1e-12, "terameter": 1e12,
|
|
38
|
+
"yard": 0.9144, "yoctometer": 1e-24, "yottameter": 1e24, "zeptometer": 1e-21,
|
|
39
|
+
"zettameter": 1e21,
|
|
40
|
+
}
|
|
41
|
+
TIME_UNITS = {
|
|
42
|
+
"attosecond": 1e-18, "centisecond": 1e-2, "day": 86400.0, "decisecond": 1e-1,
|
|
43
|
+
"exasecond": 1e18, "femtosecond": 1e-15, "gigasecond": 1e9, "hectosecond": 1e2,
|
|
44
|
+
"hour": 3600.0, "kilosecond": 1e3, "megasecond": 1e6, "microsecond": 1e-6,
|
|
45
|
+
"millisecond": 1e-3, "minute": 60.0, "nanosecond": 1e-9, "petasecond": 1e15,
|
|
46
|
+
"picosecond": 1e-12, "second": 1.0, "terasecond": 1e12, "yoctosecond": 1e-24,
|
|
47
|
+
"yottasecond": 1e24, "zeptosecond": 1e-21, "zettasecond": 1e21,
|
|
48
|
+
}
|
|
49
|
+
_FACTORS = {**SPACE_UNITS, **TIME_UNITS}
|
|
50
|
+
|
|
51
|
+
#: how files spell units -> OME-Zarr's name (renaming, not converting)
|
|
52
|
+
_ALIASES = {
|
|
53
|
+
"m": "meter", "metre": "meter", "meters": "meter", "metres": "meter",
|
|
54
|
+
"cm": "centimeter", "mm": "millimeter", "um": "micrometer", "µm": "micrometer",
|
|
55
|
+
"μm": "micrometer", "micron": "micrometer", "microns": "micrometer",
|
|
56
|
+
"micrometre": "micrometer", "micrometers": "micrometer",
|
|
57
|
+
"�m": "micrometer", "µm": "micrometer", # µ decoded with the wrong charset
|
|
58
|
+
"nm": "nanometer",
|
|
59
|
+
"nanometre": "nanometer", "pm": "picometer", "a": "angstrom", "å": "angstrom",
|
|
60
|
+
"Å": "angstrom", "ang": "angstrom", "angstroms": "angstrom", "in": "inch",
|
|
61
|
+
"inches": "inch", "ft": "foot",
|
|
62
|
+
"s": "second", "sec": "second", "secs": "second", "seconds": "second",
|
|
63
|
+
"ms": "millisecond", "msec": "millisecond", "milliseconds": "millisecond",
|
|
64
|
+
"us": "microsecond", "µs": "microsecond", "μs": "microsecond",
|
|
65
|
+
"microseconds": "microsecond", "ns": "nanosecond", "ps": "picosecond",
|
|
66
|
+
"fs": "femtosecond", "min": "minute", "mins": "minute", "minutes": "minute",
|
|
67
|
+
"h": "hour", "hr": "hour", "hrs": "hour", "hours": "hour", "d": "day", "days": "day",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def unit_name(unit: Optional[str]) -> Optional[str]:
|
|
72
|
+
"""OME-Zarr's name for *unit* as a file spells it, or None (unknown)."""
|
|
73
|
+
if unit is None:
|
|
74
|
+
return None
|
|
75
|
+
text = str(unit).strip()
|
|
76
|
+
if text in _FACTORS:
|
|
77
|
+
return text
|
|
78
|
+
if text in _ALIASES: # case matters: "Å", "ms" vs "Ms"
|
|
79
|
+
return _ALIASES[text]
|
|
80
|
+
lowered = text.lower()
|
|
81
|
+
if lowered in _FACTORS:
|
|
82
|
+
return lowered
|
|
83
|
+
return _ALIASES.get(lowered)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
#: lengths in units above this many metres (cm, m, inch, ...) are given in
|
|
87
|
+
#: LARGE_LENGTH_UNIT: microscopy has no use for a pixel of 2.1e-8 meter
|
|
88
|
+
#: (Bugra, 2026-10-02); millimetres and smaller keep the file's unit (D1)
|
|
89
|
+
LARGEST_KEPT_LENGTH = 1e-3
|
|
90
|
+
LARGE_LENGTH_UNIT = "micrometer"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def quantity(value, unit: Optional[str], *, positive: bool = True,
|
|
94
|
+
default_unit: Optional[str] = None) -> Optional[Quantity]:
|
|
95
|
+
"""A Quantity from a file's value and unit spelling; None when the value
|
|
96
|
+
is missing or not a finite number (or not positive, for sizes), or the
|
|
97
|
+
unit is unknown. *default_unit* is used when the file gives none. The
|
|
98
|
+
file's unit is kept, except lengths in units larger than a millimetre,
|
|
99
|
+
which are converted to micrometres."""
|
|
100
|
+
try:
|
|
101
|
+
number = float(value)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
return None
|
|
104
|
+
if not math.isfinite(number) or (positive and number <= 0):
|
|
105
|
+
return None
|
|
106
|
+
name = unit_name(unit if unit not in (None, "") else default_unit)
|
|
107
|
+
if name is None:
|
|
108
|
+
return None
|
|
109
|
+
if SPACE_UNITS.get(name, 0.0) > LARGEST_KEPT_LENGTH:
|
|
110
|
+
return Quantity(number, name).to(LARGE_LENGTH_UNIT)
|
|
111
|
+
return Quantity(number, name)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def color_hex(value) -> Optional[str]:
|
|
115
|
+
"""'#RRGGBB' from the ways files store a colour: '#RRGGBB', '#AARRGGBB',
|
|
116
|
+
'RRGGBB', a signed 32-bit RGBA int (OME), or an (r, g, b) tuple."""
|
|
117
|
+
if value is None:
|
|
118
|
+
return None
|
|
119
|
+
if isinstance(value, (tuple, list)) and len(value) >= 3:
|
|
120
|
+
r, g, b = (int(round(float(v))) for v in value[:3])
|
|
121
|
+
if max(r, g, b) <= 1 and any(isinstance(v, float) for v in value[:3]):
|
|
122
|
+
r, g, b = (int(round(float(v) * 255)) for v in value[:3])
|
|
123
|
+
return "#{:02X}{:02X}{:02X}".format(r & 255, g & 255, b & 255)
|
|
124
|
+
if isinstance(value, int): # OME: RGBA, signed
|
|
125
|
+
v = value & 0xFFFFFFFF
|
|
126
|
+
return "#{:02X}{:02X}{:02X}".format((v >> 24) & 255, (v >> 16) & 255, (v >> 8) & 255)
|
|
127
|
+
text = str(value).strip().lstrip("#")
|
|
128
|
+
if re.fullmatch(r"[0-9A-Fa-f]{8}", text): # AARRGGBB (Zeiss, Imaris)
|
|
129
|
+
return "#" + text[2:].upper()
|
|
130
|
+
if re.fullmatch(r"[0-9A-Fa-f]{6}", text):
|
|
131
|
+
return "#" + text.upper()
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class Channel:
|
|
137
|
+
name: Optional[str] = None
|
|
138
|
+
color: Optional[str] = None # '#RRGGBB'
|
|
139
|
+
emission_wavelength: Optional[Quantity] = None
|
|
140
|
+
excitation_wavelength: Optional[Quantity] = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class Metadata:
|
|
145
|
+
"""One image's metadata (docs/METADATA.md); what the file does not say
|
|
146
|
+
is absent / None."""
|
|
147
|
+
pixel_sizes: dict = field(default_factory=dict) # axis -> Quantity (x y z t)
|
|
148
|
+
position: dict = field(default_factory=dict) # axis -> Quantity
|
|
149
|
+
channels: tuple = () # Channel per index of c
|
|
150
|
+
#: this image's value of its indices that have one (D5): {"emission":
|
|
151
|
+
#: Quantity(510.0, "nanometer"), "lifetime": Quantity(2.5, "nanosecond")}
|
|
152
|
+
index_values: dict = field(default_factory=dict)
|
|
153
|
+
|
|
154
|
+
def scaled(self, factors: dict) -> "Metadata":
|
|
155
|
+
"""This metadata for a resolution level whose pixels are *factors*
|
|
156
|
+
(axis -> float) times larger than level 0's."""
|
|
157
|
+
sizes = {a: Quantity(q.value * factors.get(a, 1.0), q.unit)
|
|
158
|
+
for a, q in self.pixel_sizes.items()}
|
|
159
|
+
return replace(self, pixel_sizes=sizes)
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def unknown(cls, n_channels: int = 1) -> "Metadata":
|
|
163
|
+
return cls(channels=tuple(Channel() for _ in range(max(1, n_channels))))
|
micro_reader/_pool.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""One process-wide thread pool for decoding and reading.
|
|
2
|
+
|
|
3
|
+
Codecs (zlib, LZW, zstd, JPEG, ...) and file reads release the GIL, so a
|
|
4
|
+
single read can decode and read its pieces on all cores instead of only on
|
|
5
|
+
the caller's thread.
|
|
6
|
+
Shared by every scene, so the total number of decoding threads stays fixed
|
|
7
|
+
however many reads run at once.
|
|
8
|
+
|
|
9
|
+
Size: ``set_decode_threads(n)``, else the ``MICRO_READER_DECODE_THREADS``
|
|
10
|
+
environment variable, else the number of CPUs. 1 decodes on the caller's
|
|
11
|
+
thread (no pool). Callers that already run many reads in parallel (like
|
|
12
|
+
eubi-bridge's writer) can lower it to avoid oversubscribing the CPU.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import itertools
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
from collections import deque
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
21
|
+
from typing import Callable, Iterable, Optional
|
|
22
|
+
|
|
23
|
+
_lock = threading.Lock()
|
|
24
|
+
_pool: Optional[ThreadPoolExecutor] = None
|
|
25
|
+
_threads: Optional[int] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def decode_threads() -> int:
|
|
29
|
+
"""The number of decoding threads in use."""
|
|
30
|
+
global _threads
|
|
31
|
+
if _threads is None:
|
|
32
|
+
env = os.environ.get("MICRO_READER_DECODE_THREADS")
|
|
33
|
+
_threads = max(1, int(env)) if env else (os.cpu_count() or 1)
|
|
34
|
+
return _threads
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_decode_threads(n: int) -> None:
|
|
38
|
+
"""Set the number of decoding threads (1: decode on the caller's thread)."""
|
|
39
|
+
global _pool, _threads
|
|
40
|
+
with _lock:
|
|
41
|
+
old, _pool = _pool, None
|
|
42
|
+
_threads = max(1, int(n))
|
|
43
|
+
if old is not None:
|
|
44
|
+
old.shutdown(wait=False)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_pool() -> Optional[ThreadPoolExecutor]:
|
|
48
|
+
global _pool
|
|
49
|
+
if decode_threads() < 2:
|
|
50
|
+
return None
|
|
51
|
+
with _lock:
|
|
52
|
+
if _pool is None:
|
|
53
|
+
_pool = ThreadPoolExecutor(decode_threads(), thread_name_prefix="micro-reader-decode")
|
|
54
|
+
return _pool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(tasks: Iterable[Callable[[], int]]) -> int:
|
|
58
|
+
"""Run *tasks* (each returning a count) on the pool; return the sum.
|
|
59
|
+
|
|
60
|
+
The iterable is consumed lazily and at most two tasks per thread are in
|
|
61
|
+
flight, so whatever the tasks hold (compressed bytes, decoded segments)
|
|
62
|
+
stays bounded however many there are. Preparation done by the iterable
|
|
63
|
+
itself -- reading bytes from a file handle that is not thread-safe --
|
|
64
|
+
happens on the caller's thread.
|
|
65
|
+
"""
|
|
66
|
+
pool = _get_pool()
|
|
67
|
+
if pool is None:
|
|
68
|
+
return sum(task() for task in tasks)
|
|
69
|
+
tasks = iter(tasks)
|
|
70
|
+
first, second = next(tasks, None), next(tasks, None)
|
|
71
|
+
if second is None: # nothing to spread: skip the hand-off
|
|
72
|
+
return first() if first is not None else 0
|
|
73
|
+
tasks = itertools.chain((first, second), tasks)
|
|
74
|
+
window, pending, total = 2 * decode_threads(), deque(), 0
|
|
75
|
+
try:
|
|
76
|
+
for task in tasks:
|
|
77
|
+
pending.append(pool.submit(task))
|
|
78
|
+
if len(pending) >= window:
|
|
79
|
+
total += pending.popleft().result()
|
|
80
|
+
while pending:
|
|
81
|
+
total += pending.popleft().result()
|
|
82
|
+
finally:
|
|
83
|
+
for future in pending: # after an error: don't leave work running
|
|
84
|
+
future.cancel()
|
|
85
|
+
return total
|