auroraomics 0.1.0.dev0__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.
- auroraomics/__init__.py +74 -0
- auroraomics/_contracts/MANIFEST.json +11 -0
- auroraomics/_contracts/genes/gene-table.2026-09-05.json +19368 -0
- auroraomics/_contracts/public-api/golden-16-tiles.manifest.json +308 -0
- auroraomics/_contracts/public-api-counters.tokens.json +28 -0
- auroraomics/_contracts/public-api-input-kinds.tokens.json +71 -0
- auroraomics/_contracts/public-api.tokens.json +345 -0
- auroraomics/_contracts/qc-thresholds.tokens.json +10 -0
- auroraomics/contracts.py +155 -0
- auroraomics/h5ad.py +398 -0
- auroraomics/pack.py +667 -0
- auroraomics/py.typed +0 -0
- auroraomics/qc.py +288 -0
- auroraomics/subsample.py +140 -0
- auroraomics-0.1.0.dev0.dist-info/METADATA +134 -0
- auroraomics-0.1.0.dev0.dist-info/RECORD +19 -0
- auroraomics-0.1.0.dev0.dist-info/WHEEL +5 -0
- auroraomics-0.1.0.dev0.dist-info/licenses/LICENSE +131 -0
- auroraomics-0.1.0.dev0.dist-info/top_level.txt +1 -0
auroraomics/pack.py
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Build and validate the ``patches`` container.
|
|
2
|
+
|
|
3
|
+
A prediction takes one ``.zip`` per sample: the tiles as images, a ``tiles.csv``
|
|
4
|
+
naming each tile and where on the slide it came from, and optionally a
|
|
5
|
+
thumbnail. It is a zip and not an array file because zip members stream — a
|
|
6
|
+
loader can pull one tile at a time out of a 50k-tile archive, while a single
|
|
7
|
+
packed array has to be materialised whole before the first tile is available.
|
|
8
|
+
|
|
9
|
+
Two directions, with very different obligations:
|
|
10
|
+
|
|
11
|
+
:func:`pack_tiles` **produces** an archive from tiles you already have. It runs
|
|
12
|
+
quality control as the tiles stream past, writes only the ones that pass,
|
|
13
|
+
strips every scrap of metadata from the images it writes, and reports what
|
|
14
|
+
it dropped and why. Metadata stripping is not cosmetic: a tile cut from a
|
|
15
|
+
scanned slide can inherit the slide label, the scanner serial and a capture
|
|
16
|
+
timestamp, which is patient-adjacent data leaving the building inside an
|
|
17
|
+
image nobody looks inside.
|
|
18
|
+
|
|
19
|
+
:func:`read_archive` **consumes** one, and treats it as hostile. Everything it
|
|
20
|
+
can check from the archive's own index — member names, declared sizes, the
|
|
21
|
+
manifest, the image headers — is checked BEFORE a single pixel is decoded,
|
|
22
|
+
because a decoder is the largest attack surface in the whole path and the
|
|
23
|
+
sizes a zip declares are written by whoever produced it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import csv
|
|
29
|
+
import io
|
|
30
|
+
import numbers
|
|
31
|
+
import re
|
|
32
|
+
import zipfile
|
|
33
|
+
from collections import Counter
|
|
34
|
+
from collections.abc import Iterable, Iterator
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path, PurePosixPath
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import cv2
|
|
40
|
+
import numpy as np
|
|
41
|
+
from PIL import Image
|
|
42
|
+
|
|
43
|
+
from . import contracts
|
|
44
|
+
from .qc import DEFAULT_THRESHOLDS, QcResult, QcThresholds, qc_tile
|
|
45
|
+
|
|
46
|
+
#: The input kind whose registry row bounds an archive.
|
|
47
|
+
KIND = "patches"
|
|
48
|
+
|
|
49
|
+
#: Decimal places each QC statistic is written with. Percentages to two places
|
|
50
|
+
#: and a variance to one is the precision the manifest carries, and a packer
|
|
51
|
+
#: that writes more digits produces a file no committed expectation can pin.
|
|
52
|
+
STAT_DECIMALS = {"foreground_ratio": 2, "hsv_ratio": 2, "laplacian_var": 1}
|
|
53
|
+
|
|
54
|
+
#: Every member of a fixed archive timestamp, so two runs over the same tiles
|
|
55
|
+
#: produce the same bytes. Zip cannot represent anything before 1980, and a
|
|
56
|
+
#: real time would make the archive unhashable and every diff of two archives
|
|
57
|
+
#: a diff of when they were built.
|
|
58
|
+
_FIXED_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
|
|
59
|
+
|
|
60
|
+
#: Unix permissions for a written member: readable, never executable.
|
|
61
|
+
_MEMBER_MODE = 0o644 << 16
|
|
62
|
+
|
|
63
|
+
#: Image formats Pillow may report for a member, by extension.
|
|
64
|
+
_FORMATS = {".png": {"PNG"}, ".jpg": {"JPEG"}, ".jpeg": {"JPEG"}}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PackError(ValueError):
|
|
68
|
+
"""An archive cannot be built from what was passed."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ArchiveError(ValueError):
|
|
72
|
+
"""An archive is not a valid container and must not be trusted."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Tile:
|
|
77
|
+
"""One tile: an RGB ``uint8`` image and its position on the slide.
|
|
78
|
+
|
|
79
|
+
``x`` and ``y`` are the tile's top-left corner in full-resolution slide
|
|
80
|
+
pixels, which is the frame every coordinate in the result is expressed in.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
image: np.ndarray
|
|
84
|
+
x: int
|
|
85
|
+
y: int
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Rejection:
|
|
90
|
+
"""One tile that quality control dropped, and the numbers behind it."""
|
|
91
|
+
|
|
92
|
+
x: int
|
|
93
|
+
y: int
|
|
94
|
+
reason: str
|
|
95
|
+
stats: dict[str, float]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class PackReport:
|
|
100
|
+
"""What :func:`pack_tiles` wrote, and what it did not."""
|
|
101
|
+
|
|
102
|
+
path: Path
|
|
103
|
+
written: int
|
|
104
|
+
rejected: int
|
|
105
|
+
rejected_by_reason: dict[str, int]
|
|
106
|
+
rejections: tuple[Rejection, ...]
|
|
107
|
+
thresholds: QcThresholds
|
|
108
|
+
mpp: float
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def considered(self) -> int:
|
|
112
|
+
"""Tiles handed in, whether they were written or not."""
|
|
113
|
+
return self.written + self.rejected
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class ArchiveRow:
|
|
118
|
+
"""One row of an archive's manifest, parsed.
|
|
119
|
+
|
|
120
|
+
``slots=True`` and no untyped spillover dict: an archive may declare 50k
|
|
121
|
+
tiles and :class:`PatchArchive` holds every row for its whole open life, so
|
|
122
|
+
a per-row dict of the same values as strings is tens of megabytes of exact
|
|
123
|
+
duplicate. A reader that needs the extra QC columns should parse the
|
|
124
|
+
manifest itself rather than have every reader pay for them.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
tile_id: str
|
|
128
|
+
file: str
|
|
129
|
+
x: int
|
|
130
|
+
y: int
|
|
131
|
+
width: int
|
|
132
|
+
height: int
|
|
133
|
+
mpp: float
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _spec() -> dict[str, Any]:
|
|
137
|
+
return contracts.patch_archive()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _tile_name_pattern() -> re.Pattern[str]:
|
|
141
|
+
return re.compile(_spec()["tile_name_pattern"])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _name_for(index: int) -> str:
|
|
145
|
+
"""The member name of the ``index``-th written tile.
|
|
146
|
+
|
|
147
|
+
The width of the number comes out of the container's own name pattern
|
|
148
|
+
rather than being spelled here, so the two cannot disagree — and when the
|
|
149
|
+
pattern cannot express an index, that is a hard error rather than a name a
|
|
150
|
+
validator on the other side will reject.
|
|
151
|
+
"""
|
|
152
|
+
pattern = _spec()["tile_name_pattern"]
|
|
153
|
+
digits = re.search(r"\{(\d+)\}", pattern)
|
|
154
|
+
if digits is None:
|
|
155
|
+
# Falling back to a guessed width made the refusal below misstate the
|
|
156
|
+
# pattern's capacity as 10,000, which is a worse answer than saying
|
|
157
|
+
# the width could not be read.
|
|
158
|
+
raise PackError(
|
|
159
|
+
f"the container's tile name pattern {pattern} declares no digit "
|
|
160
|
+
"width, so a tile's name cannot be derived from it"
|
|
161
|
+
)
|
|
162
|
+
width = int(digits.group(1))
|
|
163
|
+
name = f"tile_{index:0{width}d}.png"
|
|
164
|
+
if not re.match(pattern, name):
|
|
165
|
+
raise PackError(
|
|
166
|
+
f"tile {index} cannot be named inside this container: the name "
|
|
167
|
+
f"pattern {pattern} holds at most {10 ** width} tiles. Reduce the "
|
|
168
|
+
"tile count first — auroraomics.subsample picks the densest square."
|
|
169
|
+
)
|
|
170
|
+
return name
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _encode_png(image: np.ndarray) -> bytes:
|
|
174
|
+
"""Encode RGB ``uint8`` as PNG carrying no metadata at all.
|
|
175
|
+
|
|
176
|
+
The encoder is handed nothing but pixels, so there is nothing for it to
|
|
177
|
+
write into a text chunk, and it writes no timestamp of its own. A test
|
|
178
|
+
walks the chunk types of a written tile to keep that true.
|
|
179
|
+
"""
|
|
180
|
+
ok, buffer = cv2.imencode(".png", cv2.cvtColor(image, cv2.COLOR_RGB2BGR))
|
|
181
|
+
if not ok: # pragma: no cover - only a broken build gets here
|
|
182
|
+
raise PackError("the image encoder refused a tile")
|
|
183
|
+
return buffer.tobytes()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _encode_jpeg(image: np.ndarray, quality: int = 90) -> bytes:
|
|
187
|
+
"""Encode RGB ``uint8`` as JPEG carrying no EXIF and no thumbnail."""
|
|
188
|
+
ok, buffer = cv2.imencode(
|
|
189
|
+
".jpg",
|
|
190
|
+
cv2.cvtColor(image, cv2.COLOR_RGB2BGR),
|
|
191
|
+
[int(cv2.IMWRITE_JPEG_QUALITY), quality],
|
|
192
|
+
)
|
|
193
|
+
if not ok: # pragma: no cover
|
|
194
|
+
raise PackError("the image encoder refused the thumbnail")
|
|
195
|
+
return buffer.tobytes()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _write_member(archive: zipfile.ZipFile, name: str, payload: bytes) -> None:
|
|
199
|
+
info = zipfile.ZipInfo(name, date_time=_FIXED_TIMESTAMP)
|
|
200
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
201
|
+
info.external_attr = _MEMBER_MODE
|
|
202
|
+
archive.writestr(info, payload)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _format_stat(name: str, value: float) -> str:
|
|
206
|
+
return str(round(float(value), STAT_DECIMALS[name]))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def pack_tiles(
|
|
210
|
+
tiles: Iterable[Tile],
|
|
211
|
+
out_zip: str | Path,
|
|
212
|
+
*,
|
|
213
|
+
mpp: float,
|
|
214
|
+
thresholds: QcThresholds | None = None,
|
|
215
|
+
thumbnail: np.ndarray | None = None,
|
|
216
|
+
) -> PackReport:
|
|
217
|
+
"""Write the tiles that pass quality control into one archive.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
tiles:
|
|
222
|
+
Any iterable of :class:`Tile`. It is consumed lazily and one tile at a
|
|
223
|
+
time, so a generator that decodes a slide region per step never holds
|
|
224
|
+
more than one tile — which is what makes a whole slide packable on a
|
|
225
|
+
laptop.
|
|
226
|
+
out_zip:
|
|
227
|
+
Where to write. An existing file is replaced.
|
|
228
|
+
mpp:
|
|
229
|
+
Micrometres per pixel of the tiles, at the resolution they were cut.
|
|
230
|
+
Recorded for every tile: without it a tile is a picture of unknown
|
|
231
|
+
physical size and no model can be matched to it.
|
|
232
|
+
thresholds:
|
|
233
|
+
Quality-control floors. The defaults if omitted.
|
|
234
|
+
thumbnail:
|
|
235
|
+
An optional RGB overview image, written as the container's thumbnail.
|
|
236
|
+
Any metadata it carries is dropped, as for every tile.
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
PackReport
|
|
241
|
+
Counts, plus one :class:`Rejection` per dropped tile naming the
|
|
242
|
+
threshold that dropped it. A caller that gets an unexpectedly small
|
|
243
|
+
archive can see immediately whether the slide was empty or a threshold
|
|
244
|
+
was wrong.
|
|
245
|
+
"""
|
|
246
|
+
# numbers.Real rather than (int, float), because a resolution usually comes
|
|
247
|
+
# out of a slide's own metadata as a numpy scalar; bool is excluded because
|
|
248
|
+
# True would otherwise be accepted as 1 micrometre per pixel.
|
|
249
|
+
if isinstance(mpp, bool) or not isinstance(mpp, numbers.Real) or not mpp > 0:
|
|
250
|
+
raise PackError(f"mpp must be a positive number of micrometres, got {mpp!r}")
|
|
251
|
+
thresholds = thresholds or DEFAULT_THRESHOLDS
|
|
252
|
+
spec = _spec()
|
|
253
|
+
columns = list(spec["manifest_columns"]) + list(QcResult.stat_names())
|
|
254
|
+
|
|
255
|
+
out_zip = Path(out_zip)
|
|
256
|
+
rows: list[list[str]] = []
|
|
257
|
+
rejections: list[Rejection] = []
|
|
258
|
+
by_reason: dict[str, int] = {}
|
|
259
|
+
|
|
260
|
+
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
261
|
+
for tile in tiles:
|
|
262
|
+
verdict = qc_tile(tile.image, thresholds)
|
|
263
|
+
if not verdict.passes:
|
|
264
|
+
reason = verdict.rejected_by or "unknown"
|
|
265
|
+
rejections.append(
|
|
266
|
+
Rejection(
|
|
267
|
+
x=int(tile.x), y=int(tile.y), reason=reason,
|
|
268
|
+
stats=verdict.stats(),
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
by_reason[reason] = by_reason.get(reason, 0) + 1
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
name = _name_for(len(rows))
|
|
275
|
+
height, width = tile.image.shape[:2]
|
|
276
|
+
_write_member(archive, name, _encode_png(tile.image))
|
|
277
|
+
values = {
|
|
278
|
+
"tile_id": PurePosixPath(name).stem,
|
|
279
|
+
"file": name,
|
|
280
|
+
"x": str(int(tile.x)),
|
|
281
|
+
"y": str(int(tile.y)),
|
|
282
|
+
"width": str(int(width)),
|
|
283
|
+
"height": str(int(height)),
|
|
284
|
+
"mpp": str(mpp),
|
|
285
|
+
**{
|
|
286
|
+
stat: _format_stat(stat, value)
|
|
287
|
+
for stat, value in verdict.stats().items()
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
rows.append([values[column] for column in columns])
|
|
291
|
+
|
|
292
|
+
buffer = io.StringIO()
|
|
293
|
+
writer = csv.writer(buffer)
|
|
294
|
+
writer.writerow(columns)
|
|
295
|
+
writer.writerows(rows)
|
|
296
|
+
_write_member(archive, spec["manifest"], buffer.getvalue().encode("utf-8"))
|
|
297
|
+
|
|
298
|
+
if thumbnail is not None:
|
|
299
|
+
_write_member(
|
|
300
|
+
archive, spec["thumbnail"], _encode_jpeg(np.asarray(thumbnail))
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
return PackReport(
|
|
304
|
+
path=out_zip,
|
|
305
|
+
written=len(rows),
|
|
306
|
+
rejected=len(rejections),
|
|
307
|
+
rejected_by_reason=by_reason,
|
|
308
|
+
rejections=tuple(rejections),
|
|
309
|
+
thresholds=thresholds,
|
|
310
|
+
mpp=float(mpp),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ── reading someone else's archive ──────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class PatchArchive:
|
|
318
|
+
"""A validated archive, open for reading one tile at a time.
|
|
319
|
+
|
|
320
|
+
Constructed by :func:`read_archive`, which has already checked everything
|
|
321
|
+
checkable without decoding. Decoding happens here, per tile, on demand.
|
|
322
|
+
|
|
323
|
+
The instance holds the archive OPEN, because a zip's index is read on open
|
|
324
|
+
and re-opening per tile would make reading an archive quadratic in the
|
|
325
|
+
number of tiles — which at 50k tiles is the difference between seconds and
|
|
326
|
+
an afternoon. Close it when done, or use it as a context manager:
|
|
327
|
+
|
|
328
|
+
>>> with read_archive("sample.zip") as archive: # doctest: +SKIP
|
|
329
|
+
... for tile in archive.tiles():
|
|
330
|
+
... ...
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
def __init__(
|
|
334
|
+
self,
|
|
335
|
+
path: Path,
|
|
336
|
+
handle: zipfile.ZipFile,
|
|
337
|
+
rows: tuple[ArchiveRow, ...],
|
|
338
|
+
tile_names: tuple[str, ...],
|
|
339
|
+
has_thumbnail: bool,
|
|
340
|
+
) -> None:
|
|
341
|
+
self.path = path
|
|
342
|
+
self.rows = rows
|
|
343
|
+
self.tile_names = tile_names
|
|
344
|
+
self.has_thumbnail = has_thumbnail
|
|
345
|
+
self._handle = handle
|
|
346
|
+
# read_tile checks membership once per tile and tiles() calls it once
|
|
347
|
+
# per row, so a linear scan of tile_names here is what makes reading a
|
|
348
|
+
# whole archive quadratic — the very cost this class exists to avoid.
|
|
349
|
+
self._tiles = frozenset(tile_names)
|
|
350
|
+
|
|
351
|
+
def __len__(self) -> int:
|
|
352
|
+
return len(self.rows)
|
|
353
|
+
|
|
354
|
+
def close(self) -> None:
|
|
355
|
+
"""Release the archive's file handle."""
|
|
356
|
+
self._handle.close()
|
|
357
|
+
|
|
358
|
+
def __enter__(self) -> PatchArchive:
|
|
359
|
+
return self
|
|
360
|
+
|
|
361
|
+
def __exit__(self, *exc: object) -> None:
|
|
362
|
+
self.close()
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def mpp(self) -> float:
|
|
366
|
+
"""The archive's resolution, which every row must agree on."""
|
|
367
|
+
values = {row.mpp for row in self.rows}
|
|
368
|
+
if len(values) != 1:
|
|
369
|
+
raise ArchiveError(
|
|
370
|
+
f"the manifest declares {len(values)} different mpp values; "
|
|
371
|
+
"tiles of one sample are cut at one resolution"
|
|
372
|
+
)
|
|
373
|
+
return values.pop()
|
|
374
|
+
|
|
375
|
+
def _payload(self, name: str) -> bytes:
|
|
376
|
+
return _read_bounded(self._handle, name)
|
|
377
|
+
|
|
378
|
+
def read_tile(self, name: str) -> np.ndarray:
|
|
379
|
+
"""Decode one tile to an RGB ``uint8`` array."""
|
|
380
|
+
if name not in self._tiles:
|
|
381
|
+
raise ArchiveError(f"{name!r} is not a tile of this archive")
|
|
382
|
+
image = cv2.imdecode(
|
|
383
|
+
np.frombuffer(self._payload(name), dtype=np.uint8), cv2.IMREAD_COLOR
|
|
384
|
+
)
|
|
385
|
+
if image is None:
|
|
386
|
+
raise ArchiveError(f"member {name} is not a decodable image")
|
|
387
|
+
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
|
388
|
+
|
|
389
|
+
def tiles(self) -> Iterator[Tile]:
|
|
390
|
+
"""Every tile in manifest order, decoded one at a time."""
|
|
391
|
+
for row in self.rows:
|
|
392
|
+
yield Tile(image=self.read_tile(row.file), x=row.x, y=row.y)
|
|
393
|
+
|
|
394
|
+
def thumbnail(self) -> np.ndarray | None:
|
|
395
|
+
"""The overview image as RGB ``uint8``, or ``None`` if there is none."""
|
|
396
|
+
if not self.has_thumbnail:
|
|
397
|
+
return None
|
|
398
|
+
payload = self._payload(_spec()["thumbnail"])
|
|
399
|
+
image = cv2.imdecode(np.frombuffer(payload, dtype=np.uint8), cv2.IMREAD_COLOR)
|
|
400
|
+
if image is None:
|
|
401
|
+
raise ArchiveError("the thumbnail is not a decodable image")
|
|
402
|
+
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _read_bounded(handle: zipfile.ZipFile, name: str) -> bytes:
|
|
406
|
+
"""Read one member, refusing to accept more bytes than its index declares.
|
|
407
|
+
|
|
408
|
+
The declared size is what :func:`_check_declared_sizes` bounded, so it is
|
|
409
|
+
the number the caller has already agreed to spend memory on. Reading one
|
|
410
|
+
byte past it and refusing on a non-empty remainder turns a lying header
|
|
411
|
+
into an error instead of an unbounded read.
|
|
412
|
+
"""
|
|
413
|
+
info = handle.getinfo(name)
|
|
414
|
+
with handle.open(info) as member:
|
|
415
|
+
payload = member.read(info.file_size + 1)
|
|
416
|
+
if len(payload) > info.file_size:
|
|
417
|
+
raise ArchiveError(
|
|
418
|
+
f"member {name} holds more bytes than its index declares — the "
|
|
419
|
+
"archive is inconsistent and was not decoded"
|
|
420
|
+
)
|
|
421
|
+
return payload
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _check_member_name(name: str) -> None:
|
|
425
|
+
"""Refuse any name that is not a plain file at the archive root.
|
|
426
|
+
|
|
427
|
+
A member name is attacker-controlled text, and the two classic shapes are
|
|
428
|
+
an absolute path and a ``../`` walk out of the extraction directory. This
|
|
429
|
+
package never extracts to disk, but a caller might, and a name that cannot
|
|
430
|
+
escape is a name nobody has to remember to sanitise.
|
|
431
|
+
"""
|
|
432
|
+
if not name or name != PurePosixPath(name).name:
|
|
433
|
+
raise ArchiveError(
|
|
434
|
+
f"member {name!r} is not a plain file name at the archive root; a "
|
|
435
|
+
"container holds no directories and no paths"
|
|
436
|
+
)
|
|
437
|
+
if name.startswith(".") or "\\" in name or ".." in name:
|
|
438
|
+
raise ArchiveError(f"member {name!r} is not an acceptable file name")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _check_declared_sizes(infos: list[zipfile.ZipInfo], cap: int) -> None:
|
|
442
|
+
"""Bound the archive by what it DECLARES, before anything is decompressed.
|
|
443
|
+
|
|
444
|
+
This is the zip-bomb check, and it has to happen here: a few hundred
|
|
445
|
+
kilobytes of deflate can declare — and expand to — gigabytes, so the only
|
|
446
|
+
safe moment to refuse is while the numbers are still just numbers.
|
|
447
|
+
"""
|
|
448
|
+
total = 0
|
|
449
|
+
for info in infos:
|
|
450
|
+
if info.file_size < 0:
|
|
451
|
+
raise ArchiveError(f"member {info.filename} declares a negative size")
|
|
452
|
+
if info.file_size > cap:
|
|
453
|
+
raise ArchiveError(
|
|
454
|
+
f"member {info.filename} declares {info.file_size} bytes, over "
|
|
455
|
+
f"the {cap}-byte limit for one {KIND} input"
|
|
456
|
+
)
|
|
457
|
+
total += info.file_size
|
|
458
|
+
if total > cap:
|
|
459
|
+
raise ArchiveError(
|
|
460
|
+
f"the archive declares {total} bytes uncompressed, over the {cap}-byte "
|
|
461
|
+
f"limit for one {KIND} input"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _check_member_set(
|
|
466
|
+
names: list[str],
|
|
467
|
+
tile_pattern: re.Pattern[str],
|
|
468
|
+
manifest_name: str,
|
|
469
|
+
thumbnail_name: str,
|
|
470
|
+
) -> tuple[str, ...]:
|
|
471
|
+
"""Every member is named once, and named something the container defines.
|
|
472
|
+
|
|
473
|
+
A named step rather than two comprehensions inside :func:`read_archive`,
|
|
474
|
+
because it is the one part of validation that runs over EVERY member and so
|
|
475
|
+
is the one part whose cost has to be linear. Both checks were written as a
|
|
476
|
+
scan-inside-a-scan and cost ~49 s on a 50k-tile archive — the container's
|
|
477
|
+
own ceiling — for work that takes milliseconds. Keeping it here means it
|
|
478
|
+
can be tested at that ceiling without building a 50k-tile zip.
|
|
479
|
+
|
|
480
|
+
Returns the tile members, in archive order.
|
|
481
|
+
"""
|
|
482
|
+
# A zip may physically hold two members with the SAME name, and which one a
|
|
483
|
+
# reader gets is up to the reader — so one tool validates a member and
|
|
484
|
+
# another decodes the other. There is no legitimate reason for a container
|
|
485
|
+
# to carry one, so it is a refusal rather than a choice.
|
|
486
|
+
duplicates = sorted(n for n, seen in Counter(names).items() if seen > 1)
|
|
487
|
+
if duplicates:
|
|
488
|
+
raise ArchiveError(
|
|
489
|
+
f"the archive holds more than one member named: "
|
|
490
|
+
f"{', '.join(duplicates)}"
|
|
491
|
+
)
|
|
492
|
+
tile_names = tuple(name for name in names if tile_pattern.match(name))
|
|
493
|
+
defined = frozenset(tile_names) | {manifest_name, thumbnail_name}
|
|
494
|
+
stray = [name for name in names if name not in defined]
|
|
495
|
+
if stray:
|
|
496
|
+
raise ArchiveError(
|
|
497
|
+
f"the archive holds member(s) the container does not define: "
|
|
498
|
+
f"{', '.join(sorted(stray))}"
|
|
499
|
+
)
|
|
500
|
+
return tile_names
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _parse_manifest(payload: bytes, columns: list[str]) -> tuple[ArchiveRow, ...]:
|
|
504
|
+
try:
|
|
505
|
+
text = payload.decode("utf-8")
|
|
506
|
+
except UnicodeDecodeError as exc:
|
|
507
|
+
raise ArchiveError("the manifest is not UTF-8 text") from exc
|
|
508
|
+
reader = csv.reader(io.StringIO(text, newline=""))
|
|
509
|
+
try:
|
|
510
|
+
header = next(reader)
|
|
511
|
+
except StopIteration:
|
|
512
|
+
raise ArchiveError("the manifest is empty") from None
|
|
513
|
+
if header[: len(columns)] != columns:
|
|
514
|
+
raise ArchiveError(
|
|
515
|
+
f"the manifest must start with the columns {', '.join(columns)}; it "
|
|
516
|
+
f"starts with {', '.join(header[: len(columns)]) or '(nothing)'}"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
rows: list[ArchiveRow] = []
|
|
520
|
+
for number, values in enumerate(reader, start=2):
|
|
521
|
+
if not values:
|
|
522
|
+
continue
|
|
523
|
+
if len(values) != len(header):
|
|
524
|
+
raise ArchiveError(
|
|
525
|
+
f"manifest line {number} has {len(values)} fields for "
|
|
526
|
+
f"{len(header)} columns"
|
|
527
|
+
)
|
|
528
|
+
raw = dict(zip(header, values))
|
|
529
|
+
try:
|
|
530
|
+
rows.append(
|
|
531
|
+
ArchiveRow(
|
|
532
|
+
tile_id=raw["tile_id"],
|
|
533
|
+
file=raw["file"],
|
|
534
|
+
x=int(raw["x"]),
|
|
535
|
+
y=int(raw["y"]),
|
|
536
|
+
width=int(raw["width"]),
|
|
537
|
+
height=int(raw["height"]),
|
|
538
|
+
mpp=float(raw["mpp"]),
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
except ValueError as exc:
|
|
542
|
+
raise ArchiveError(f"manifest line {number}: {exc}") from exc
|
|
543
|
+
if not rows:
|
|
544
|
+
raise ArchiveError("the manifest declares no tiles")
|
|
545
|
+
return tuple(rows)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _check_image_header(name: str, payload: bytes) -> tuple[int, int]:
|
|
549
|
+
"""Width and height from the image HEADER, without decoding the pixels.
|
|
550
|
+
|
|
551
|
+
Pillow parses the header and stops, and it refuses implausible dimensions
|
|
552
|
+
on its own, so the geometry can be checked while the pixel data is still
|
|
553
|
+
untouched. It also reports the real format, which catches a member whose
|
|
554
|
+
extension says one thing and whose bytes say another.
|
|
555
|
+
"""
|
|
556
|
+
suffix = PurePosixPath(name).suffix.lower()
|
|
557
|
+
try:
|
|
558
|
+
with Image.open(io.BytesIO(payload)) as image:
|
|
559
|
+
width, height = image.size
|
|
560
|
+
found = image.format
|
|
561
|
+
except Exception as exc: # Pillow raises a family of errors here
|
|
562
|
+
raise ArchiveError(f"member {name} is not a readable image: {exc}") from exc
|
|
563
|
+
if found not in _FORMATS.get(suffix, set()):
|
|
564
|
+
raise ArchiveError(
|
|
565
|
+
f"member {name} is a {found} file, which does not match its "
|
|
566
|
+
f"{suffix} extension"
|
|
567
|
+
)
|
|
568
|
+
if width <= 0 or height <= 0:
|
|
569
|
+
raise ArchiveError(f"member {name} declares an empty image")
|
|
570
|
+
return width, height
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def read_archive(zip_path: str | Path) -> PatchArchive:
|
|
574
|
+
"""Validate an archive and return it, open for reading.
|
|
575
|
+
|
|
576
|
+
Every check a server has to make, in the order that keeps the risky ones
|
|
577
|
+
last:
|
|
578
|
+
|
|
579
|
+
1. it is a zip at all;
|
|
580
|
+
2. every member is a plain file name at the root — no paths, no traversal;
|
|
581
|
+
3. every DECLARED size, and their total, is inside the cap for this input
|
|
582
|
+
kind — before anything is decompressed;
|
|
583
|
+
4. the member names are exactly the manifest, the tiles matching the
|
|
584
|
+
container's name pattern, and at most the thumbnail: a stray member is a
|
|
585
|
+
refusal, not something to ignore;
|
|
586
|
+
5. the manifest parses, starts with the agreed columns, and its rows and
|
|
587
|
+
the tile members name each other exactly — in both directions;
|
|
588
|
+
6. every tile's header agrees with its manifest row and with every other
|
|
589
|
+
tile, so one sample is one geometry.
|
|
590
|
+
|
|
591
|
+
Raises :class:`ArchiveError` on the first failure, naming what failed.
|
|
592
|
+
"""
|
|
593
|
+
zip_path = Path(zip_path)
|
|
594
|
+
spec = _spec()
|
|
595
|
+
manifest_name = spec["manifest"]
|
|
596
|
+
thumbnail_name = spec["thumbnail"]
|
|
597
|
+
tile_pattern = _tile_name_pattern()
|
|
598
|
+
columns = list(spec["manifest_columns"])
|
|
599
|
+
|
|
600
|
+
try:
|
|
601
|
+
archive = zipfile.ZipFile(zip_path)
|
|
602
|
+
except zipfile.BadZipFile as exc:
|
|
603
|
+
raise ArchiveError(f"{zip_path.name} is not a zip archive") from exc
|
|
604
|
+
|
|
605
|
+
# Every refusal below closes the handle on the way out: a rejected archive
|
|
606
|
+
# must not leave a file open in the caller's process.
|
|
607
|
+
try:
|
|
608
|
+
infos = [info for info in archive.infolist() if not info.is_dir()]
|
|
609
|
+
if not infos:
|
|
610
|
+
raise ArchiveError("the archive is empty")
|
|
611
|
+
for info in infos:
|
|
612
|
+
_check_member_name(info.filename)
|
|
613
|
+
_check_declared_sizes(infos, contracts.max_bytes(KIND))
|
|
614
|
+
|
|
615
|
+
names = [info.filename for info in infos]
|
|
616
|
+
tile_names = _check_member_set(
|
|
617
|
+
names, tile_pattern, manifest_name, thumbnail_name
|
|
618
|
+
)
|
|
619
|
+
if manifest_name not in names:
|
|
620
|
+
raise ArchiveError(f"the archive has no {manifest_name}")
|
|
621
|
+
|
|
622
|
+
rows = _parse_manifest(_read_bounded(archive, manifest_name), columns)
|
|
623
|
+
|
|
624
|
+
declared = [row.file for row in rows]
|
|
625
|
+
if len(set(declared)) != len(declared):
|
|
626
|
+
raise ArchiveError("the manifest names the same tile twice")
|
|
627
|
+
missing = sorted(set(declared) - set(tile_names))
|
|
628
|
+
if missing:
|
|
629
|
+
raise ArchiveError(
|
|
630
|
+
f"the manifest names tile(s) the archive does not contain: "
|
|
631
|
+
f"{', '.join(missing)}"
|
|
632
|
+
)
|
|
633
|
+
undeclared = sorted(set(tile_names) - set(declared))
|
|
634
|
+
if undeclared:
|
|
635
|
+
raise ArchiveError(
|
|
636
|
+
f"the archive contains tile(s) the manifest does not name: "
|
|
637
|
+
f"{', '.join(undeclared)}"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
geometry: set[tuple[int, int]] = set()
|
|
641
|
+
for row in rows:
|
|
642
|
+
width, height = _check_image_header(
|
|
643
|
+
row.file, _read_bounded(archive, row.file)
|
|
644
|
+
)
|
|
645
|
+
if (width, height) != (row.width, row.height):
|
|
646
|
+
raise ArchiveError(
|
|
647
|
+
f"member {row.file} is {width}x{height} but its manifest row "
|
|
648
|
+
f"says {row.width}x{row.height}"
|
|
649
|
+
)
|
|
650
|
+
geometry.add((width, height))
|
|
651
|
+
if len(geometry) > 1:
|
|
652
|
+
sizes = ", ".join(f"{w}x{h}" for w, h in sorted(geometry))
|
|
653
|
+
raise ArchiveError(
|
|
654
|
+
f"the tiles are not one geometry ({sizes}); a model is matched to "
|
|
655
|
+
"one tile size and one resolution"
|
|
656
|
+
)
|
|
657
|
+
except BaseException:
|
|
658
|
+
archive.close()
|
|
659
|
+
raise
|
|
660
|
+
|
|
661
|
+
return PatchArchive(
|
|
662
|
+
path=zip_path,
|
|
663
|
+
handle=archive,
|
|
664
|
+
rows=rows,
|
|
665
|
+
tile_names=tile_names,
|
|
666
|
+
has_thumbnail=thumbnail_name in names,
|
|
667
|
+
)
|
auroraomics/py.typed
ADDED
|
File without changes
|