tmapslide 0.2.2__tar.gz → 0.2.3__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.2
3
+ Version: 0.2.3
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
@@ -115,14 +115,17 @@ TMAP backend (median of 5 runs, same files, same machine):
115
115
 
116
116
  | Scenario | TMAP07 | TMAP06 |
117
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.
118
+ | Open slide | 68 ms vs 46 ms | **80 ms vs 220 ms (2.8×)** |
119
+ | Cold 1024² region @L0 | **3.6 ms vs 15.7 ms (4.4×)** | **2.8 ms vs 12.3 ms (4.4×)** |
120
+ | Random 512² region @L0 | **1.0 ms vs 5.2 ms (5.0×)** | **1.0 ms vs 5.6 ms (5.6×)** |
121
+ | Warm 512² region ×50 | **45 ms vs 242 ms (5.4×)** | **55 ms vs 373 ms (6.8×)** |
122
+
123
+ > The two libraries pay for indexing in opposite places. tmapslide builds a
124
+ > validated tile index (grid + lazy tile tables) once at open; ASlide defers
125
+ > that work into every read, re-decoding tiles per call. That is why TMAP07
126
+ > open runs ~20 ms slower while every read is 4–7× faster — for any workload
127
+ > that reads more than a handful of regions, tmapslide comes out far ahead.
128
+ > Slide open is a once-per-process cost.
126
129
 
127
130
  ## 📖 API
128
131
 
@@ -84,14 +84,17 @@ TMAP backend (median of 5 runs, same files, same machine):
84
84
 
85
85
  | Scenario | TMAP07 | TMAP06 |
86
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.
87
+ | Open slide | 68 ms vs 46 ms | **80 ms vs 220 ms (2.8×)** |
88
+ | Cold 1024² region @L0 | **3.6 ms vs 15.7 ms (4.4×)** | **2.8 ms vs 12.3 ms (4.4×)** |
89
+ | Random 512² region @L0 | **1.0 ms vs 5.2 ms (5.0×)** | **1.0 ms vs 5.6 ms (5.6×)** |
90
+ | Warm 512² region ×50 | **45 ms vs 242 ms (5.4×)** | **55 ms vs 373 ms (6.8×)** |
91
+
92
+ > The two libraries pay for indexing in opposite places. tmapslide builds a
93
+ > validated tile index (grid + lazy tile tables) once at open; ASlide defers
94
+ > that work into every read, re-decoding tiles per call. That is why TMAP07
95
+ > open runs ~20 ms slower while every read is 4–7× faster — for any workload
96
+ > that reads more than a handful of regions, tmapslide comes out far ahead.
97
+ > Slide open is a once-per-process cost.
95
98
 
96
99
  ## 📖 API
97
100
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tmapslide"
7
- version = "0.2.2"
7
+ version = "0.2.3"
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.2"
23
+ __version__ = "0.2.3"
24
24
 
25
25
  # Standard OpenSlide property name constants
26
26
  PROPERTY_NAME_VENDOR = "openslide.vendor"
@@ -114,7 +114,6 @@ class OpenSlide:
114
114
  "_info",
115
115
  "_properties",
116
116
  "_associated_images",
117
- "_tile_lookup",
118
117
  "_background_rgba",
119
118
  "_io_lock",
120
119
  "_data_paths",
@@ -138,12 +137,6 @@ class OpenSlide:
138
137
  except Exception as e:
139
138
  raise OpenSlideUnsupportedFormatError(f"Cannot parse TMAP file: {e}")
140
139
 
141
- # (level, x, y) -> tile index, for grid lookup in read_region.
142
- self._tile_lookup: Dict[Tuple[int, int, int], int] = {}
143
- for level in self._info.levels:
144
- for idx, t in enumerate(level.tiles):
145
- self._tile_lookup[(level.index, t.x, t.y)] = idx
146
-
147
140
  try:
148
141
  self._file_handle = open(filename, "rb")
149
142
  except Exception as e:
@@ -416,13 +409,22 @@ class OpenSlide:
416
409
 
417
410
  self._ensure_open_handle()
418
411
 
412
+ grid = lvl.grid
413
+ gcols = lvl.grid_cols
414
+ glen = len(grid) if grid is not None else 0
415
+
419
416
  for ty in range(ty_start, ty_end):
417
+ tile_y = ty * th
420
418
  for tx in range(tx_start, tx_end):
421
- tile_x = tx * tw
422
- tile_y = ty * th
423
- idx = self._tile_lookup.get((level, tile_x, tile_y))
424
- if idx is None:
419
+ if grid is None or tx < 0:
425
420
  continue
421
+ cell = ty * gcols + tx
422
+ if cell >= glen:
423
+ continue
424
+ idx = grid[cell]
425
+ if idx < 0:
426
+ continue
427
+ tile_x = tx * tw
426
428
  tile = self._read_decoded_tile(lvl, idx)
427
429
  crop_x0 = max(0, x0 - tile_x)
428
430
  crop_y0 = max(0, y0 - tile_y)
@@ -41,6 +41,7 @@ TMAP06 layout
41
41
  import io
42
42
  import os
43
43
  import struct
