tmapslide 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tmapslide
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Pure Python UNIC TMAP whole-slide image reader with OpenSlide-compatible API
5
5
  Project-URL: Homepage, https://github.com/yifanfeng97/tmapslide
6
6
  Project-URL: Documentation, https://github.com/yifanfeng97/tmapslide#readme
@@ -31,11 +31,7 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  <div align="center">
33
33
 
34
- <img src="docs/hero.jpg" alt="TmapSlide — UNIC TMAP whole-slide images in pure Python" width="800" style="max-width: 100%;">
35
-
36
- <br/>
37
-
38
- **TmapSlide**
34
+ # TmapSlide
39
35
 
40
36
  **Pure Python reader for UNIC TMAP whole-slide images — no SDK, no native deps.**
41
37
 
@@ -54,9 +50,6 @@ Description-Content-Type: text/markdown
54
50
  <a href="LICENSE">
55
51
  <img src="https://img.shields.io/badge/license-MIT-06b6d4?style=for-the-badge&logo=openaccess&logoColor=white&labelColor=1a1a2e" alt="License">
56
52
  </a>
57
- <a href="https://github.com/yifanfeng97/tmapslide/actions/workflows/test.yml">
58
- <img src="https://img.shields.io/github/actions/yifanfeng97/tmapslide/test.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&labelColor=1a1a2e&label=tests" alt="Tests">
59
- </a>
60
53
  <a href="https://github.com/yifanfeng97/tmapslide/stargazers">
61
54
  <img src="https://img.shields.io/github/stars/yifanfeng97/tmapslide?style=for-the-badge&logo=github&labelColor=1a1a2e&color=facc15" alt="GitHub Stars">
62
55
  </a>
@@ -64,6 +57,10 @@ Description-Content-Type: text/markdown
64
57
 
