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.
@@ -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]
@@ -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