44
+ from collections.abc import Sequence
44
45
  from dataclasses import dataclass, field
45
46
  from typing import Dict, List, Optional, Tuple
46
47
 
@@ -60,12 +61,13 @@ REC07_SIZE = 40
60
61
  REC07_HEADER_SKIP = 24
61
62
 
62
63
 
63
- @dataclass
64
+ @dataclass(slots=True)
64
65
  class TmapTile:
65
66
  """One pyramid tile pointing at a JPEG blob in a data file.
66
67
 
67
68
  ``file_id`` selects the file: 0 is the main TMAP, 1+ are .DT sidecar
68
- files (TMAP06 multi-file slides).
69
+ files (TMAP06 multi-file slides). Slotted for fast bulk construction:
70
+ TMAP07 slides materialize 100k+ tiles at open time.
69
71
  """
70
72
 
71
73
  level: int
@@ -78,6 +80,46 @@ class TmapTile:
78
80
  file_id: int = 0
79
81
 
80
82
 
83
+ class _TileList(Sequence):
84
+ """Lazily materializes TmapTile objects from a level's raw record table.
85
+
86
+ A TMAP07 level-0 table holds 100k+ records; constructing every tile
87
+ up front dominated slide-open time. The record table is kept as raw
88
+ bytes and a tile is unpacked on first access. Public behavior is
89
+ identical to a list of TmapTile.
90
+ """
91
+
92
+ __slots__ = ("_level", "_raw", "_rec_idx", "_tw", "_th")
93
+
94
+ def __init__(self, level: int, raw: bytes, rec_idx: List[int], tw: int, th: int):
95
+ self._level = level
96
+ self._raw = raw
97
+ self._rec_idx = rec_idx
98
+ self._tw = tw
99
+ self._th = th
100
+
101
+ def __len__(self) -> int:
102
+ return len(self._rec_idx)
103
+
104
+ def __getitem__(self, i):
105
+ if isinstance(i, slice):
106
+ return [self[j] for j in range(*i.indices(len(self)))]
107
+ base = self._rec_idx[i] * TMAP07_REC_SIZE
108
+ _layer, _focus, x, y, _w, _h, off, size = _REC07.unpack_from(self._raw, base)
109
+ return TmapTile(self._level, off, size, x, y, self._tw, self._th)
110
+
111
+ def __iter__(self):
112
+ unpack = _REC07.unpack_from
113
+ raw = self._raw
114
+ level, tw, th = self._level, self._tw, self._th
115
+ for i in self._rec_idx:
116
+ _layer, _focus, x, y, _w, _h, off, size = unpack(raw, i * TMAP07_REC_SIZE)
117
+ yield TmapTile(level, off, size, x, y, tw, th)
118
+
119
+ def __repr__(self) -> str:
120
+ return f"_TileList(len={len(self._rec_idx)})"
121
+
122
+
81
123
  @dataclass
82
124
  class TmapLevel:
83
125
  """A pyramid level."""
@@ -88,7 +130,12 @@ class TmapLevel:
88
130
  height: int
89
131
  tile_width: int
90
132
  tile_height: int
91
- tiles: List[TmapTile] = field(default_factory=list)
133
+ tiles: Sequence[TmapTile] = field(default_factory=list)
134
+ # TMAP07: map from grid cell (row * grid_cols + col) to the index of
135
+ # that tile in `tiles`, or -1 for blank cells. Replaces a hash lookup
136
+ # at read time; None for TMAP06 levels (block-based geometry).
137
+ grid: Optional[List[int]] = None
138
+ grid_cols: int = 0
92
139
 
93
140
 
94
141
  @dataclass
@@ -189,19 +236,6 @@ _ASSOC07_NAMES = {
189
236
  }
190
237
 
191
238
 
192
- def _find_tile_data_start07(f, file_size: int) -> int:
193
- """First JPEG SOI at/after the record region -> start of tile data."""
194
- pos = 0
195
- step = 1024 * 1024
196
- while pos < file_size:
197
- chunk = _read_at(f, pos, min(step, file_size - pos))
198
- p = chunk.find(b"\xff\xd8\xff")
199
- if p != -1:
200
- return pos + p
201
- pos += len(chunk)
202
- raise ValueError("TMAP07: no tile data found")
203
-
204
-
205
239
  def _parse_assoc07(f, file_size: int, tile_data_end: int) -> List[TmapAssocImage]:
206
240
  """Collect TMAP07 associated images.
207
241
 
@@ -284,32 +318,7 @@ def _parse_layer_table07(f, base: int = TMAP07_LAYERINFO_BASE) -> List[dict]:
284
318
  return entries
285
319
 
286
320
 
287
- def _parse_records07(f, entry: dict, file_size: int) -> List[Optional[tuple]]:
288
- """Read one level's tile table.
289
-
290
- Record (40 B), indexed by (row * tile_col + col):
291
- layer_no u32, focus_no u32, x u32, y u32, w u32, h u32,
292
- data_offset u64, data_length u32, pad u32
293
- Blank cells store offset == 0.
294
- """
295
- cols, rows = entry["tile_col"], entry["tile_row"]
296
- total = cols * rows
297
- raw = _read_at(f, entry["index_offset"], total * TMAP07_REC_SIZE + 64)
298
- recs: List[Optional[tuple]] = []
299
- for i in range(total):
300
- base = i * TMAP07_REC_SIZE
301
- if base + TMAP07_REC_SIZE > len(raw):
302
- break
303
- layer_no, focus_no, x, y, w, h, off, size = struct.unpack_from(
304
- "<IIIIIIQI", raw, base
305
- )
306
- if off == 0 and size == 0:
307
- recs.append(None)
308
- continue
309
- if size == 0 or off + size > file_size:
310
- break
311
- recs.append((off, size, layer_no, focus_no, x, y, w, h))
312
- return recs
321
+ _REC07 = struct.Struct("<IIIIIIQI") # layer_no, focus_no, x, y, w, h, off u64, size
313
322
 
314
323
 
315
324
  def _parse_tmap07(path: str) -> TmapFileInfo:
@@ -327,8 +336,6 @@ def _parse_tmap07(path: str) -> TmapFileInfo:
327
336
  declared_tiles = struct.unpack_from("<I", head, 0x1C)[0]
328
337
  magic = head[:6].decode("ascii", errors="replace")
329
338
 
330
- data_start = _find_tile_data_start07(f, file_size)
331
-
332
339
  # TMAP07 LayerInfo table at 560.
333
340
  entries = _parse_layer_table07(f)
334
341
  if not entries:
@@ -336,32 +343,55 @@ def _parse_tmap07(path: str) -> TmapFileInfo:
336
343
 
337
344
  levels: List[TmapLevel] = []
338
345
  prop_layer_info = []
346
+ tile_data_end = 0
339
347
  for order, e in enumerate(entries):
340
- real = _parse_records07(f, e, file_size)
341
- real = [r for r in real if r is not None]
342
- if not real:
348
+ # Single pass over the record table: validate records, fix the
349
+ # level tile size from the first stored record, build the
350
+ # grid-position index, and keep the table as raw bytes — tile
351
+ # objects are materialized lazily by _TileList (100k+ tiles on
352
+ # level 0 would otherwise dominate slide-open time).
353
+ cols = e["tile_col"]
354
+ total = cols * e["tile_row"]
355
+ raw = _read_at(f, e["index_offset"], total * TMAP07_REC_SIZE + 64)
356
+ n = min(total, len(raw) // TMAP07_REC_SIZE)
357
+ unpack = _REC07.unpack_from
358
+ grid = [-1] * total
359
+ rec_idx: List[int] = []
360
+ ri_append = rec_idx.append
361
+ tw = th = None
362
+ level_end = 0
363
+ pos = 0
364
+ for i in range(n):
365
+ _layer, _focus, x, y, w, h, off, size = unpack(raw, i * TMAP07_REC_SIZE)
366
+ if off == 0 and size == 0:
367
+ continue
368
+ if size == 0 or off + size > file_size:
369
+ break
370
+ if tw is None:
371
+ tw = w if 16 <= w <= 4096 else 256
372
+ th = h if 16 <= h <= 4096 else 256
373
+ grid[i] = pos
374
+ pos += 1
375
+ ri_append(i)
376
+ if off + size > level_end:
377
+ level_end = off + size
378
+ if not rec_idx:
343
379
  continue
344
- sample = real[0]
345
- tw = sample[6] if 16 <= sample[6] <= 4096 else 256
346
- th = sample[7] if 16 <= sample[7] <= 4096 else 256
347
- lvl = TmapLevel(
348
- index=order,
349
- downsample=float(2**order),
350
- width=e["width"],
351
- height=e["height"],
352
- tile_width=tw,
353
- tile_height=th,
354
- )
355
- for r in real:
356
- off, size = r[0], r[1]
357
- x, y = r[4], r[5]
358
- lvl.tiles.append(
359
- TmapTile(
360
- level=order, offset=off, size=size, x=x, y=y,
361
- width=tw, height=th,
362
- )
380
+ if level_end > tile_data_end:
381
+ tile_data_end = level_end
382
+ levels.append(
383
+ TmapLevel(
384
+ index=order,
385
+ downsample=float(2**order),
386
+ width=e["width"],
387
+ height=e["height"],
388
+ tile_width=tw,
389
+ tile_height=th,
390
+ tiles=_TileList(order, raw, rec_idx, tw, th),
391
+ grid=grid,
392
+ grid_cols=cols,
363
393
  )
364
- levels.append(lvl)
394
+ )
365
395
  prop_layer_info.append(
366
396
  f"{e['width']}x{e['height']}:{e['tile_col']}x{e['tile_row']}"
367
397
  )
@@ -369,7 +399,6 @@ def _parse_tmap07(path: str) -> TmapFileInfo:
369
399
  if not levels:
370
400
  raise ValueError("TMAP07: no tile records parsed")
371
401
 
372
- tile_data_end = max(t.offset + t.size for l in levels for t in l.tiles)
373
402
  assoc = _parse_assoc07(f, file_size, tile_data_end)
374
403
 
375
404
  return TmapFileInfo(
File without changes
File without changes
File without changes