65
58
  [📖 English](#-quick-start) · [中文说明](#-中文说明)
66
59
 
60
+ <br/>
61
+
62
+ <img src="docs/hero.jpg" alt="TmapSlide — UNIC TMAP whole-slide images in pure Python" width="800" style="max-width: 100%;">
63
+
67
64
  </div>
68
65
 
69
66
  ## ⚡ Quick Start
@@ -99,6 +96,14 @@ macro = slide.associated_images["macro"]
99
96
  `associated_images`
100
97
  - **Both known TMAP variants** — `TMAP06` (3-level pyramid) and
101
98
  `TMAP07` (up to 10 levels)
99
+ - **Multi-file slides** — TMAP06 slides that spill tiles into `.DT1`
100
+ sidecar files are read transparently
101
+ - **Trusted pixel size** — several TMAP06 *and* TMAP07 headers carry an
102
+ impossible `pixel_size` (e.g. 6.88e-05 mm at 40x, implying ~2 µm
103
+ nuclei); tmapslide cross-checks it against the objective power and
104
+ falls back to 10 µm / magnification, exposing the result via
105
+ `openslide.mpp-x` / `openslide.mpp-y` (the raw header value stays in
106
+ `tmap.pixel_size_mm`, the decision in `tmap.mpp_source`)
102
107
  - **Fork-safe file handles** — safe with PyTorch `DataLoader` workers
103
108
  - **LRU decoded-tile cache** — fast repeated reads
104
109
  - **Thread-safe reads** — concurrent `read_region` from worker threads
@@ -129,11 +134,12 @@ TMAP backend (median of 5 runs, same files, same machine):
129
134
  | `level_count` | number of pyramid levels |
130
135
  | `level_dimensions` | `(w, h)` per level |
131
136
  | `level_downsamples` | downsample factor per level |
132
- | `properties` | read-only metadata mapping (`openslide.vendor=unic`, `tmap.*`) |
137
+ | `properties` | read-only metadata mapping (`openslide.vendor=unic`, `openslide.mpp-x/y`, `tmap.*`) |
133
138
  | `associated_images` | lazy mapping, typically `macro` / `label` / `thumbnail` |
134
139
  | `read_region(loc, level, size)` | `PIL.Image` (RGBA) of the region |
135
- | `get_thumbnail(size)` | thumbnail from the lowest resolution level |
140
+ | `get_thumbnail(size)` | stored thumbnail when available, else lowest level |
136
141
  | `get_best_level_for_downsample(ds)` | best level for a downsample factor |
142
+ | `iter_tiles(level=0)` | yields `(x, y, load)` per stored tile |
137
143
  | `close()` / context manager | release resources |
138
144
 
139
145
  ### `tmapslide.open_slide(filename)`
@@ -144,7 +150,7 @@ Alias of `OpenSlide(filename)`.
144
150
 
145
151
  | Format | Extension | Vendor | Backend |
146
152
  | --- | --- | --- | --- |
147
- | TMAP 06 | `.TMAP` | UNIC (United Imaging) | Pure Python |
153
+ | TMAP 06 | `.TMAP` (+ optional `.DT1` sidecars) | UNIC (United Imaging) | Pure Python |
148
154
  | TMAP 07 | `.TMAP` | UNIC (United Imaging) | Pure Python |
149
155
 
150
156
  ## 🧪 Testing
@@ -172,6 +178,14 @@ encryption.
172
178
  are JPEG-compressed at that scale.
173
179
  - `iter_tiles()` exposes the stored tile grid directly — useful for
174
180
  tile-based ML pipelines.
181
+ - **Metadata caveat**: the header `pixel_size` field cannot be trusted.
182
+ Measured on real slides (cell-nucleus diameters, canvas physical size,
183
+ cross-checked with ASlide), both the Henan TMAP06 batch (6.88e-05 mm)
184
+ and the Shanxi TMAP07 batch (1.01e-04 mm) carry corrupt values at 40x;
185
+ the true resolution is 0.25 µm/px. tmapslide keeps the raw value in
186
+ `tmap.pixel_size_mm`, exposes the corrected one via
187
+ `openslide.mpp-x/y`, and records the decision in `tmap.mpp_source`
188
+ (`header` / `derived`).
175
189
 
176
190
  ## 📄 License
177
191
 
@@ -1,10 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="docs/hero.jpg" alt="TmapSlide — UNIC TMAP whole-slide images in pure Python" width="800" style="max-width: 100%;">
4
-
5
- <br/>
6
-
7
- **TmapSlide**
3
+ # TmapSlide
8
4
 
9
5
  **Pure Python reader for UNIC TMAP whole-slide images — no SDK, no native deps.**
10
6
 
@@ -23,9 +19,6 @@
23
19
  <a href="LICENSE">
24
20
  <img src="https://img.shields.io/badge/license-MIT-06b6d4?style=for-the-badge&logo=openaccess&logoColor=white&labelColor=1a1a2e" alt="License">
25
21
  </a>
26
- <a href="https://github.com/yifanfeng97/tmapslide/actions/workflows/test.yml">
27
- <img src="https://img.shields.io/github/actions/yifanfeng97/tmapslide/test.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&labelColor=1a1a2e&label=tests" alt="Tests">
28
- </a>
29
22
  <a href="https://github.com/yifanfeng97/tmapslide/stargazers">
30
23
  <img src="https://img.shields.io/github/stars/yifanfeng97/tmapslide?style=for-the-badge&logo=github&labelColor=1a1a2e&color=facc15" alt="GitHub Stars">
31
24
  </a>
@@ -33,6 +26,10 @@
33
26
 
34
27
  [📖 English](#-quick-start) · [中文说明](#-中文说明)
35
28
 
29
+ <br/>
30
+
31
+ <img src="docs/hero.jpg" alt="TmapSlide — UNIC TMAP whole-slide images in pure Python" width="800" style="max-width: 100%;">
32
+
36
33
  </div>
37
34
 
38
35
  ## ⚡ Quick Start
@@ -68,6 +65,14 @@ macro = slide.associated_images["macro"]
68
65
  `associated_images`
69
66
  - **Both known TMAP variants** — `TMAP06` (3-level pyramid) and
70
67
  `TMAP07` (up to 10 levels)
68
+ - **Multi-file slides** — TMAP06 slides that spill tiles into `.DT1`
69
+ sidecar files are read transparently
70
+ - **Trusted pixel size** — several TMAP06 *and* TMAP07 headers carry an
71
+ impossible `pixel_size` (e.g. 6.88e-05 mm at 40x, implying ~2 µm
72
+ nuclei); tmapslide cross-checks it against the objective power and
73
+ falls back to 10 µm / magnification, exposing the result via
74
+ `openslide.mpp-x` / `openslide.mpp-y` (the raw header value stays in
75
+ `tmap.pixel_size_mm`, the decision in `tmap.mpp_source`)
71
76
  - **Fork-safe file handles** — safe with PyTorch `DataLoader` workers
72
77
  - **LRU decoded-tile cache** — fast repeated reads
73
78
  - **Thread-safe reads** — concurrent `read_region` from worker threads
@@ -98,11 +103,12 @@ TMAP backend (median of 5 runs, same files, same machine):
98
103
  | `level_count` | number of pyramid levels |
99
104
  | `level_dimensions` | `(w, h)` per level |
100
105
  | `level_downsamples` | downsample factor per level |
101
- | `properties` | read-only metadata mapping (`openslide.vendor=unic`, `tmap.*`) |
106
+ | `properties` | read-only metadata mapping (`openslide.vendor=unic`, `openslide.mpp-x/y`, `tmap.*`) |
102
107
  | `associated_images` | lazy mapping, typically `macro` / `label` / `thumbnail` |
103
108
  | `read_region(loc, level, size)` | `PIL.Image` (RGBA) of the region |
104
- | `get_thumbnail(size)` | thumbnail from the lowest resolution level |
109
+ | `get_thumbnail(size)` | stored thumbnail when available, else lowest level |
105
110
  | `get_best_level_for_downsample(ds)` | best level for a downsample factor |
111
+ | `iter_tiles(level=0)` | yields `(x, y, load)` per stored tile |
106
112
  | `close()` / context manager | release resources |
107
113
 
108
114
  ### `tmapslide.open_slide(filename)`
@@ -113,7 +119,7 @@ Alias of `OpenSlide(filename)`.
113
119
 
114
120
  | Format | Extension | Vendor | Backend |
115
121
  | --- | --- | --- | --- |
116
- | TMAP 06 | `.TMAP` | UNIC (United Imaging) | Pure Python |
122
+ | TMAP 06 | `.TMAP` (+ optional `.DT1` sidecars) | UNIC (United Imaging) | Pure Python |
117
123
  | TMAP 07 | `.TMAP` | UNIC (United Imaging) | Pure Python |
118
124
 
119
125
  ## 🧪 Testing
@@ -141,6 +147,14 @@ encryption.
141
147
  are JPEG-compressed at that scale.
142
148
  - `iter_tiles()` exposes the stored tile grid directly — useful for
143
149
  tile-based ML pipelines.
150
+ - **Metadata caveat**: the header `pixel_size` field cannot be trusted.
151
+ Measured on real slides (cell-nucleus diameters, canvas physical size,
152
+ cross-checked with ASlide), both the Henan TMAP06 batch (6.88e-05 mm)
153
+ and the Shanxi TMAP07 batch (1.01e-04 mm) carry corrupt values at 40x;
154
+ the true resolution is 0.25 µm/px. tmapslide keeps the raw value in
155
+ `tmap.pixel_size_mm`, exposes the corrected one via
156
+ `openslide.mpp-x/y`, and records the decision in `tmap.mpp_source`
157
+ (`header` / `derived`).
144
158
 
145
159
  ## 📄 License
146
160
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tmapslide"
7
- version = "0.2.1"
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.1"
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
@@ -409,7 +454,6 @@ class OpenSlide:
409
454
  img_w = int(props.get("tmap.img_width", 2448))
410
455
  img_h = int(props.get("tmap.img_height", 2048))
411
456
  tile_img_w, tile_img_h = info.__dict__.get("_tile_jpeg_size", (612, 512))
412
- fh = self._ensure_open_handle()
413
457
 
414
458
  # clamp level to what we can render
415
459
  if shrinks:
@@ -463,11 +507,10 @@ class OpenSlide:
463
507
 
464
508
  if level >= 2 and shrinks:
465
509
  # ShrinkTiles: pre-rendered tiles for deep zoom-out levels.
466
- for layer_no, n_x, n_y, off, length in shrinks:
510
+ for layer_no, n_x, n_y, off, length, fid in shrinks:
467
511
  if layer_no != level:
468
512
  continue
469
513
  scale = ratio_step**layer_no
470
- tile_img = self._get_cached_tile(off, length)
471
514
  cover_w = info.__dict__["_tile_jpeg_size"][0] * scale
472
515
  cover_h = info.__dict__["_tile_jpeg_size"][1] * scale
473
516
  if (
@@ -477,6 +520,7 @@ class OpenSlide:
477
520
  or n_y > y0_l0 + h0
478
521
  ):
479
522
  continue
523
+ tile_img = self._get_cached_tile(off, length, fid)
480
524
  paste_scaled(tile_img, n_x, n_y, cover_w, cover_h)
481
525
  return out
482
526
 
@@ -489,6 +533,9 @@ class OpenSlide:
489
533
  if n_x + img_w < x0_l0 or n_x > x0_l0 + w0 or n_y + img_h < y0_l0 or n_y > y0_l0 + h0:
490
534
  continue
491
535
  jw, jh = info.__dict__.get("_tile_jpeg_size", (tile_img_w, tile_img_h))
536
+ fid = blk.get("file_id", 0)
537
+ if self._get_file_handle(fid) is None:
538
+ continue # sidecar file missing; degrade to background
492
539
  for layer_no, col, row, off, length in blk["tiles"]:
493
540
  if layer_no != level:
494
541
  continue
@@ -508,7 +555,7 @@ class OpenSlide:
508
555
  or tile_y0 >= y0_l0 + h0
509
556
  ):
510
557
  continue
511
- tile_img = self._get_cached_tile(off, length)
558
+ tile_img = self._get_cached_tile(off, length, fid)
512
559
  paste_scaled(tile_img, tile_x0, tile_y0, cover_w, cover_h)
513
560
  return out
514
561
 
@@ -39,6 +39,7 @@ TMAP06 layout
39
39
  """
40
40
 
41
41
  import io
42
+ import os
42
43
  import struct
43
44
  from dataclasses import dataclass, field
44
45
  from typing import Dict, List, Optional, Tuple
@@ -61,7 +62,11 @@ REC07_HEADER_SKIP = 24
61
62
 
62
63
  @dataclass
63
64
  class TmapTile:
64
- """One pyramid tile pointing at a JPEG blob in 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,
File without changes
File without changes
File without changes