tmapslide 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {tmapslide-0.2.1 → tmapslide-0.2.2}/PKG-INFO +26 -12
- {tmapslide-0.2.1 → tmapslide-0.2.2}/README.md +25 -11
- {tmapslide-0.2.1 → tmapslide-0.2.2}/pyproject.toml +1 -1
- {tmapslide-0.2.1 → tmapslide-0.2.2}/src/tmapslide/__init__.py +1 -1
- {tmapslide-0.2.1 → tmapslide-0.2.2}/src/tmapslide/_slide.py +55 -8
- {tmapslide-0.2.1 → tmapslide-0.2.2}/src/tmapslide/_tmapformat.py +78 -9
- {tmapslide-0.2.1 → tmapslide-0.2.2}/tests/test_synthetic.py +80 -0
- {tmapslide-0.2.1 → tmapslide-0.2.2}/tests/tmap_fixtures.py +26 -9
- {tmapslide-0.2.1 → tmapslide-0.2.2}/.gitignore +0 -0
- {tmapslide-0.2.1 → tmapslide-0.2.2}/LICENSE +0 -0
- {tmapslide-0.2.1 → tmapslide-0.2.2}/examples/read_region.py +0 -0
- {tmapslide-0.2.1 → tmapslide-0.2.2}/src/tmapslide/_cache.py +0 -0
- {tmapslide-0.2.1 → tmapslide-0.2.2}/src/tmapslide/_exceptions.py +0 -0
- {tmapslide-0.2.1 → tmapslide-0.2.2}/tests/test_basic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tmapslide
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Pure Python UNIC TMAP whole-slide image reader with OpenSlide-compatible API
|
|
5
5
|
Project-URL: Homepage, https://github.com/yifanfeng97/tmapslide
|
|
6
6
|
Project-URL: Documentation, https://github.com/yifanfeng97/tmapslide#readme
|
|
@@ -31,11 +31,7 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
|
|
32
32
|
<div align="center">
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<br/>
|
|
37
|
-
|
|
38
|
-
**TmapSlide**
|
|
34
|
+
# TmapSlide
|
|
39
35
|
|
|
40
36
|
**Pure Python reader for UNIC TMAP whole-slide images — no SDK, no native deps.**
|
|
41
37
|
|
|
@@ -54,9 +50,6 @@ Description-Content-Type: text/markdown
|
|
|
54
50
|
<a href="LICENSE">
|
|
55
51
|
<img src="https://img.shields.io/badge/license-MIT-06b6d4?style=for-the-badge&logo=openaccess&logoColor=white&labelColor=1a1a2e" alt="License">
|
|
56
52
|
</a>
|
|
57
|
-
<a href="https://github.com/yifanfeng97/tmapslide/actions/workflows/test.yml">
|
|
58
|
-
<img src="https://img.shields.io/github/actions/yifanfeng97/tmapslide/test.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&labelColor=1a1a2e&label=tests" alt="Tests">
|
|
59
|
-
</a>
|
|
60
53
|
<a href="https://github.com/yifanfeng97/tmapslide/stargazers">
|
|
61
54
|
<img src="https://img.shields.io/github/stars/yifanfeng97/tmapslide?style=for-the-badge&logo=github&labelColor=1a1a2e&color=facc15" alt="GitHub Stars">
|
|
62
55
|
</a>
|
|
@@ -64,6 +57,10 @@ Description-Content-Type: text/markdown
|
|
|
64
57
|
|
|
65
58
|
[📖 English](#-quick-start) · [中文说明](#-中文说明)
|
|
66
59
|
|
|
60
|
+
<br/>
|
|
61
|
+
|
|
62
|
+
<img src="docs/hero.jpg" alt="TmapSlide — UNIC TMAP whole-slide images in pure Python" width="800" style="max-width: 100%;">
|
|
63
|
+
|
|
67
64
|
</div>
|
|
68
65
|
|
|
69
66
|
## ⚡ Quick Start
|
|
@@ -99,6 +96,14 @@ macro = slide.associated_images["macro"]
|
|
|
99
96
|
`associated_images`
|
|
100
97
|
- **Both known TMAP variants** — `TMAP06` (3-level pyramid) and
|
|
101
98
|
`TMAP07` (up to 10 levels)
|
|
99
|
+
- **Multi-file slides** — TMAP06 slides that spill tiles into `.DT1`
|
|
100
|
+
sidecar files are read transparently
|
|
101
|
+
- **Trusted pixel size** — several TMAP06 *and* TMAP07 headers carry an
|
|
102
|
+
impossible `pixel_size` (e.g. 6.88e-05 mm at 40x, implying ~2 µm
|
|
103
|
+
nuclei); tmapslide cross-checks it against the objective power and
|
|
104
|
+
falls back to 10 µm / magnification, exposing the result via
|
|
105
|
+
`openslide.mpp-x` / `openslide.mpp-y` (the raw header value stays in
|
|
106
|
+
`tmap.pixel_size_mm`, the decision in `tmap.mpp_source`)
|
|
102
107
|
- **Fork-safe file handles** — safe with PyTorch `DataLoader` workers
|
|
103
108
|
- **LRU decoded-tile cache** — fast repeated reads
|
|
104
109
|
- **Thread-safe reads** — concurrent `read_region` from worker threads
|
|
@@ -129,11 +134,12 @@ TMAP backend (median of 5 runs, same files, same machine):
|
|
|
129
134
|
| `level_count` | number of pyramid levels |
|
|
130
135
|
| `level_dimensions` | `(w, h)` per level |
|
|
131
136
|
| `level_downsamples` | downsample factor per level |
|
|
132
|
-
| `properties` | read-only metadata mapping (`openslide.vendor=unic`, `tmap.*`) |
|
|
137
|
+
| `properties` | read-only metadata mapping (`openslide.vendor=unic`, `openslide.mpp-x/y`, `tmap.*`) |
|
|
133
138
|
| `associated_images` | lazy mapping, typically `macro` / `label` / `thumbnail` |
|
|
134
139
|
| `read_region(loc, level, size)` | `PIL.Image` (RGBA) of the region |
|
|
135
|
-
| `get_thumbnail(size)` | thumbnail
|
|
140
|
+
| `get_thumbnail(size)` | stored thumbnail when available, else lowest level |
|
|
136
141
|
| `get_best_level_for_downsample(ds)` | best level for a downsample factor |
|
|
142
|
+
| `iter_tiles(level=0)` | yields `(x, y, load)` per stored tile |
|
|
137
143
|
| `close()` / context manager | release resources |
|
|
138
144
|
|
|
139
145
|
### `tmapslide.open_slide(filename)`
|
|
@@ -144,7 +150,7 @@ Alias of `OpenSlide(filename)`.
|
|
|
144
150
|
|
|
145
151
|
| Format | Extension | Vendor | Backend |
|
|
146
152
|
| --- | --- | --- | --- |
|
|
147
|
-
| TMAP 06 | `.TMAP` | UNIC (United Imaging) | Pure Python |
|
|
153
|
+
| TMAP 06 | `.TMAP` (+ optional `.DT1` sidecars) | UNIC (United Imaging) | Pure Python |
|
|
148
154
|
| TMAP 07 | `.TMAP` | UNIC (United Imaging) | Pure Python |
|
|
149
155
|
|
|
150
156
|
## 🧪 Testing
|
|
@@ -172,6 +178,14 @@ encryption.
|
|
|
172
178
|
are JPEG-compressed at that scale.
|
|
173
179
|
- `iter_tiles()` exposes the stored tile grid directly — useful for
|
|
174
180
|
tile-based ML pipelines.
|
|
181
|
+
- **Metadata caveat**: the header `pixel_size` field cannot be trusted.
|
|
182
|
+
Measured on real slides (cell-nucleus diameters, canvas physical size,
|
|
183
|
+
cross-checked with ASlide), both the Henan TMAP06 batch (6.88e-05 mm)
|
|
184
|
+
and the Shanxi TMAP07 batch (1.01e-04 mm) carry corrupt values at 40x;
|
|
185
|
+
the true resolution is 0.25 µm/px. tmapslide keeps the raw value in
|
|
186
|
+
`tmap.pixel_size_mm`, exposes the corrected one via
|
|
187
|
+
`openslide.mpp-x/y`, and records the decision in `tmap.mpp_source`
|
|
188
|
+
(`header` / `derived`).
|
|
175
189
|
|
|
176
190
|
## 📄 License
|
|
177
191
|
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
<br/>
|
|
6
|
-
|
|
7
|
-
**TmapSlide**
|
|
3
|
+
# TmapSlide
|
|
8
4
|
|
|
9
5
|
**Pure Python reader for UNIC TMAP whole-slide images — no SDK, no native deps.**
|
|
10
6
|
|
|
@@ -23,9 +19,6 @@
|
|
|
23
19
|
<a href="LICENSE">
|
|
24
20
|
<img src="https://img.shields.io/badge/license-MIT-06b6d4?style=for-the-badge&logo=openaccess&logoColor=white&labelColor=1a1a2e" alt="License">
|
|
25
21
|
</a>
|
|
26
|
-
<a href="https://github.com/yifanfeng97/tmapslide/actions/workflows/test.yml">
|
|
27
|
-
<img src="https://img.shields.io/github/actions/yifanfeng97/tmapslide/test.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&labelColor=1a1a2e&label=tests" alt="Tests">
|
|
28
|
-
</a>
|
|
29
22
|
<a href="https://github.com/yifanfeng97/tmapslide/stargazers">
|
|
30
23
|
<img src="https://img.shields.io/github/stars/yifanfeng97/tmapslide?style=for-the-badge&logo=github&labelColor=1a1a2e&color=facc15" alt="GitHub Stars">
|
|
31
24
|
</a>
|
|
@@ -33,6 +26,10 @@
|
|
|
33
26
|
|
|
34
27
|
[📖 English](#-quick-start) · [中文说明](#-中文说明)
|
|
35
28
|
|
|
29
|
+
<br/>
|
|
30
|
+
|
|
31
|
+
<img src="docs/hero.jpg" alt="TmapSlide — UNIC TMAP whole-slide images in pure Python" width="800" style="max-width: 100%;">
|
|
32
|
+
|
|
36
33
|
</div>
|
|
37
34
|
|
|
38
35
|
## ⚡ Quick Start
|
|
@@ -68,6 +65,14 @@ macro = slide.associated_images["macro"]
|
|
|
68
65
|
`associated_images`
|
|
69
66
|
- **Both known TMAP variants** — `TMAP06` (3-level pyramid) and
|
|
70
67
|
`TMAP07` (up to 10 levels)
|
|
68
|
+
- **Multi-file slides** — TMAP06 slides that spill tiles into `.DT1`
|
|
69
|
+
sidecar files are read transparently
|
|
70
|
+
- **Trusted pixel size** — several TMAP06 *and* TMAP07 headers carry an
|
|
71
|
+
impossible `pixel_size` (e.g. 6.88e-05 mm at 40x, implying ~2 µm
|
|
72
|
+
nuclei); tmapslide cross-checks it against the objective power and
|
|
73
|
+
falls back to 10 µm / magnification, exposing the result via
|
|
74
|
+
`openslide.mpp-x` / `openslide.mpp-y` (the raw header value stays in
|
|
75
|
+
`tmap.pixel_size_mm`, the decision in `tmap.mpp_source`)
|
|
71
76
|
- **Fork-safe file handles** — safe with PyTorch `DataLoader` workers
|
|
72
77
|
- **LRU decoded-tile cache** — fast repeated reads
|
|
73
78
|
- **Thread-safe reads** — concurrent `read_region` from worker threads
|
|
@@ -98,11 +103,12 @@ TMAP backend (median of 5 runs, same files, same machine):
|
|
|
98
103
|
| `level_count` | number of pyramid levels |
|
|
99
104
|
| `level_dimensions` | `(w, h)` per level |
|
|
100
105
|
| `level_downsamples` | downsample factor per level |
|
|
101
|
-
| `properties` | read-only metadata mapping (`openslide.vendor=unic`, `tmap.*`) |
|
|
106
|
+
| `properties` | read-only metadata mapping (`openslide.vendor=unic`, `openslide.mpp-x/y`, `tmap.*`) |
|
|
102
107
|
| `associated_images` | lazy mapping, typically `macro` / `label` / `thumbnail` |
|
|
103
108
|
| `read_region(loc, level, size)` | `PIL.Image` (RGBA) of the region |
|
|
104
|
-
| `get_thumbnail(size)` | thumbnail
|
|
109
|
+
| `get_thumbnail(size)` | stored thumbnail when available, else lowest level |
|
|
105
110
|
| `get_best_level_for_downsample(ds)` | best level for a downsample factor |
|
|
111
|
+
| `iter_tiles(level=0)` | yields `(x, y, load)` per stored tile |
|
|
106
112
|
| `close()` / context manager | release resources |
|
|
107
113
|
|
|
108
114
|
### `tmapslide.open_slide(filename)`
|
|
@@ -113,7 +119,7 @@ Alias of `OpenSlide(filename)`.
|
|
|
113
119
|
|
|
114
120
|
| Format | Extension | Vendor | Backend |
|
|
115
121
|
| --- | --- | --- | --- |
|
|
116
|
-
| TMAP 06 | `.TMAP` | UNIC (United Imaging) | Pure Python |
|
|
122
|
+
| TMAP 06 | `.TMAP` (+ optional `.DT1` sidecars) | UNIC (United Imaging) | Pure Python |
|
|
117
123
|
| TMAP 07 | `.TMAP` | UNIC (United Imaging) | Pure Python |
|
|
118
124
|
|
|
119
125
|
## 🧪 Testing
|
|
@@ -141,6 +147,14 @@ encryption.
|
|
|
141
147
|
are JPEG-compressed at that scale.
|
|
142
148
|
- `iter_tiles()` exposes the stored tile grid directly — useful for
|
|
143
149
|
tile-based ML pipelines.
|
|
150
|
+
- **Metadata caveat**: the header `pixel_size` field cannot be trusted.
|
|
151
|
+
Measured on real slides (cell-nucleus diameters, canvas physical size,
|
|
152
|
+
cross-checked with ASlide), both the Henan TMAP06 batch (6.88e-05 mm)
|
|
153
|
+
and the Shanxi TMAP07 batch (1.01e-04 mm) carry corrupt values at 40x;
|
|
154
|
+
the true resolution is 0.25 µm/px. tmapslide keeps the raw value in
|
|
155
|
+
`tmap.pixel_size_mm`, exposes the corrected one via
|
|
156
|
+
`openslide.mpp-x/y`, and records the decision in `tmap.mpp_source`
|
|
157
|
+
(`header` / `derived`).
|
|
144
158
|
|
|
145
159
|
## 📄 License
|
|
146
160
|
|
|
@@ -117,6 +117,8 @@ class OpenSlide:
|
|
|
117
117
|
"_tile_lookup",
|
|
118
118
|
"_background_rgba",
|
|
119
119
|
"_io_lock",
|
|
120
|
+
"_data_paths",
|
|
121
|
+
"_data_handles",
|
|
120
122
|
)
|
|
121
123
|
|
|
122
124
|
def __init__(self, filename: str):
|
|
@@ -127,6 +129,9 @@ class OpenSlide:
|
|
|
127
129
|
self._pid = os.getpid()
|
|
128
130
|
# Serialises seek+read on the shared file handle across threads.
|
|
129
131
|
self._io_lock = threading.Lock()
|
|
132
|
+
# Auxiliary .DT1/.DT2 data files (TMAP06 multi-file slides).
|
|
133
|
+
self._data_paths: Dict[int, str] = {}
|
|
134
|
+
self._data_handles: Dict[int, io.BufferedReader] = {}
|
|
130
135
|
|
|
131
136
|
try:
|
|
132
137
|
self._info: TmapFileInfo = parse_tmap_file(filename)
|
|
@@ -151,6 +156,7 @@ class OpenSlide:
|
|
|
151
156
|
}
|
|
152
157
|
props.update(self._info.properties)
|
|
153
158
|
self._properties = _PropertyMap(props)
|
|
159
|
+
self._data_paths = dict(self._info.data_files)
|
|
154
160
|
if "tmap.objective_power" in self._info.properties:
|
|
155
161
|
self._properties._data["openslide.objective-power"] = self._info.properties[
|
|
156
162
|
"tmap.objective_power"
|
|
@@ -217,8 +223,12 @@ class OpenSlide:
|
|
|
217
223
|
tile = self._tile_cache.get(key)
|
|
218
224
|
if tile is not None:
|
|
219
225
|
return tile
|
|
220
|
-
fh = self._ensure_open_handle()
|
|
221
226
|
t = level.tiles[idx]
|
|
227
|
+
fh = self._get_file_handle(t.file_id)
|
|
228
|
+
if fh is None:
|
|
229
|
+
raise OpenSlideError(
|
|
230
|
+
f"Data file for file_id {t.file_id} is missing"
|
|
231
|
+
)
|
|
222
232
|
with self._io_lock:
|
|
223
233
|
fh.seek(t.offset)
|
|
224
234
|
jpeg = fh.read(t.size)
|
|
@@ -226,13 +236,42 @@ class OpenSlide:
|
|
|
226
236
|
self._tile_cache.put(key, tile)
|
|
227
237
|
return tile
|
|
228
238
|
|
|
229
|
-
def
|
|
239
|
+
def _get_file_handle(self, file_id: int):
|
|
240
|
+
"""Return the handle for a data file: 0 = main TMAP, 1+ = .DT sidecars.
|
|
241
|
+
|
|
242
|
+
Sidecar handles open lazily and reopen when the PID changes (same
|
|
243
|
+
fork-safety rule as the main handle). Returns None if the sidecar
|
|
244
|
+
file is missing."""
|
|
245
|
+
self._check_open()
|
|
246
|
+
if file_id == 0:
|
|
247
|
+
return self._ensure_open_handle()
|
|
248
|
+
path = self._data_paths.get(file_id)
|
|
249
|
+
if path is None:
|
|
250
|
+
return None
|
|
251
|
+
handle = self._data_handles.get(file_id)
|
|
252
|
+
if handle is None or os.getpid() != getattr(handle, "_tmap_pid", -1):
|
|
253
|
+
try:
|
|
254
|
+
handle.close()
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
try:
|
|
258
|
+
handle = open(path, "rb")
|
|
259
|
+
except OSError:
|
|
260
|
+
self._data_paths.pop(file_id, None)
|
|
261
|
+
return None
|
|
262
|
+
handle._tmap_pid = os.getpid()
|
|
263
|
+
self._data_handles[file_id] = handle
|
|
264
|
+
return handle
|
|
265
|
+
|
|
266
|
+
def _get_cached_tile(self, offset: int, length: int, file_id: int = 0) -> Image.Image:
|
|
230
267
|
"""Read + decode a raw tile by file range, with caching."""
|
|
231
|
-
key = ("raw", offset, length)
|
|
268
|
+
key = ("raw", file_id, offset, length)
|
|
232
269
|
tile = self._tile_cache.get(key)
|
|
233
270
|
if tile is not None:
|
|
234
271
|
return tile
|
|
235
|
-
fh = self.
|
|
272
|
+
fh = self._get_file_handle(file_id)
|
|
273
|
+
if fh is None:
|
|
274
|
+
raise OpenSlideError(f"Data file for file_id {file_id} is missing")
|
|
236
275
|
with self._io_lock:
|
|
237
276
|
fh.seek(offset)
|
|
238
277
|
data = fh.read(length)
|
|
@@ -260,6 +299,12 @@ class OpenSlide:
|
|
|
260
299
|
except Exception:
|
|
261
300
|
pass
|
|
262
301
|
self._file_handle = None
|
|
302
|
+
for handle in self._data_handles.values():
|
|
303
|
+
try:
|
|
304
|
+
handle.close()
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
self._data_handles.clear()
|
|
263
308
|
|
|
264
309
|
# ------------------------------------------------------------------
|
|
265
310
|
# Properties
|
|
@@ -409,7 +454,6 @@ class OpenSlide:
|
|
|
409
454
|
img_w = int(props.get("tmap.img_width", 2448))
|
|
410
455
|
img_h = int(props.get("tmap.img_height", 2048))
|
|
411
456
|
tile_img_w, tile_img_h = info.__dict__.get("_tile_jpeg_size", (612, 512))
|
|
412
|
-
fh = self._ensure_open_handle()
|
|
413
457
|
|
|
414
458
|
# clamp level to what we can render
|
|
415
459
|
if shrinks:
|
|
@@ -463,11 +507,10 @@ class OpenSlide:
|
|
|
463
507
|
|
|
464
508
|
if level >= 2 and shrinks:
|
|
465
509
|
# ShrinkTiles: pre-rendered tiles for deep zoom-out levels.
|
|
466
|
-
for layer_no, n_x, n_y, off, length in shrinks:
|
|
510
|
+
for layer_no, n_x, n_y, off, length, fid in shrinks:
|
|
467
511
|
if layer_no != level:
|
|
468
512
|
continue
|
|
469
513
|
scale = ratio_step**layer_no
|
|
470
|
-
tile_img = self._get_cached_tile(off, length)
|
|
471
514
|
cover_w = info.__dict__["_tile_jpeg_size"][0] * scale
|
|
472
515
|
cover_h = info.__dict__["_tile_jpeg_size"][1] * scale
|
|
473
516
|
if (
|
|
@@ -477,6 +520,7 @@ class OpenSlide:
|
|
|
477
520
|
or n_y > y0_l0 + h0
|
|
478
521
|
):
|
|
479
522
|
continue
|
|
523
|
+
tile_img = self._get_cached_tile(off, length, fid)
|
|
480
524
|
paste_scaled(tile_img, n_x, n_y, cover_w, cover_h)
|
|
481
525
|
return out
|
|
482
526
|
|
|
@@ -489,6 +533,9 @@ class OpenSlide:
|
|
|
489
533
|
if n_x + img_w < x0_l0 or n_x > x0_l0 + w0 or n_y + img_h < y0_l0 or n_y > y0_l0 + h0:
|
|
490
534
|
continue
|
|
491
535
|
jw, jh = info.__dict__.get("_tile_jpeg_size", (tile_img_w, tile_img_h))
|
|
536
|
+
fid = blk.get("file_id", 0)
|
|
537
|
+
if self._get_file_handle(fid) is None:
|
|
538
|
+
continue # sidecar file missing; degrade to background
|
|
492
539
|
for layer_no, col, row, off, length in blk["tiles"]:
|
|
493
540
|
if layer_no != level:
|
|
494
541
|
continue
|
|
@@ -508,7 +555,7 @@ class OpenSlide:
|
|
|
508
555
|
or tile_y0 >= y0_l0 + h0
|
|
509
556
|
):
|
|
510
557
|
continue
|
|
511
|
-
tile_img = self._get_cached_tile(off, length)
|
|
558
|
+
tile_img = self._get_cached_tile(off, length, fid)
|
|
512
559
|
paste_scaled(tile_img, tile_x0, tile_y0, cover_w, cover_h)
|
|
513
560
|
return out
|
|
514
561
|
|
|
@@ -39,6 +39,7 @@ TMAP06 layout
|
|
|
39
39
|
"""
|
|
40
40
|
|
|
41
41
|
import io
|
|
42
|
+
import os
|
|
42
43
|
import struct
|
|
43
44
|
from dataclasses import dataclass, field
|
|
44
45
|
from typing import Dict, List, Optional, Tuple
|
|
@@ -61,7 +62,11 @@ REC07_HEADER_SKIP = 24
|
|
|
61
62
|
|
|
62
63
|
@dataclass
|
|
63
64
|
class TmapTile:
|
|
64
|
-
"""One pyramid tile pointing at a JPEG blob in
|
|
65
|
+
"""One pyramid tile pointing at a JPEG blob in a data file.
|
|
66
|
+
|
|
67
|
+
``file_id`` selects the file: 0 is the main TMAP, 1+ are .DT sidecar
|
|
68
|
+
files (TMAP06 multi-file slides).
|
|
69
|
+
"""
|
|
65
70
|
|
|
66
71
|
level: int
|
|
67
72
|
offset: int
|
|
@@ -70,6 +75,7 @@ class TmapTile:
|
|
|
70
75
|
y: int
|
|
71
76
|
width: int
|
|
72
77
|
height: int
|
|
78
|
+
file_id: int = 0
|
|
73
79
|
|
|
74
80
|
|
|
75
81
|
@dataclass
|
|
@@ -104,6 +110,44 @@ class TmapFileInfo:
|
|
|
104
110
|
assoc_images: List[TmapAssocImage]
|
|
105
111
|
tile_count: int
|
|
106
112
|
properties: Dict[str, str] = field(default_factory=dict)
|
|
113
|
+
# file_id -> path of auxiliary data files (0 is the main TMAP itself).
|
|
114
|
+
data_files: Dict[int, str] = field(default_factory=dict)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Header pixel_size fields are unreliable in the wild (TMAP06 slides ship
|
|
118
|
+
# with values like 6.88e-05 mm at 40x, implying biologically impossible
|
|
119
|
+
# ~2 µm nuclei), so derived values are used whenever the header value is
|
|
120
|
+
# outside the plausible mpp range for whole-slide imaging. 0.1 µm/px is
|
|
121
|
+
# the practical floor (100x); no light-microscopy WSI scanner goes finer.
|
|
122
|
+
MPP_MIN, MPP_MAX = 0.1, 5.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _mpp_properties(pixel_size_mm: float, scan_scale: float) -> Dict[str, str]:
|
|
126
|
+
"""openslide.mpp-x/y with plausibility checks and a documented fallback.
|
|
127
|
+
|
|
128
|
+
Two known-corrupt cases in real files: (1) TMAP06 headers carrying
|
|
129
|
+
6.88e-05 mm at 40x (biologically impossible ~2 µm nuclei), and
|
|
130
|
+
(2) TMAP07 headers carrying 1.01e-04 mm at 40x (implies a ~99x
|
|
131
|
+
objective that no WSI scanner has, and canvas physical sizes of 6-9 mm
|
|
132
|
+
instead of the standard 21-24 mm). A header value is trusted only if it
|
|
133
|
+
is inside the plausible mpp range AND within 25% of the 10 µm /
|
|
134
|
+
objective-power physics; otherwise 10 µm / objective is used. The raw
|
|
135
|
+
header value is always preserved in ``tmap.pixel_size_mm`` and
|
|
136
|
+
``tmap.mpp_source`` records which value won.
|
|
137
|
+
"""
|
|
138
|
+
mpp = pixel_size_mm * 1000.0
|
|
139
|
+
source = "header"
|
|
140
|
+
expected = 10.0 / scan_scale if scan_scale >= 4 else 0.0
|
|
141
|
+
plausible = MPP_MIN <= mpp <= MPP_MAX
|
|
142
|
+
consistent = expected <= 0 or abs(mpp - expected) / expected <= 0.25
|
|
143
|
+
if not (plausible and consistent):
|
|
144
|
+
mpp = expected if MPP_MIN <= expected <= MPP_MAX else 0.25
|
|
145
|
+
source = "derived"
|
|
146
|
+
return {
|
|
147
|
+
"openslide.mpp-x": f"{mpp:g}",
|
|
148
|
+
"openslide.mpp-y": f"{mpp:g}",
|
|
149
|
+
"tmap.mpp_source": source,
|
|
150
|
+
}
|
|
107
151
|
|
|
108
152
|
|
|
109
153
|
def _read_at(f, offset: int, size: int) -> bytes:
|
|
@@ -348,6 +392,7 @@ def _parse_tmap07(path: str) -> TmapFileInfo:
|
|
|
348
392
|
"tmap.declared_tiles": str(declared_tiles),
|
|
349
393
|
"tmap.layer_infos": ",".join(prop_layer_info),
|
|
350
394
|
"tmap.objective_power": str(scan_scale),
|
|
395
|
+
**_mpp_properties(pixel_size, scan_scale),
|
|
351
396
|
},
|
|
352
397
|
)
|
|
353
398
|
|
|
@@ -409,6 +454,18 @@ def _parse_tmap06(path: str) -> TmapFileInfo:
|
|
|
409
454
|
if width <= 0 or height <= 0:
|
|
410
455
|
raise ValueError("TMAP06: invalid canvas dimensions")
|
|
411
456
|
|
|
457
|
+
# Multi-file slides: file_num counts the main TMAP plus .DT
|
|
458
|
+
# sidecars; tiles with file_id k live in <stem>.DTk.
|
|
459
|
+
data_files: Dict[int, str] = {0: path}
|
|
460
|
+
data_sizes: Dict[int, int] = {0: file_size}
|
|
461
|
+
stem = os.path.splitext(path)[0]
|
|
462
|
+
for fid in range(1, max(1, file_num)):
|
|
463
|
+
for cand in (f"{stem}.DT{fid}", f"{stem}.dt{fid}"):
|
|
464
|
+
if os.path.exists(cand):
|
|
465
|
+
data_files[fid] = cand
|
|
466
|
+
data_sizes[fid] = os.path.getsize(cand)
|
|
467
|
+
break
|
|
468
|
+
|
|
412
469
|
# ExtInfo at 0x3C: assoc images as (type, offset, length) triplets.
|
|
413
470
|
# ext type 1 = combined macro+label image, 2 = thumbnail.
|
|
414
471
|
assoc: List[TmapAssocImage] = []
|
|
@@ -514,18 +571,20 @@ def _parse_tmap06(path: str) -> TmapFileInfo:
|
|
|
514
571
|
blocks = {} # (n_img_col, n_img_row) -> layer info dict
|
|
515
572
|
for k in range(min(image_num, len(li_raw) // 308)):
|
|
516
573
|
base = k * 308
|
|
574
|
+
fid = li_raw[base]
|
|
517
575
|
n_col, n_row = struct.unpack_from("<HH", li_raw, base + 8)
|
|
518
576
|
if n_col == 0xFFFF and n_row == 0xFFFF:
|
|
519
577
|
continue
|
|
520
578
|
n_x, n_y = struct.unpack_from("<ii", li_raw, base + 12)
|
|
579
|
+
limit = data_sizes.get(fid, 0)
|
|
521
580
|
tiles = []
|
|
522
581
|
for t in range(24):
|
|
523
582
|
tb = base + 20 + t * 12
|
|
524
583
|
layer_no, col, row = struct.unpack_from("<BBB", li_raw, tb)
|
|
525
584
|
off, length = struct.unpack_from("<II", li_raw, tb + 4)
|
|
526
|
-
if off > 0 and length > 0 and off + length <=
|
|
585
|
+
if off > 0 and length > 0 and off + length <= limit:
|
|
527
586
|
tiles.append((layer_no, col, row, off, length))
|
|
528
|
-
blocks[(n_col, n_row)] = dict(n_x=n_x, n_y=n_y, tiles=tiles, file_id=
|
|
587
|
+
blocks[(n_col, n_row)] = dict(n_x=n_x, n_y=n_y, tiles=tiles, file_id=fid)
|
|
529
588
|
|
|
530
589
|
# ShrinkTileInfo right after the LayerInfo table.
|
|
531
590
|
st_base = li_base + image_num * 308
|
|
@@ -533,10 +592,10 @@ def _parse_tmap06(path: str) -> TmapFileInfo:
|
|
|
533
592
|
st_raw = _read_at(f, st_base, min(shrink_tile_num, 100000) * 20)
|
|
534
593
|
for k in range(min(shrink_tile_num, len(st_raw) // 20)):
|
|
535
594
|
base = k * 20
|
|
536
|
-
|
|
595
|
+
fid, layer_no = struct.unpack_from("<BB", st_raw, base)
|
|
537
596
|
n_x, n_y, off, length = struct.unpack_from("<iiII", st_raw, base + 4)
|
|
538
|
-
if off > 0 and length > 0 and off + length <=
|
|
539
|
-
shrinks.append((layer_no, n_x, n_y, off, length))
|
|
597
|
+
if off > 0 and length > 0 and off + length <= data_sizes.get(fid, 0):
|
|
598
|
+
shrinks.append((layer_no, n_x, n_y, off, length, fid))
|
|
540
599
|
|
|
541
600
|
# Pyramid levels: ratio_step division from the level-0 canvas.
|
|
542
601
|
levels_meta = []
|
|
@@ -548,9 +607,14 @@ def _parse_tmap06(path: str) -> TmapFileInfo:
|
|
|
548
607
|
h = (h + ratio_step - 1) // ratio_step
|
|
549
608
|
scale = scale // ratio_step
|
|
550
609
|
|
|
551
|
-
def _tile_img(off: int, length: int) -> Optional[Image.Image]:
|
|
552
|
-
|
|
610
|
+
def _tile_img(fid: int, off: int, length: int) -> Optional[Image.Image]:
|
|
611
|
+
p = data_files.get(fid)
|
|
612
|
+
if p is None:
|
|
613
|
+
return None
|
|
553
614
|
try:
|
|
615
|
+
with open(p, "rb") as df:
|
|
616
|
+
df.seek(off)
|
|
617
|
+
data = df.read(length)
|
|
554
618
|
return Image.open(io.BytesIO(data))
|
|
555
619
|
except Exception:
|
|
556
620
|
return None
|
|
@@ -560,7 +624,7 @@ def _parse_tmap06(path: str) -> TmapFileInfo:
|
|
|
560
624
|
for info in blocks.values():
|
|
561
625
|
for layer_no, col, row, off, length in info["tiles"]:
|
|
562
626
|
if layer_no == 0:
|
|
563
|
-
im = _tile_img(off, length)
|
|
627
|
+
im = _tile_img(info["file_id"], off, length)
|
|
564
628
|
if im is not None:
|
|
565
629
|
tw, th = im.size
|
|
566
630
|
break
|
|
@@ -585,6 +649,7 @@ def _parse_tmap06(path: str) -> TmapFileInfo:
|
|
|
585
649
|
|
|
586
650
|
for (n_col, n_row), blk in blocks.items():
|
|
587
651
|
n_x, n_y = blk["n_x"], blk["n_y"]
|
|
652
|
+
fid = blk["file_id"]
|
|
588
653
|
for layer_no, col, row, off, length in blk["tiles"]:
|
|
589
654
|
if layer_no == 0:
|
|
590
655
|
levels[0].tiles.append(
|
|
@@ -596,6 +661,7 @@ def _parse_tmap06(path: str) -> TmapFileInfo:
|
|
|
596
661
|
y=n_y + row * th,
|
|
597
662
|
width=tw,
|
|
598
663
|
height=th,
|
|
664
|
+
file_id=fid,
|
|
599
665
|
)
|
|
600
666
|
)
|
|
601
667
|
elif layer_no == 1 and len(levels) > 1:
|
|
@@ -608,6 +674,7 @@ def _parse_tmap06(path: str) -> TmapFileInfo:
|
|
|
608
674
|
y=n_y // ratio_step,
|
|
609
675
|
width=tw,
|
|
610
676
|
height=th,
|
|
677
|
+
file_id=fid,
|
|
611
678
|
)
|
|
612
679
|
)
|
|
613
680
|
|
|
@@ -631,7 +698,9 @@ def _parse_tmap06(path: str) -> TmapFileInfo:
|
|
|
631
698
|
"tmap.blocks": str(len(blocks)),
|
|
632
699
|
"tmap.shrink_tiles": str(len(shrinks)),
|
|
633
700
|
"tmap.bkg_color": str(bkg_color),
|
|
701
|
+
**_mpp_properties(pixel_size, scan_scale),
|
|
634
702
|
},
|
|
703
|
+
data_files=data_files,
|
|
635
704
|
# extra fields for read_region
|
|
636
705
|
)
|
|
637
706
|
info.__dict__["_blocks"] = blocks
|
|
@@ -122,3 +122,83 @@ def test_synthetic_matches_reference_reading(tmp_path):
|
|
|
122
122
|
s = OpenSlide(str(p))
|
|
123
123
|
assert s.level_count == 2
|
|
124
124
|
s.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_synthetic_tmap06_multifile(tmp_path):
|
|
128
|
+
"""TMAP06 file_num=2: block 1's tiles live in a .DT1 sidecar file and
|
|
129
|
+
must be routed to it for every read path (region, iter_tiles)."""
|
|
130
|
+
main = tmp_path / "s.TMAP"
|
|
131
|
+
side = tmp_path / "s.DT1"
|
|
132
|
+
meta = make_tmap06(str(main), sidecar_path=str(side))
|
|
133
|
+
assert side.exists()
|
|
134
|
+
assert sum(1 for t in meta["tiles"] if t["file_id"] == 1) == 9
|
|
135
|
+
s = OpenSlide(str(main))
|
|
136
|
+
|
|
137
|
+
# layer-0 sidecar tile colour through read_region
|
|
138
|
+
want = (
|
|
139
|
+
(120 + 1 * 20 + 2 * 25) % 256,
|
|
140
|
+
(60 + 1 * 30 + 0 * 45) % 256,
|
|
141
|
+
(150 + 2 * 8) % 256,
|
|
142
|
+
)
|
|
143
|
+
assert close(center(s.read_region((2 * 612 + 10, 1024 + 10), 0, (300, 200))), want)
|
|
144
|
+
|
|
145
|
+
# layer-1 whole-block sidecar tile renders at 1/4 scale
|
|
146
|
+
assert close(center(s.read_region((624, 1036), 1, (300, 250))), (37, 200, 90))
|
|
147
|
+
|
|
148
|
+
# iter_tiles load() must decode all 16 layer-0 tiles (8 of them sidecar)
|
|
149
|
+
loads = [load for _, _, load in s.iter_tiles()]
|
|
150
|
+
assert all(load().size == (612, 512) for load in loads)
|
|
151
|
+
|
|
152
|
+
# level-1 iter_tiles too (one whole-block tile per block)
|
|
153
|
+
for _, _, load in s.iter_tiles(level=1):
|
|
154
|
+
assert load().size == (612, 512)
|
|
155
|
+
|
|
156
|
+
# missing sidecar file degrades to background instead of crashing
|
|
157
|
+
s.close()
|
|
158
|
+
side.unlink()
|
|
159
|
+
s2 = OpenSlide(str(main))
|
|
160
|
+
edge = s2.read_region((2 * 612 + 10, 1024 + 10), 0, (8, 8)).getpixel((4, 4))
|
|
161
|
+
assert edge[3] == 255 # opaque background colour, not an exception
|
|
162
|
+
s2.close()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_tmap06_mpp_fallback(tmp_path):
|
|
166
|
+
"""Corrupt vendor pixel_size (real TMAP06 bug: 6.88e-05 mm at 40x, which
|
|
167
|
+
implies impossible 2 µm nuclei) must not leak into openslide.mpp-x/y —
|
|
168
|
+
fall back to 10 µm / objective power; the raw value stays in
|
|
169
|
+
tmap.pixel_size_mm."""
|
|
170
|
+
make_tmap06(str(tmp_path / "s.TMAP"), pixel_size_mm=6.88e-05)
|
|
171
|
+
s = OpenSlide(str(tmp_path / "s.TMAP"))
|
|
172
|
+
assert float(s.properties["tmap.pixel_size_mm"]) == pytest.approx(6.88e-05)
|
|
173
|
+
assert float(s.properties["openslide.mpp-x"]) == pytest.approx(0.25)
|
|
174
|
+
assert float(s.properties["openslide.mpp-y"]) == pytest.approx(0.25)
|
|
175
|
+
assert s.properties["tmap.mpp_source"] == "derived"
|
|
176
|
+
s.close()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_tmap07_mpp_inconsistent_header(tmp_path):
|
|
180
|
+
"""Real Shanxi TMAP07 headers carry 1.01e-04 mm at 40x — biologically
|
|
181
|
+
impossible (~99x objective, 6-9 mm canvas). The header value must lose
|
|
182
|
+
to the 10 µm / 40x = 0.25 physics even though it sits inside the
|
|
183
|
+
absolute plausibility range."""
|
|
184
|
+
make_tmap07(str(tmp_path / "a.TMAP"), pixel_size_mm=1.0117647e-4)
|
|
185
|
+
s = OpenSlide(str(tmp_path / "a.TMAP"))
|
|
186
|
+
assert float(s.properties["tmap.pixel_size_mm"]) == pytest.approx(1.0117647e-4)
|
|
187
|
+
assert float(s.properties["openslide.mpp-x"]) == pytest.approx(0.25)
|
|
188
|
+
assert s.properties["tmap.mpp_source"] == "derived"
|
|
189
|
+
s.close()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_mpp_from_header(tmp_path):
|
|
193
|
+
"""A plausible header pixel size is used as-is."""
|
|
194
|
+
make_tmap07(str(tmp_path / "a.TMAP"))
|
|
195
|
+
s = OpenSlide(str(tmp_path / "a.TMAP"))
|
|
196
|
+
assert float(s.properties["openslide.mpp-x"]) == pytest.approx(0.25)
|
|
197
|
+
assert s.properties["tmap.mpp_source"] == "header"
|
|
198
|
+
s.close()
|
|
199
|
+
|
|
200
|
+
make_tmap06(str(tmp_path / "b.TMAP"), pixel_size_mm=2.5e-4)
|
|
201
|
+
s = OpenSlide(str(tmp_path / "b.TMAP"))
|
|
202
|
+
assert float(s.properties["openslide.mpp-x"]) == pytest.approx(0.25)
|
|
203
|
+
assert s.properties["tmap.mpp_source"] == "header"
|
|
204
|
+
s.close()
|
|
@@ -19,7 +19,7 @@ def _jpeg(color, size):
|
|
|
19
19
|
return buf.getvalue()
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def make_tmap07(path, background=250):
|
|
22
|
+
def make_tmap07(path, background=250, pixel_size_mm=2.5e-4):
|
|
23
23
|
"""Tiny TMAP07: level 0 = 2x2 grid of 256 px tiles, level 1 = one tile."""
|
|
24
24
|
W = H = 512
|
|
25
25
|
cols = rows = 2
|
|
@@ -53,7 +53,7 @@ def make_tmap07(path, background=250):
|
|
|
53
53
|
struct.pack_into("<BBB", head, 8, 0, 80, 1) # fmt, quality, focus
|
|
54
54
|
head[11] = 40 # scan scale
|
|
55
55
|
head[12] = background
|
|
56
|
-
struct.pack_into("<f", head, 0x10,
|
|
56
|
+
struct.pack_into("<f", head, 0x10, pixel_size_mm) # pixel size (mm)
|
|
57
57
|
struct.pack_into("<I", head, 0x14, 2) # image num
|
|
58
58
|
struct.pack_into("<I", head, 0x18, 2) # layer num
|
|
59
59
|
struct.pack_into("<I", head, 0x1C, 5) # tile num
|
|
@@ -83,14 +83,20 @@ def make_tmap07(path, background=250):
|
|
|
83
83
|
return dict(dims=(W, H), background=background)
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
def make_tmap06(path, background=250):
|
|
86
|
+
def make_tmap06(path, background=250, pixel_size_mm=2.5e-4, sidecar_path=None):
|
|
87
87
|
"""Tiny TMAP06: a 1x2 grid of blocks (each 2448x1024 = 4x2 tiles of
|
|
88
88
|
612x512), one layer-1 whole-block tile per block, 3 pyramid levels
|
|
89
|
-
(ratio step 4).
|
|
89
|
+
(ratio step 4).
|
|
90
|
+
|
|
91
|
+
With ``sidecar_path`` set, the header declares file_num=2 and block 1's
|
|
92
|
+
JPEG blobs are written to a separate .DT1-style file (512-byte header
|
|
93
|
+
then packed tiles); its LayerInfo offsets address that sidecar.
|
|
94
|
+
"""
|
|
90
95
|
TW, TH = 612, 512
|
|
91
96
|
IMG_W, IMG_H = 4 * TW, 2 * TH # 2448 x 1024 per block
|
|
92
97
|
N_BLOCKS = 2
|
|
93
98
|
CANVAS_W, CANVAS_H = IMG_W, N_BLOCKS * IMG_H # 2448 x 2048
|
|
99
|
+
use_sidecar = sidecar_path is not None
|
|
94
100
|
|
|
95
101
|
li_base = 192
|
|
96
102
|
li_size = 308
|
|
@@ -98,8 +104,8 @@ def make_tmap06(path, background=250):
|
|
|
98
104
|
|
|
99
105
|
out = bytearray(60)
|
|
100
106
|
out[0:6] = b"TMAP06"
|
|
101
|
-
out[6:16] = bytes([1, 0, 0, 3, 24, 0, 4, 10, 0, background])
|
|
102
|
-
struct.pack_into("<f", out, 0x10,
|
|
107
|
+
out[6:16] = bytes([1, 0, 2 if use_sidecar else 0, 3, 24, 0, 4, 10, 0, background])
|
|
108
|
+
struct.pack_into("<f", out, 0x10, pixel_size_mm) # pixel size (mm)
|
|
103
109
|
struct.pack_into("<I", out, 0x14, N_BLOCKS) # image num (LayerInfo count)
|
|
104
110
|
struct.pack_into(
|
|
105
111
|
"<HHHHHHHH", out, 0x18,
|
|
@@ -116,10 +122,14 @@ def make_tmap06(path, background=250):
|
|
|
116
122
|
|
|
117
123
|
layerinfos = bytearray()
|
|
118
124
|
tiles_blob = bytearray()
|
|
125
|
+
side_blob = bytearray()
|
|
119
126
|
tile_colors = []
|
|
120
127
|
for b in range(N_BLOCKS):
|
|
128
|
+
fid = 1 if (use_sidecar and b == 1) else 0
|
|
129
|
+
target = side_blob if fid else tiles_blob
|
|
130
|
+
base_off = 512 if fid else data_off # sidecar tiles follow a 512 B header
|
|
121
131
|
li = bytearray(li_size)
|
|
122
|
-
li[0] =
|
|
132
|
+
li[0] = fid # file id
|
|
123
133
|
li[1] = 0 # layer
|
|
124
134
|
struct.pack_into("<HH", li, 8, 0, b) # n_img_col, n_img_row
|
|
125
135
|
n_x, n_y = 0, b * IMG_H
|
|
@@ -136,11 +146,11 @@ def make_tmap06(path, background=250):
|
|
|
136
146
|
l1_color = (b * 37 % 256, 200, 90)
|
|
137
147
|
entries.append((1, 0, 0, _jpeg(l1_color, (TW, TH)), l1_color))
|
|
138
148
|
for t, (layer, col, row, j, color) in enumerate(entries[:24]):
|
|
139
|
-
off =
|
|
149
|
+
off = base_off + len(target)
|
|
140
150
|
li[20 + t * 12 : 20 + t * 12 + 12] = struct.pack(
|
|
141
151
|
"<BBBB II", layer, col, row, 0, off, len(j)
|
|
142
152
|
)
|
|
143
|
-
|
|
153
|
+
target += j
|
|
144
154
|
tile_colors.append(
|
|
145
155
|
dict(
|
|
146
156
|
layer=layer,
|
|
@@ -149,6 +159,7 @@ def make_tmap06(path, background=250):
|
|
|
149
159
|
w=TW,
|
|
150
160
|
h=TH,
|
|
151
161
|
color=color,
|
|
162
|
+
file_id=fid,
|
|
152
163
|
)
|
|
153
164
|
)
|
|
154
165
|
layerinfos += li
|
|
@@ -159,6 +170,12 @@ def make_tmap06(path, background=250):
|
|
|
159
170
|
|
|
160
171
|
with open(path, "wb") as f:
|
|
161
172
|
f.write(bytes(out))
|
|
173
|
+
if use_sidecar:
|
|
174
|
+
side = bytearray(512)
|
|
175
|
+
side[0:4] = b"DT01"
|
|
176
|
+
side += side_blob
|
|
177
|
+
with open(sidecar_path, "wb") as f:
|
|
178
|
+
f.write(bytes(side))
|
|
162
179
|
return dict(
|
|
163
180
|
dims=(CANVAS_W, CANVAS_H),
|
|
164
181
|
background=background,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|