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.
@@ -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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tmapslide"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Pure Python UNIC TMAP whole-slide image reader with OpenSlide-compatible API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -20,7 +20,7 @@ from ._exceptions import (
20
20
  )
21
21
  from ._slide import OpenSlide, TmapSlide, open_slide
22
22
 
23
- __version__ = "0.2.0"
23
+ __version__ = "0.2.2"
24
24
 
25
25
  # Standard OpenSlide property name constants
26
26
  PROPERTY_NAME_VENDOR = "openslide.vendor"
@@ -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 _get_cached_tile(self, offset: int, length: int) -> Image.Image:
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._ensure_open_handle()
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
- fh = self._ensure_open_handle()
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 * tile_img.width
500
- tile_y0 = n_y + row * tile_img.height
501
- cover_w, cover_h = tile_img.width, tile_img.height
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
- # Prefer the lowest-resolution pyramid level.
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 the file."""
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 <= file_size:
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=li_raw[base])
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
- _file_id, layer_no = struct.unpack_from("<BB", st_raw, base)
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 <= file_size:
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
- data = _read_at(f, off, length)
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, 2.5e-4) # pixel size (mm)
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, 2.5e-4) # pixel size (mm)
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] = 0 # file id
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 = data_off + len(tiles_blob)
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
- tiles_blob += j
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
- ![tmapslide — TMAP whole-slide images in pure Python](docs/hero.jpg)
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
- ![tmapslide — TMAP whole-slide images in pure Python](docs/hero.jpg)
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