tmapslide 0.2.0__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.2/PKG-INFO +202 -0
- tmapslide-0.2.2/README.md +171 -0
- {tmapslide-0.2.0 → tmapslide-0.2.2}/pyproject.toml +1 -1
- {tmapslide-0.2.0 → tmapslide-0.2.2}/src/tmapslide/__init__.py +1 -1
- {tmapslide-0.2.0 → tmapslide-0.2.2}/src/tmapslide/_slide.py +83 -13
- {tmapslide-0.2.0 → tmapslide-0.2.2}/src/tmapslide/_tmapformat.py +78 -9
- {tmapslide-0.2.0 → tmapslide-0.2.2}/tests/test_synthetic.py +80 -0
- {tmapslide-0.2.0 → tmapslide-0.2.2}/tests/tmap_fixtures.py +26 -9
- tmapslide-0.2.0/PKG-INFO +0 -142
- tmapslide-0.2.0/README.md +0 -111
- {tmapslide-0.2.0 → tmapslide-0.2.2}/.gitignore +0 -0
- {tmapslide-0.2.0 → tmapslide-0.2.2}/LICENSE +0 -0
- {tmapslide-0.2.0 → tmapslide-0.2.2}/examples/read_region.py +0 -0
- {tmapslide-0.2.0 → tmapslide-0.2.2}/src/tmapslide/_cache.py +0 -0
- {tmapslide-0.2.0 → tmapslide-0.2.2}/src/tmapslide/_exceptions.py +0 -0
- {tmapslide-0.2.0 → tmapslide-0.2.2}/tests/test_basic.py +0 -0
tmapslide-0.2.2/PKG-INFO
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tmapslide
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Pure Python UNIC TMAP whole-slide image reader with OpenSlide-compatible API
|
|
5
|
+
Project-URL: Homepage, https://github.com/yifanfeng97/tmapslide
|
|
6
|
+
Project-URL: Documentation, https://github.com/yifanfeng97/tmapslide#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/yifanfeng97/tmapslide
|
|
8
|
+
Project-URL: Issues, https://github.com/yifanfeng97/tmapslide/issues
|
|
9
|
+
Author-email: Yifan Feng <evanfeng97@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: digital-pathology,openslide,pathology,tmap,tmapslide,unic,whole-slide-image,wsi
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: pillow>=9.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
<div align="center">
|
|
33
|
+
|
|
34
|
+
# TmapSlide
|
|
35
|
+
|
|
36
|
+
**Pure Python reader for UNIC TMAP whole-slide images — no SDK, no native deps.**
|
|
37
|
+
|
|
38
|
+
*以纯 Python 读取联影 TMAP 全切片图像,开箱即用*
|
|
39
|
+
|
|
40
|
+
<p align="center">
|
|
41
|
+
<a href="https://pypi.org/project/tmapslide/">
|
|
42
|
+
<img src="https://img.shields.io/pypi/v/tmapslide?style=for-the-badge&logo=pypi&logoColor=white&labelColor=1a1a2e&color=3776ab" alt="PyPI Version">
|
|
43
|
+
</a>
|
|
44
|
+
<a href="https://pypi.org/project/tmapslide/">
|
|
45
|
+
<img src="https://img.shields.io/pypi/dm/tmapslide?style=for-the-badge&logo=pypi&logoColor=white&labelColor=1a1a2e&color=3776ab" alt="PyPI Downloads">
|
|
46
|
+
</a>
|
|
47
|
+
<a href="https://python.org">
|
|
48
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-3776ab?style=for-the-badge&logo=python&logoColor=white&labelColor=1a1a2e" alt="Python Version">
|
|
49
|
+
</a>
|
|
50
|
+
<a href="LICENSE">
|
|
51
|
+
<img src="https://img.shields.io/badge/license-MIT-06b6d4?style=for-the-badge&logo=openaccess&logoColor=white&labelColor=1a1a2e" alt="License">
|
|
52
|
+
</a>
|
|
53
|
+
<a href="https://github.com/yifanfeng97/tmapslide/stargazers">
|
|
54
|
+
<img src="https://img.shields.io/github/stars/yifanfeng97/tmapslide?style=for-the-badge&logo=github&labelColor=1a1a2e&color=facc15" alt="GitHub Stars">
|
|
55
|
+
</a>
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
[📖 English](#-quick-start) · [中文说明](#-中文说明)
|
|
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
|
+
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
## ⚡ Quick Start
|
|
67
|
+
|
|
68
|
+
**1. Install:**
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install tmapslide
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**2. Read a slide:**
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import tmapslide
|
|
78
|
+
|
|
79
|
+
slide = tmapslide.OpenSlide("sample.TMAP")
|
|
80
|
+
|
|
81
|
+
print(slide.dimensions) # (71424, 72704)
|
|
82
|
+
print(slide.level_count) # 10
|
|
83
|
+
print(slide.level_downsamples) # (1.0, 2.0, 4.0, ...)
|
|
84
|
+
|
|
85
|
+
region = slide.read_region((512, 512), 0, (1024, 1024)) # RGBA PIL image
|
|
86
|
+
thumb = slide.get_thumbnail((512, 512))
|
|
87
|
+
macro = slide.associated_images["macro"]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## ✨ Features
|
|
91
|
+
|
|
92
|
+
- **Pure Python** — no vendor SDK, no native dependencies; only Pillow
|
|
93
|
+
- **OpenSlide-compatible API** — drop-in for code written against
|
|
94
|
+
`openslide` / `kfbslide`: `read_region`, `get_thumbnail`, `dimensions`,
|
|
95
|
+
`level_count`, `level_dimensions`, `level_downsamples`, `properties`,
|
|
96
|
+
`associated_images`
|
|
97
|
+
- **Both known TMAP variants** — `TMAP06` (3-level pyramid) and
|
|
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`)
|
|
107
|
+
- **Fork-safe file handles** — safe with PyTorch `DataLoader` workers
|
|
108
|
+
- **LRU decoded-tile cache** — fast repeated reads
|
|
109
|
+
- **Thread-safe reads** — concurrent `read_region` from worker threads
|
|
110
|
+
|
|
111
|
+
## 🏎️ Performance
|
|
112
|
+
|
|
113
|
+
Benchmark vs [ASlide](https://github.com/MrPeterJin/ASlide)'s pure-Python
|
|
114
|
+
TMAP backend (median of 5 runs, same files, same machine):
|
|
115
|
+
|
|
116
|
+
| Scenario | TMAP07 | TMAP06 |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| Open slide | **281 ms** vs 43 ms ⚠️ | **101 ms** vs 224 ms (2.2×) |
|
|
119
|
+
| Cold 1024² region @L0 | **4.1 ms** vs 15.7 ms (3.8×) | **2.6 ms** vs 14.8 ms (5.6×) |
|
|
120
|
+
| Random 512² region @L0 | **1.1 ms** vs 5.7 ms (5.3×) | **0.9 ms** vs 6.3 ms (6.7×) |
|
|
121
|
+
| Warm 512² region ×50 | **42 ms** vs 217 ms (5.1×) | **54 ms** vs 408 ms (7.6×) |
|
|
122
|
+
|
|
123
|
+
> ASlide's TMAP backend re-decodes every tile on every call; tmapslide adds
|
|
124
|
+
> an LRU decoded-tile cache and per-tile culling, so warm reads and random
|
|
125
|
+
> access are several times faster.
|
|
126
|
+
|
|
127
|
+
## 📖 API
|
|
128
|
+
|
|
129
|
+
### `tmapslide.OpenSlide(filename)`
|
|
130
|
+
|
|
131
|
+
| Member | Description |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| `dimensions` | `(width, height)` at level 0 |
|
|
134
|
+
| `level_count` | number of pyramid levels |
|
|
135
|
+
| `level_dimensions` | `(w, h)` per level |
|
|
136
|
+
| `level_downsamples` | downsample factor per level |
|
|
137
|
+
| `properties` | read-only metadata mapping (`openslide.vendor=unic`, `openslide.mpp-x/y`, `tmap.*`) |
|
|
138
|
+
| `associated_images` | lazy mapping, typically `macro` / `label` / `thumbnail` |
|
|
139
|
+
| `read_region(loc, level, size)` | `PIL.Image` (RGBA) of the region |
|
|
140
|
+
| `get_thumbnail(size)` | stored thumbnail when available, else lowest level |
|
|
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 |
|
|
143
|
+
| `close()` / context manager | release resources |
|
|
144
|
+
|
|
145
|
+
### `tmapslide.open_slide(filename)`
|
|
146
|
+
|
|
147
|
+
Alias of `OpenSlide(filename)`.
|
|
148
|
+
|
|
149
|
+
## 📦 Supported Formats
|
|
150
|
+
|
|
151
|
+
| Format | Extension | Vendor | Backend |
|
|
152
|
+
| --- | --- | --- | --- |
|
|
153
|
+
| TMAP 06 | `.TMAP` (+ optional `.DT1` sidecars) | UNIC (United Imaging) | Pure Python |
|
|
154
|
+
| TMAP 07 | `.TMAP` | UNIC (United Imaging) | Pure Python |
|
|
155
|
+
|
|
156
|
+
## 🧪 Testing
|
|
157
|
+
|
|
158
|
+
Tests run against real TMAP samples when the `scce_external_center` data
|
|
159
|
+
directory (or `TAPSLIDE_TEST_DATA`) is present next to the repo; synthetic
|
|
160
|
+
fixtures keep the core parser covered everywhere else.
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
pip install -e .[dev]
|
|
164
|
+
pytest
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## 📄 Format Notes (reverse-engineered)
|
|
168
|
+
|
|
169
|
+
TMAP is an undocumented proprietary format. This reader is built from
|
|
170
|
+
binary analysis of real scanner output, cross-validated against
|
|
171
|
+
[ASlide](https://github.com/MrPeterJin/ASlide). Both variants store plain
|
|
172
|
+
JPEG tiles with a small binary header and index tables; there is no
|
|
173
|
+
encryption.
|
|
174
|
+
|
|
175
|
+
- TMAP06 stores 3 pyramid levels (40x / 10x / 2.5x); TMAP07 stores up to
|
|
176
|
+
10 levels (40x down to 0.078x, halving each level).
|
|
177
|
+
- TMAP06 level-2 previews come from pre-rendered `ShrinkTile` entries and
|
|
178
|
+
are JPEG-compressed at that scale.
|
|
179
|
+
- `iter_tiles()` exposes the stored tile grid directly — useful for
|
|
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`).
|
|
189
|
+
|
|
190
|
+
## 📄 License
|
|
191
|
+
|
|
192
|
+
[MIT](LICENSE)
|
|
193
|
+
|
|
194
|
+
## 🙏 Acknowledgments
|
|
195
|
+
|
|
196
|
+
- [kfbslide](https://github.com/yifanfeng97/kfbslide) — the KFB reader this
|
|
197
|
+
project is modelled after
|
|
198
|
+
- [OpenSlide](https://openslide.org/) — the API this library mimics
|
|
199
|
+
- [ASlide](https://github.com/MrPeterJin/ASlide) — its independent
|
|
200
|
+
reverse-engineering of the TMAP06 layer/block structures (from decompiled
|
|
201
|
+
vendor SDK) was used to cross-validate this implementation. tmapslide is
|
|
202
|
+
an independent MIT-licensed implementation and ships no ASlide code
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# TmapSlide
|
|
4
|
+
|
|
5
|
+
**Pure Python reader for UNIC TMAP whole-slide images — no SDK, no native deps.**
|
|
6
|
+
|
|
7
|
+
*以纯 Python 读取联影 TMAP 全切片图像,开箱即用*
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://pypi.org/project/tmapslide/">
|
|
11
|
+
<img src="https://img.shields.io/pypi/v/tmapslide?style=for-the-badge&logo=pypi&logoColor=white&labelColor=1a1a2e&color=3776ab" alt="PyPI Version">
|
|
12
|
+
</a>
|
|
13
|
+
<a href="https://pypi.org/project/tmapslide/">
|
|
14
|
+
<img src="https://img.shields.io/pypi/dm/tmapslide?style=for-the-badge&logo=pypi&logoColor=white&labelColor=1a1a2e&color=3776ab" alt="PyPI Downloads">
|
|
15
|
+
</a>
|
|
16
|
+
<a href="https://python.org">
|
|
17
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-3776ab?style=for-the-badge&logo=python&logoColor=white&labelColor=1a1a2e" alt="Python Version">
|
|
18
|
+
</a>
|
|
19
|
+
<a href="LICENSE">
|
|
20
|
+
<img src="https://img.shields.io/badge/license-MIT-06b6d4?style=for-the-badge&logo=openaccess&logoColor=white&labelColor=1a1a2e" alt="License">
|
|
21
|
+
</a>
|
|
22
|
+
<a href="https://github.com/yifanfeng97/tmapslide/stargazers">
|
|
23
|
+
<img src="https://img.shields.io/github/stars/yifanfeng97/tmapslide?style=for-the-badge&logo=github&labelColor=1a1a2e&color=facc15" alt="GitHub Stars">
|
|
24
|
+
</a>
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
[📖 English](#-quick-start) · [中文说明](#-中文说明)
|
|
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
|
+
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
## ⚡ Quick Start
|
|
36
|
+
|
|
37
|
+
**1. Install:**
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install tmapslide
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**2. Read a slide:**
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import tmapslide
|
|
47
|
+
|
|
48
|
+
slide = tmapslide.OpenSlide("sample.TMAP")
|
|
49
|
+
|
|
50
|
+
print(slide.dimensions) # (71424, 72704)
|
|
51
|
+
print(slide.level_count) # 10
|
|
52
|
+
print(slide.level_downsamples) # (1.0, 2.0, 4.0, ...)
|
|
53
|
+
|
|
54
|
+
region = slide.read_region((512, 512), 0, (1024, 1024)) # RGBA PIL image
|
|
55
|
+
thumb = slide.get_thumbnail((512, 512))
|
|
56
|
+
macro = slide.associated_images["macro"]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## ✨ Features
|
|
60
|
+
|
|
61
|
+
- **Pure Python** — no vendor SDK, no native dependencies; only Pillow
|
|
62
|
+
- **OpenSlide-compatible API** — drop-in for code written against
|
|
63
|
+
`openslide` / `kfbslide`: `read_region`, `get_thumbnail`, `dimensions`,
|
|
64
|
+
`level_count`, `level_dimensions`, `level_downsamples`, `properties`,
|
|
65
|
+
`associated_images`
|
|
66
|
+
- **Both known TMAP variants** — `TMAP06` (3-level pyramid) and
|
|
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`)
|
|
76
|
+
- **Fork-safe file handles** — safe with PyTorch `DataLoader` workers
|
|
77
|
+
- **LRU decoded-tile cache** — fast repeated reads
|
|
78
|
+
- **Thread-safe reads** — concurrent `read_region` from worker threads
|
|
79
|
+
|
|
80
|
+
## 🏎️ Performance
|
|
81
|
+
|
|
82
|
+
Benchmark vs [ASlide](https://github.com/MrPeterJin/ASlide)'s pure-Python
|
|
83
|
+
TMAP backend (median of 5 runs, same files, same machine):
|
|
84
|
+
|
|
85
|
+
| Scenario | TMAP07 | TMAP06 |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| Open slide | **281 ms** vs 43 ms ⚠️ | **101 ms** vs 224 ms (2.2×) |
|
|
88
|
+
| Cold 1024² region @L0 | **4.1 ms** vs 15.7 ms (3.8×) | **2.6 ms** vs 14.8 ms (5.6×) |
|
|
89
|
+
| Random 512² region @L0 | **1.1 ms** vs 5.7 ms (5.3×) | **0.9 ms** vs 6.3 ms (6.7×) |
|
|
90
|
+
| Warm 512² region ×50 | **42 ms** vs 217 ms (5.1×) | **54 ms** vs 408 ms (7.6×) |
|
|
91
|
+
|
|
92
|
+
> ASlide's TMAP backend re-decodes every tile on every call; tmapslide adds
|
|
93
|
+
> an LRU decoded-tile cache and per-tile culling, so warm reads and random
|
|
94
|
+
> access are several times faster.
|
|
95
|
+
|
|
96
|
+
## 📖 API
|
|
97
|
+
|
|
98
|
+
### `tmapslide.OpenSlide(filename)`
|
|
99
|
+
|
|
100
|
+
| Member | Description |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `dimensions` | `(width, height)` at level 0 |
|
|
103
|
+
| `level_count` | number of pyramid levels |
|
|
104
|
+
| `level_dimensions` | `(w, h)` per level |
|
|
105
|
+
| `level_downsamples` | downsample factor per level |
|
|
106
|
+
| `properties` | read-only metadata mapping (`openslide.vendor=unic`, `openslide.mpp-x/y`, `tmap.*`) |
|
|
107
|
+
| `associated_images` | lazy mapping, typically `macro` / `label` / `thumbnail` |
|
|
108
|
+
| `read_region(loc, level, size)` | `PIL.Image` (RGBA) of the region |
|
|
109
|
+
| `get_thumbnail(size)` | stored thumbnail when available, else lowest level |
|
|
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 |
|
|
112
|
+
| `close()` / context manager | release resources |
|
|
113
|
+
|
|
114
|
+
### `tmapslide.open_slide(filename)`
|
|
115
|
+
|
|
116
|
+
Alias of `OpenSlide(filename)`.
|
|
117
|
+
|
|
118
|
+
## 📦 Supported Formats
|
|
119
|
+
|
|
120
|
+
| Format | Extension | Vendor | Backend |
|
|
121
|
+
| --- | --- | --- | --- |
|
|
122
|
+
| TMAP 06 | `.TMAP` (+ optional `.DT1` sidecars) | UNIC (United Imaging) | Pure Python |
|
|
123
|
+
| TMAP 07 | `.TMAP` | UNIC (United Imaging) | Pure Python |
|
|
124
|
+
|
|
125
|
+
## 🧪 Testing
|
|
126
|
+
|
|
127
|
+
Tests run against real TMAP samples when the `scce_external_center` data
|
|
128
|
+
directory (or `TAPSLIDE_TEST_DATA`) is present next to the repo; synthetic
|
|
129
|
+
fixtures keep the core parser covered everywhere else.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
pip install -e .[dev]
|
|
133
|
+
pytest
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## 📄 Format Notes (reverse-engineered)
|
|
137
|
+
|
|
138
|
+
TMAP is an undocumented proprietary format. This reader is built from
|
|
139
|
+
binary analysis of real scanner output, cross-validated against
|
|
140
|
+
[ASlide](https://github.com/MrPeterJin/ASlide). Both variants store plain
|
|
141
|
+
JPEG tiles with a small binary header and index tables; there is no
|
|
142
|
+
encryption.
|
|
143
|
+
|
|
144
|
+
- TMAP06 stores 3 pyramid levels (40x / 10x / 2.5x); TMAP07 stores up to
|
|
145
|
+
10 levels (40x down to 0.078x, halving each level).
|
|
146
|
+
- TMAP06 level-2 previews come from pre-rendered `ShrinkTile` entries and
|
|
147
|
+
are JPEG-compressed at that scale.
|
|
148
|
+
- `iter_tiles()` exposes the stored tile grid directly — useful for
|
|
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`).
|
|
158
|
+
|
|
159
|
+
## 📄 License
|
|
160
|
+
|
|
161
|
+
[MIT](LICENSE)
|
|
162
|
+
|
|
163
|
+
## 🙏 Acknowledgments
|
|
164
|
+
|
|
165
|
+
- [kfbslide](https://github.com/yifanfeng97/kfbslide) — the KFB reader this
|
|
166
|
+
project is modelled after
|
|
167
|
+
- [OpenSlide](https://openslide.org/) — the API this library mimics
|
|
168
|
+
- [ASlide](https://github.com/MrPeterJin/ASlide) — its independent
|
|
169
|
+
reverse-engineering of the TMAP06 layer/block structures (from decompiled
|
|
170
|
+
vendor SDK) was used to cross-validate this implementation. tmapslide is
|
|
171
|
+
an independent MIT-licensed implementation and ships no ASlide code
|
|
@@ -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
|
|
@@ -408,7 +453,7 @@ class OpenSlide:
|
|
|
408
453
|
props = info.properties
|
|
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
457
|
|
|
413
458
|
# clamp level to what we can render
|
|
414
459
|
if shrinks:
|
|
@@ -462,11 +507,10 @@ class OpenSlide:
|
|
|
462
507
|
|
|
463
508
|
if level >= 2 and shrinks:
|
|
464
509
|
# ShrinkTiles: pre-rendered tiles for deep zoom-out levels.
|
|
465
|
-
for layer_no, n_x, n_y, off, length in shrinks:
|
|
510
|
+
for layer_no, n_x, n_y, off, length, fid in shrinks:
|
|
466
511
|
if layer_no != level:
|
|
467
512
|
continue
|
|
468
513
|
scale = ratio_step**layer_no
|
|
469
|
-
tile_img = self._get_cached_tile(off, length)
|
|
470
514
|
cover_w = info.__dict__["_tile_jpeg_size"][0] * scale
|
|
471
515
|
cover_h = info.__dict__["_tile_jpeg_size"][1] * scale
|
|
472
516
|
if (
|
|
@@ -476,6 +520,7 @@ class OpenSlide:
|
|
|
476
520
|
or n_y > y0_l0 + h0
|
|
477
521
|
):
|
|
478
522
|
continue
|
|
523
|
+
tile_img = self._get_cached_tile(off, length, fid)
|
|
479
524
|
paste_scaled(tile_img, n_x, n_y, cover_w, cover_h)
|
|
480
525
|
return out
|
|
481
526
|
|
|
@@ -487,25 +532,50 @@ class OpenSlide:
|
|
|
487
532
|
n_x, n_y = blk["n_x"], blk["n_y"]
|
|
488
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:
|
|
489
534
|
continue
|
|
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
|
|
490
539
|
for layer_no, col, row, off, length in blk["tiles"]:
|
|
491
540
|
if layer_no != level:
|
|
492
541
|
continue
|
|
493
|
-
tile_img = self._get_cached_tile(off, length)
|
|
494
542
|
if layer_no > 0:
|
|
495
543
|
# one tile covers the whole block
|
|
496
544
|
tile_x0, tile_y0 = n_x, n_y
|
|
497
545
|
cover_w, cover_h = img_w, img_h
|
|
498
546
|
else:
|
|
499
|
-
tile_x0 = n_x + col *
|
|
500
|
-
tile_y0 = n_y + row *
|
|
501
|
-
cover_w, cover_h =
|
|
547
|
+
tile_x0 = n_x + col * jw
|
|
548
|
+
tile_y0 = n_y + row * jh
|
|
549
|
+
cover_w, cover_h = jw, jh
|
|
550
|
+
# cull tiles that miss the region BEFORE decoding
|
|
551
|
+
if (
|
|
552
|
+
tile_x0 + cover_w <= x0_l0
|
|
553
|
+
or tile_x0 >= x0_l0 + w0
|
|
554
|
+
or tile_y0 + cover_h <= y0_l0
|
|
555
|
+
or tile_y0 >= y0_l0 + h0
|
|
556
|
+
):
|
|
557
|
+
continue
|
|
558
|
+
tile_img = self._get_cached_tile(off, length, fid)
|
|
502
559
|
paste_scaled(tile_img, tile_x0, tile_y0, cover_w, cover_h)
|
|
503
560
|
return out
|
|
504
561
|
|
|
505
562
|
def get_thumbnail(self, size: Tuple[int, int]) -> Image.Image:
|
|
506
|
-
"""Get a thumbnail image no larger than `size`.
|
|
563
|
+
"""Get a thumbnail image no larger than `size`.
|
|
564
|
+
|
|
565
|
+
Prefers the stored thumbnail associated image when the file
|
|
566
|
+
provides one; otherwise renders the lowest-resolution level.
|
|
567
|
+
"""
|
|
507
568
|
self._check_open()
|
|
508
|
-
|
|
569
|
+
names = set()
|
|
570
|
+
try:
|
|
571
|
+
names = set(self.associated_images)
|
|
572
|
+
except Exception:
|
|
573
|
+
names = set()
|
|
574
|
+
if "thumbnail" in names:
|
|
575
|
+
thumb = self.associated_images["thumbnail"].convert("RGB")
|
|
576
|
+
thumb.thumbnail(size, Image.LANCZOS)
|
|
577
|
+
return thumb
|
|
578
|
+
# Fall back to the lowest-resolution pyramid level.
|
|
509
579
|
level = self.level_count - 1
|
|
510
580
|
dims = self.level_dimensions[level]
|
|
511
581
|
region = self.read_region((0, 0), level, dims)
|
|
@@ -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,
|
tmapslide-0.2.0/PKG-INFO
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: tmapslide
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: Pure Python UNIC TMAP whole-slide image reader with OpenSlide-compatible API
|
|
5
|
-
Project-URL: Homepage, https://github.com/yifanfeng97/tmapslide
|
|
6
|
-
Project-URL: Documentation, https://github.com/yifanfeng97/tmapslide#readme
|
|
7
|
-
Project-URL: Repository, https://github.com/yifanfeng97/tmapslide
|
|
8
|
-
Project-URL: Issues, https://github.com/yifanfeng97/tmapslide/issues
|
|
9
|
-
Author-email: Yifan Feng <evanfeng97@gmail.com>
|
|
10
|
-
License-Expression: MIT
|
|
11
|
-
License-File: LICENSE
|
|
12
|
-
Keywords: digital-pathology,openslide,pathology,tmap,tmapslide,unic,whole-slide-image,wsi
|
|
13
|
-
Classifier: Development Status :: 3 - Alpha
|
|
14
|
-
Classifier: Intended Audience :: Science/Research
|
|
15
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
-
Classifier: Programming Language :: Python :: 3
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
-
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
22
|
-
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
23
|
-
Requires-Python: >=3.10
|
|
24
|
-
Requires-Dist: pillow>=9.0.0
|
|
25
|
-
Provides-Extra: dev
|
|
26
|
-
Requires-Dist: mypy; extra == 'dev'
|
|
27
|
-
Requires-Dist: pytest-cov; extra == 'dev'
|
|
28
|
-
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
|
-
Requires-Dist: ruff; extra == 'dev'
|
|
30
|
-
Description-Content-Type: text/markdown
|
|
31
|
-
|
|
32
|
-
# TmapSlide
|
|
33
|
-
|
|
34
|
-

|
|
35
|
-
|
|
36
|
-
Pure Python reader for **UNIC TMAP** whole-slide images (WSI) with an
|
|
37
|
-
[OpenSlide](https://openslide.org/)-compatible API — the companion library to
|
|
38
|
-
[kfbslide](https://github.com/yifanfeng97/kfbslide) (KFB format).
|
|
39
|
-
|
|
40
|
-
```python
|
|
41
|
-
import tmapslide
|
|
42
|
-
|
|
43
|
-
slide = tmapslide.OpenSlide("sample.TMAP")
|
|
44
|
-
|
|
45
|
-
print(slide.dimensions) # (71424, 72704)
|
|
46
|
-
print(slide.level_count) # 10
|
|
47
|
-
print(slide.level_downsamples) # (1.0, 2.0, 4.0, ...)
|
|
48
|
-
|
|
49
|
-
region = slide.read_region((512, 512), 0, (1024, 1024)) # RGBA PIL image
|
|
50
|
-
thumb = slide.get_thumbnail((512, 512))
|
|
51
|
-
macro = slide.associated_images["macro"]
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
## Features
|
|
55
|
-
|
|
56
|
-
- **Pure Python** — no vendor SDK, no native dependencies; only Pillow.
|
|
57
|
-
- **OpenSlide-compatible API** — drop-in for code written against
|
|
58
|
-
`openslide`/`kfbslide`: `read_region`, `get_thumbnail`, `dimensions`,
|
|
59
|
-
`level_count`, `level_dimensions`, `level_downsamples`, `properties`,
|
|
60
|
-
`associated_images`.
|
|
61
|
-
- **Both known TMAP variants**: `TMAP06` (single pixel level, 612x512
|
|
62
|
-
overlapping tiles) and `TMAP07` (full pyramid, up to 10 levels, 256x256
|
|
63
|
-
tiles).
|
|
64
|
-
- **Fork-safe file handles** — safe to use from PyTorch `DataLoader` workers.
|
|
65
|
-
- LRU tile cache for fast repeated reads.
|
|
66
|
-
|
|
67
|
-
## Installation
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
pip install tmapslide
|
|
71
|
-
# or from source
|
|
72
|
-
pip install git+https://github.com/yifanfeng97/tmapslide.git
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
## Supported formats
|
|
76
|
-
|
|
77
|
-
| Format | Extension | Vendor | Backend |
|
|
78
|
-
| ------- | --------- | ------------------ | ----------- |
|
|
79
|
-
| TMAP 06 | `.TMAP` | UNIC (United Imaging) | Pure Python |
|
|
80
|
-
| TMAP 07 | `.TMAP` | UNIC (United Imaging) | Pure Python |
|
|
81
|
-
|
|
82
|
-
## API
|
|
83
|
-
|
|
84
|
-
### `tmapslide.OpenSlide(filename)`
|
|
85
|
-
|
|
86
|
-
| Member | Description |
|
|
87
|
-
| --------------------- | -------------------------------------------------- |
|
|
88
|
-
| `dimensions` | `(width, height)` at level 0 |
|
|
89
|
-
| `level_count` | number of pyramid levels |
|
|
90
|
-
| `level_dimensions` | `(w, h)` per level |
|
|
91
|
-
| `level_downsamples` | downsample factor per level |
|
|
92
|
-
| `properties` | read-only metadata mapping (`openslide.vendor=unic`, `tmap.*`) |
|
|
93
|
-
| `associated_images` | lazy mapping, typically `macro` / `label` |
|
|
94
|
-
| `read_region(loc, level, size)` | `PIL.Image` (RGBA) of the region |
|
|
95
|
-
| `get_thumbnail(size)` | thumbnail from the lowest resolution level |
|
|
96
|
-
| `get_best_level_for_downsample(ds)` | best level for a downsample factor |
|
|
97
|
-
| `close()` / context manager | release resources |
|
|
98
|
-
|
|
99
|
-
### `tmapslide.open_slide(filename)`
|
|
100
|
-
|
|
101
|
-
Alias of `OpenSlide(filename)`.
|
|
102
|
-
|
|
103
|
-
## Format notes (reverse-engineered)
|
|
104
|
-
|
|
105
|
-
TMAP is an undocumented proprietary format. This reader is built from
|
|
106
|
-
binary analysis of real scanner output, cross-validated against
|
|
107
|
-
[ASlide](https://github.com/MrPeterJin/ASlide). In short: both variants
|
|
108
|
-
store plain JPEG tiles with a small binary header and index tables; there
|
|
109
|
-
is no encryption.
|
|
110
|
-
|
|
111
|
-
- TMAP06 stores 3 pyramid levels (40x / 10x / 2.5x); TMAP07 stores up to
|
|
112
|
-
10 levels (40x down to 0.078x, halving each level).
|
|
113
|
-
- TMAP06 level-2 previews come from pre-rendered `ShrinkTile` entries and
|
|
114
|
-
are JPEG-compressed at that scale (slightly softer than downsampling
|
|
115
|
-
level 0 yourself).
|
|
116
|
-
- `iter_tiles()` exposes the stored tile grid directly, useful for
|
|
117
|
-
tile-based ML pipelines.
|
|
118
|
-
|
|
119
|
-
## Testing
|
|
120
|
-
|
|
121
|
-
Tests run against real TMAP samples when the `scce_external_center` data
|
|
122
|
-
directory (or `TAPSLIDE_TEST_DATA`) is present next to the repo, and are
|
|
123
|
-
skipped otherwise.
|
|
124
|
-
|
|
125
|
-
```bash
|
|
126
|
-
pip install -e .[dev]
|
|
127
|
-
pytest
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
## License
|
|
131
|
-
|
|
132
|
-
MIT — see [LICENSE](LICENSE).
|
|
133
|
-
|
|
134
|
-
## Acknowledgments
|
|
135
|
-
|
|
136
|
-
- [kfbslide](https://github.com/yifanfeng97/kfbslide) — the KFB reader this
|
|
137
|
-
project is modelled after.
|
|
138
|
-
- [OpenSlide](https://openslide.org/) — the API this library mimics.
|
|
139
|
-
- [ASlide](https://github.com/MrPeterJin/ASlide) — its independent
|
|
140
|
-
reverse-engineering of the TMAP06 layer/block structures (from decompiled
|
|
141
|
-
vendor SDK) was used to cross-validate this implementation. tmapslide is
|
|
142
|
-
an independent MIT-licensed implementation and ships no ASlide code.
|
tmapslide-0.2.0/README.md
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
# TmapSlide
|
|
2
|
-
|
|
3
|
-

|
|
4
|
-
|
|
5
|
-
Pure Python reader for **UNIC TMAP** whole-slide images (WSI) with an
|
|
6
|
-
[OpenSlide](https://openslide.org/)-compatible API — the companion library to
|
|
7
|
-
[kfbslide](https://github.com/yifanfeng97/kfbslide) (KFB format).
|
|
8
|
-
|
|
9
|
-
```python
|
|
10
|
-
import tmapslide
|
|
11
|
-
|
|
12
|
-
slide = tmapslide.OpenSlide("sample.TMAP")
|
|
13
|
-
|
|
14
|
-
print(slide.dimensions) # (71424, 72704)
|
|
15
|
-
print(slide.level_count) # 10
|
|
16
|
-
print(slide.level_downsamples) # (1.0, 2.0, 4.0, ...)
|
|
17
|
-
|
|
18
|
-
region = slide.read_region((512, 512), 0, (1024, 1024)) # RGBA PIL image
|
|
19
|
-
thumb = slide.get_thumbnail((512, 512))
|
|
20
|
-
macro = slide.associated_images["macro"]
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
## Features
|
|
24
|
-
|
|
25
|
-
- **Pure Python** — no vendor SDK, no native dependencies; only Pillow.
|
|
26
|
-
- **OpenSlide-compatible API** — drop-in for code written against
|
|
27
|
-
`openslide`/`kfbslide`: `read_region`, `get_thumbnail`, `dimensions`,
|
|
28
|
-
`level_count`, `level_dimensions`, `level_downsamples`, `properties`,
|
|
29
|
-
`associated_images`.
|
|
30
|
-
- **Both known TMAP variants**: `TMAP06` (single pixel level, 612x512
|
|
31
|
-
overlapping tiles) and `TMAP07` (full pyramid, up to 10 levels, 256x256
|
|
32
|
-
tiles).
|
|
33
|
-
- **Fork-safe file handles** — safe to use from PyTorch `DataLoader` workers.
|
|
34
|
-
- LRU tile cache for fast repeated reads.
|
|
35
|
-
|
|
36
|
-
## Installation
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
pip install tmapslide
|
|
40
|
-
# or from source
|
|
41
|
-
pip install git+https://github.com/yifanfeng97/tmapslide.git
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
## Supported formats
|
|
45
|
-
|
|
46
|
-
| Format | Extension | Vendor | Backend |
|
|
47
|
-
| ------- | --------- | ------------------ | ----------- |
|
|
48
|
-
| TMAP 06 | `.TMAP` | UNIC (United Imaging) | Pure Python |
|
|
49
|
-
| TMAP 07 | `.TMAP` | UNIC (United Imaging) | Pure Python |
|
|
50
|
-
|
|
51
|
-
## API
|
|
52
|
-
|
|
53
|
-
### `tmapslide.OpenSlide(filename)`
|
|
54
|
-
|
|
55
|
-
| Member | Description |
|
|
56
|
-
| --------------------- | -------------------------------------------------- |
|
|
57
|
-
| `dimensions` | `(width, height)` at level 0 |
|
|
58
|
-
| `level_count` | number of pyramid levels |
|
|
59
|
-
| `level_dimensions` | `(w, h)` per level |
|
|
60
|
-
| `level_downsamples` | downsample factor per level |
|
|
61
|
-
| `properties` | read-only metadata mapping (`openslide.vendor=unic`, `tmap.*`) |
|
|
62
|
-
| `associated_images` | lazy mapping, typically `macro` / `label` |
|
|
63
|
-
| `read_region(loc, level, size)` | `PIL.Image` (RGBA) of the region |
|
|
64
|
-
| `get_thumbnail(size)` | thumbnail from the lowest resolution level |
|
|
65
|
-
| `get_best_level_for_downsample(ds)` | best level for a downsample factor |
|
|
66
|
-
| `close()` / context manager | release resources |
|
|
67
|
-
|
|
68
|
-
### `tmapslide.open_slide(filename)`
|
|
69
|
-
|
|
70
|
-
Alias of `OpenSlide(filename)`.
|
|
71
|
-
|
|
72
|
-
## Format notes (reverse-engineered)
|
|
73
|
-
|
|
74
|
-
TMAP is an undocumented proprietary format. This reader is built from
|
|
75
|
-
binary analysis of real scanner output, cross-validated against
|
|
76
|
-
[ASlide](https://github.com/MrPeterJin/ASlide). In short: both variants
|
|
77
|
-
store plain JPEG tiles with a small binary header and index tables; there
|
|
78
|
-
is no encryption.
|
|
79
|
-
|
|
80
|
-
- TMAP06 stores 3 pyramid levels (40x / 10x / 2.5x); TMAP07 stores up to
|
|
81
|
-
10 levels (40x down to 0.078x, halving each level).
|
|
82
|
-
- TMAP06 level-2 previews come from pre-rendered `ShrinkTile` entries and
|
|
83
|
-
are JPEG-compressed at that scale (slightly softer than downsampling
|
|
84
|
-
level 0 yourself).
|
|
85
|
-
- `iter_tiles()` exposes the stored tile grid directly, useful for
|
|
86
|
-
tile-based ML pipelines.
|
|
87
|
-
|
|
88
|
-
## Testing
|
|
89
|
-
|
|
90
|
-
Tests run against real TMAP samples when the `scce_external_center` data
|
|
91
|
-
directory (or `TAPSLIDE_TEST_DATA`) is present next to the repo, and are
|
|
92
|
-
skipped otherwise.
|
|
93
|
-
|
|
94
|
-
```bash
|
|
95
|
-
pip install -e .[dev]
|
|
96
|
-
pytest
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
## License
|
|
100
|
-
|
|
101
|
-
MIT — see [LICENSE](LICENSE).
|
|
102
|
-
|
|
103
|
-
## Acknowledgments
|
|
104
|
-
|
|
105
|
-
- [kfbslide](https://github.com/yifanfeng97/kfbslide) — the KFB reader this
|
|
106
|
-
project is modelled after.
|
|
107
|
-
- [OpenSlide](https://openslide.org/) — the API this library mimics.
|
|
108
|
-
- [ASlide](https://github.com/MrPeterJin/ASlide) — its independent
|
|
109
|
-
reverse-engineering of the TMAP06 layer/block structures (from decompiled
|
|
110
|
-
vendor SDK) was used to cross-validate this implementation. tmapslide is
|
|
111
|
-
an independent MIT-licensed implementation and ships no ASlide code.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|