tmapslide 0.2.0__py3-none-any.whl

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,654 @@
1
+ """TMAP (UNIC whole-slide) file format parser — pure Python.
2
+
3
+ Reverse-engineered from real scanner output (TMAP06 / TMAP07 variants).
4
+ All multi-byte fields are little-endian.
5
+
6
+ TMAP07 layout (fully mapped)
7
+ -----------------------------
8
+ - 0x000: magic "TMAP07\\0\\0"; 0x018: u32 level count; 0x01C: u32 tile count
9
+ - associated image descriptors (32B each) in the header region:
10
+ (offset u64, size u64, width u32, height u32, bpp u32, id u32)
11
+ payloads live after the tile data near end-of-file; width/height may be
12
+ zero in the descriptor (real size taken from the JPEG).
13
+ - level descriptor table (32B per level) right before the first record block:
14
+ (objective f32, width u32, height u32, cols u32, rows u32,
15
+ index_offset u32, cumulative_tiles u32, level_no u32)
16
+ - per level a record block at index_offset + 24; 40B per tile:
17
+ (data_offset u64, data_size u64, level_no u32, f1 u32, f2 u32,
18
+ f3 u32, tile_w u32, tile_h u32)
19
+ blank (tissue-free) grid cells are stored as zero records; tiles form a
20
+ complete row-major grid, so coordinates derive from (i % cols, i // cols).
21
+ - tile JPEG data follows the record blocks, in record order.
22
+
23
+ TMAP06 layout
24
+ -------------
25
+ - 0x000: magic "TMAP06\\0\\0", 512-byte header
26
+ - 0x10/0x12: u16 scan area (micrometers, not pixels)
27
+ - 0x18: u16 objective power
28
+ - 0x40: u32 tail length; 0x44: u32 tail start
29
+ - 0x0C8: fixed 308-byte slot array (exactly up to the first JPEG):
30
+ empty slots start with 0xFFFFFFFF; used slots are
31
+ [block_no u32][meta m1 u32][m2 u32 = y tile-row base][m3 u32][17 records]
32
+ record (12B) = (data_offset u32, data_size u32, x | y<<16 u32) where
33
+ x is a pixel column (256-px stride, tiles are 612 px wide) and y is a
34
+ tile row (512-px pitch, 4 rows per slot). Slots with m2/y-base n cover
35
+ global rows n..n+3. Records within a slot chain contiguously.
36
+ - tile JPEG data (612x512) follows the slot array.
37
+ - file tail: label then macro, each [descriptor (pad, w, h, stride, bpp)
38
+ + JPEG], sizes in header 0x88 / 0x8C.
39
+ """
40
+
41
+ import io
42
+ import struct
43
+ from dataclasses import dataclass, field
44
+ from typing import Dict, List, Optional, Tuple
45
+
46
+ from PIL import Image
47
+
48
+ TMAP_MAGIC = b"TMAP"
49
+
50
+ SLOT06_BASE = 0xC8
51
+ SLOT06_STRIDE = 308
52
+ SLOT06_RECORDS = 17
53
+ REC06_SIZE = 12
54
+
55
+ TMAP06_TILE_STRIDE_X = 256
56
+ TMAP06_TILE_PITCH_Y = 512
57
+
58
+ REC07_SIZE = 40
59
+ REC07_HEADER_SKIP = 24
60
+
61
+
62
+ @dataclass
63
+ class TmapTile:
64
+ """One pyramid tile pointing at a JPEG blob in the file."""
65
+
66
+ level: int
67
+ offset: int
68
+ size: int
69
+ x: int
70
+ y: int
71
+ width: int
72
+ height: int
73
+
74
+
75
+ @dataclass
76
+ class TmapLevel:
77
+ """A pyramid level."""
78
+
79
+ index: int
80
+ downsample: float
81
+ width: int
82
+ height: int
83
+ tile_width: int
84
+ tile_height: int
85
+ tiles: List[TmapTile] = field(default_factory=list)
86
+
87
+
88
+ @dataclass
89
+ class TmapAssocImage:
90
+ name: str
91
+ offset: int
92
+ size: int
93
+ width: int
94
+ height: int
95
+ crop: Optional[Tuple[int, int, int, int]] = None
96
+
97
+
98
+ @dataclass
99
+ class TmapFileInfo:
100
+ path: str
101
+ magic: str
102
+ version: int
103
+ levels: List[TmapLevel]
104
+ assoc_images: List[TmapAssocImage]
105
+ tile_count: int
106
+ properties: Dict[str, str] = field(default_factory=dict)
107
+
108
+
109
+ def _read_at(f, offset: int, size: int) -> bytes:
110
+ f.seek(offset)
111
+ return f.read(size)
112
+
113
+
114
+ def _is_jpeg(data: bytes) -> bool:
115
+ return len(data) >= 3 and data[:3] == b"\xff\xd8\xff"
116
+
117
+
118
+ def _jpeg_size(data: bytes) -> Optional[Tuple[int, int]]:
119
+ try:
120
+ with Image.open(io.BytesIO(data)) as img:
121
+ return img.size
122
+ except Exception:
123
+ return None
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # TMAP07
128
+ # ---------------------------------------------------------------------------
129
+
130
+ TMAP07_HEADER_SIZE = 304
131
+ TMAP07_IMAGEINFO_SIZE = 32
132
+ TMAP07_IMAGEINFO_COUNT = 8
133
+ TMAP07_LAYERINFO_BASE = 560
134
+ TMAP07_LAYERINFO_SIZE = 32
135
+ TMAP07_LAYERINFO_COUNT = 16
136
+ TMAP07_REC_SIZE = 40
137
+
138
+ _ASSOC07_NAMES = {
139
+ 0: "thumbnail",
140
+ 1: "navigate",
141
+ 2: "macro",
142
+ 3: "label",
143
+ 4: "preview",
144
+ 5: "extern",
145
+ }
146
+
147
+
148
+ def _find_tile_data_start07(f, file_size: int) -> int:
149
+ """First JPEG SOI at/after the record region -> start of tile data."""
150
+ pos = 0
151
+ step = 1024 * 1024
152
+ while pos < file_size:
153
+ chunk = _read_at(f, pos, min(step, file_size - pos))
154
+ p = chunk.find(b"\xff\xd8\xff")
155
+ if p != -1:
156
+ return pos + p
157
+ pos += len(chunk)
158
+ raise ValueError("TMAP07: no tile data found")
159
+
160
+
161
+ def _parse_assoc07(f, file_size: int, tile_data_end: int) -> List[TmapAssocImage]:
162
+ """Collect TMAP07 associated images.
163
+
164
+ ImageInfo table at offset 304, 8 entries of 32 B:
165
+ width u32, height u32, depth u32, image_type u32,
166
+ offset u64, length u32, pad u32
167
+ image_type: 0=thumbnail, 1=navigate, 2=macro, 3=label, 4=preview,
168
+ 5=extern; type 6 (whole slide) stores no pixels. Assoc payloads live
169
+ after the tile data near end-of-file.
170
+ """
171
+ out: List[TmapAssocImage] = []
172
+ raw = _read_at(f, TMAP07_HEADER_SIZE, TMAP07_IMAGEINFO_SIZE * TMAP07_IMAGEINFO_COUNT)
173
+ for k in range(TMAP07_IMAGEINFO_COUNT):
174
+ base = k * TMAP07_IMAGEINFO_SIZE
175
+ w, h, _depth, img_type = struct.unpack_from("<IIII", raw, base)
176
+ offset, length = struct.unpack_from("<QI", raw, base + 16)
177
+ if length == 0 or offset == 0:
178
+ continue
179
+ if offset < tile_data_end or offset + length > file_size:
180
+ continue
181
+ if not _is_jpeg(_read_at(f, offset, 3)):
182
+ continue
183
+ if w == 0 or h == 0:
184
+ dims = _jpeg_size(_read_at(f, offset, min(length, 8192)))
185
+ if dims:
186
+ w, h = dims
187
+ name = _ASSOC07_NAMES.get(img_type, f"assoc{img_type}")
188
+ if any(img.name == name for img in out):
189
+ name = f"{name}_{k}"
190
+ out.append(
191
+ TmapAssocImage(name=name, offset=offset, size=length, width=w, height=h)
192
+ )
193
+ # Provide stable macro/label aliases expected by OpenSlide-style clients.
194
+ _ensure_alias(out, "macro", wide=True)
195
+ _ensure_alias(out, "label", wide=False)
196
+ return out
197
+
198
+
199
+ def _ensure_alias(images: List[TmapAssocImage], name: str, wide: bool) -> None:
200
+ if any(img.name == name for img in images):
201
+ return
202
+ candidates = [i for i in images if i.width > 0 and i.height > 0]
203
+ if not candidates:
204
+ return
205
+ if wide:
206
+ best = max(candidates, key=lambda i: i.width / max(1, i.height))
207
+ else:
208
+ best = min(candidates, key=lambda i: i.width / max(1, i.height))
209
+ images.append(TmapAssocImage(name, best.offset, best.size, best.width, best.height))
210
+
211
+
212
+ def _parse_layer_table07(f, base: int = TMAP07_LAYERINFO_BASE) -> List[dict]:
213
+ """Parse the 32 B LayerInfo table for TMAP07.
214
+
215
+ Entry: layer_id u32, scale f32, width u32, height u32,
216
+ tile_row u32, tile_col u32, offset u32, tile_start u32.
217
+ tile_row is the vertical tile count and tile_col the horizontal one.
218
+ """
219
+ raw = _read_at(f, base, TMAP07_LAYERINFO_COUNT * TMAP07_LAYERINFO_SIZE)
220
+ entries = []
221
+ for k in range(TMAP07_LAYERINFO_COUNT):
222
+ layer_id, scale_f, w, h, tile_row, tile_col, offset, tile_start = (
223
+ struct.unpack_from("<IfIIIIII", raw, k * TMAP07_LAYERINFO_SIZE)
224
+ )
225
+ if w == 0 or h == 0:
226
+ continue
227
+ entries.append(
228
+ dict(
229
+ order=k,
230
+ layer_id=layer_id,
231
+ scale=scale_f,
232
+ width=w,
233
+ height=h,
234
+ tile_row=tile_row,
235
+ tile_col=tile_col,
236
+ index_offset=offset,
237
+ tile_start=tile_start,
238
+ )
239
+ )
240
+ return entries
241
+
242
+
243
+ def _parse_records07(f, entry: dict, file_size: int) -> List[Optional[tuple]]:
244
+ """Read one level's tile table.
245
+
246
+ Record (40 B), indexed by (row * tile_col + col):
247
+ layer_no u32, focus_no u32, x u32, y u32, w u32, h u32,
248
+ data_offset u64, data_length u32, pad u32
249
+ Blank cells store offset == 0.
250
+ """
251
+ cols, rows = entry["tile_col"], entry["tile_row"]
252
+ total = cols * rows
253
+ raw = _read_at(f, entry["index_offset"], total * TMAP07_REC_SIZE + 64)
254
+ recs: List[Optional[tuple]] = []
255
+ for i in range(total):
256
+ base = i * TMAP07_REC_SIZE
257
+ if base + TMAP07_REC_SIZE > len(raw):
258
+ break
259
+ layer_no, focus_no, x, y, w, h, off, size = struct.unpack_from(
260
+ "<IIIIIIQI", raw, base
261
+ )
262
+ if off == 0 and size == 0:
263
+ recs.append(None)
264
+ continue
265
+ if size == 0 or off + size > file_size:
266
+ break
267
+ recs.append((off, size, layer_no, focus_no, x, y, w, h))
268
+ return recs
269
+
270
+
271
+ def _parse_tmap07(path: str) -> TmapFileInfo:
272
+ with open(path, "rb") as f:
273
+ f.seek(0, 2)
274
+ file_size = f.tell()
275
+ head = _read_at(f, 0, TMAP07_HEADER_SIZE)
276
+ image_format, jpeg_quality = struct.unpack_from("<BB", head, 8)
277
+ focus_nums = struct.unpack_from("<B", head, 10)[0] or 1
278
+ scan_scale = struct.unpack_from("<B", head, 11)[0]
279
+ bkg_color = head[12]
280
+ pixel_size = struct.unpack_from("<f", head, 0x10)[0]
281
+ declared_image_num = struct.unpack_from("<I", head, 0x14)[0]
282
+ layer_num = struct.unpack_from("<I", head, 0x18)[0]
283
+ declared_tiles = struct.unpack_from("<I", head, 0x1C)[0]
284
+ magic = head[:6].decode("ascii", errors="replace")
285
+
286
+ data_start = _find_tile_data_start07(f, file_size)
287
+
288
+ # TMAP07 LayerInfo table at 560.
289
+ entries = _parse_layer_table07(f)
290
+ if not entries:
291
+ raise ValueError("TMAP07: level descriptor table not found")
292
+
293
+ levels: List[TmapLevel] = []
294
+ prop_layer_info = []
295
+ for order, e in enumerate(entries):
296
+ real = _parse_records07(f, e, file_size)
297
+ real = [r for r in real if r is not None]
298
+ if not real:
299
+ continue
300
+ sample = real[0]
301
+ tw = sample[6] if 16 <= sample[6] <= 4096 else 256
302
+ th = sample[7] if 16 <= sample[7] <= 4096 else 256
303
+ lvl = TmapLevel(
304
+ index=order,
305
+ downsample=float(2**order),
306
+ width=e["width"],
307
+ height=e["height"],
308
+ tile_width=tw,
309
+ tile_height=th,
310
+ )
311
+ for r in real:
312
+ off, size = r[0], r[1]
313
+ x, y = r[4], r[5]
314
+ lvl.tiles.append(
315
+ TmapTile(
316
+ level=order, offset=off, size=size, x=x, y=y,
317
+ width=tw, height=th,
318
+ )
319
+ )
320
+ levels.append(lvl)
321
+ prop_layer_info.append(
322
+ f"{e['width']}x{e['height']}:{e['tile_col']}x{e['tile_row']}"
323
+ )
324
+
325
+ if not levels:
326
+ raise ValueError("TMAP07: no tile records parsed")
327
+
328
+ tile_data_end = max(t.offset + t.size for l in levels for t in l.tiles)
329
+ assoc = _parse_assoc07(f, file_size, tile_data_end)
330
+
331
+ return TmapFileInfo(
332
+ path=path,
333
+ magic=magic,
334
+ version=7,
335
+ levels=levels,
336
+ assoc_images=assoc,
337
+ tile_count=sum(len(l.tiles) for l in levels),
338
+ properties={
339
+ "tmap.version": "07",
340
+ "tmap.image_format": str(image_format),
341
+ "tmap.jpeg_quality": str(jpeg_quality),
342
+ "tmap.focus_nums": str(focus_nums),
343
+ "tmap.scan_scale": str(scan_scale),
344
+ "tmap.bkg_color": str(bkg_color),
345
+ "tmap.pixel_size_mm": repr(pixel_size),
346
+ "tmap.declared_image_num": str(declared_image_num),
347
+ "tmap.layer_num": str(layer_num),
348
+ "tmap.declared_tiles": str(declared_tiles),
349
+ "tmap.layer_infos": ",".join(prop_layer_info),
350
+ "tmap.objective_power": str(scan_scale),
351
+ },
352
+ )
353
+
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # TMAP06
358
+ def _parse_tmap06(path: str) -> TmapFileInfo:
359
+ """Parse TMAP06.
360
+
361
+ File structure (little-endian):
362
+ 0x000 magic "TMAP06"; 0x006 focus_nums u8; 0x007 image_format u8;
363
+ 0x008 file_num u8; 0x009 layer_num u8; 0x00A img_color u8;
364
+ 0x00B checksum u8; 0x00C ratio_step u8; 0x00D max_lay_num u8;
365
+ 0x00E slide_type u8; 0x00F bkg_color u8; 0x010 pixel_size f32 (mm);
366
+ 0x014 image_num u32 (= LayerInfo count); 0x018 scan_scale u16;
367
+ 0x01A img_col u16; 0x01C img_row u16; 0x01E img_width u16;
368
+ 0x020 img_height u16; 0x022 tile_width u16; 0x024 tile_height u16;
369
+ 0x026 air_img_width u16; 0x028 air_img_height u16;
370
+ 0x02A version_minor u8; 0x02B save_scale u8;
371
+ 0x02C shrink_tile_num u32; 0x030 width u32; 0x034 height u32;
372
+ 0x038 air_img_offset u32
373
+ 0x03C ExtInfo: lMaxExtDataLen i32, lSumExtDataLen i32,
374
+ lTMAPDataEndPos i32, 8 x (ext_type i32, offset i32, length i32),
375
+ 24 reserved -> ends at 192
376
+ 192 LayerInfo[image_num], 308 B each:
377
+ file_id u8, layer u8, pad u16, top_dx u8, top_dy u8,
378
+ left_dx u8, left_dy u8, n_img_col u16, n_img_row u16,
379
+ n_x i32, n_y i32, 24 x TileInfo
380
+ TileInfo = layer_no u8, col u8, row u8, pad u8,
381
+ offset u32, length u32
382
+ empty blocks have n_img_col == n_img_row == -1
383
+ then ShrinkTileInfo[shrink_tile_num], 20 B each:
384
+ file_id u8, layer_no u8, pad u16, n_x i32, n_y i32,
385
+ offset u32, length u32
386
+ then tile JPEGs, packed.
387
+
388
+ A block (n_img_col, n_img_row) is drawn at (n_x, n_y) and spans
389
+ img_width x img_height pixels. Its layer-0 tiles form a 4x4 grid of
390
+ tile_width x tile_height JPEGs, edge to edge. Layer 1 holds one tile
391
+ covering the whole block (downsampled by ratio_step).
392
+ """
393
+ with open(path, "rb") as f:
394
+ f.seek(0, 2)
395
+ file_size = f.tell()
396
+ magic_raw = _read_at(f, 0, 0xC0)
397
+ (focus_nums, image_format, file_num, layer_num, _img_color,
398
+ _checksum, ratio_step, _max_lay_num, _slide_type,
399
+ bkg_color) = struct.unpack_from("<10B", magic_raw, 6)
400
+ pixel_size = struct.unpack_from("<f", magic_raw, 0x10)[0]
401
+ image_num, scan_scale, img_col, img_row, img_w, img_h, tile_w, tile_h = (
402
+ struct.unpack_from("<IHHHHHHH", magic_raw, 0x14)
403
+ )
404
+ shrink_tile_num, width, height = struct.unpack_from("<III", magic_raw, 0x2C)
405
+ if ratio_step < 2 or ratio_step > 4:
406
+ ratio_step = 2
407
+ if pixel_size < 1e-8:
408
+ pixel_size = 1e-4
409
+ if width <= 0 or height <= 0:
410
+ raise ValueError("TMAP06: invalid canvas dimensions")
411
+
412
+ # ExtInfo at 0x3C: assoc images as (type, offset, length) triplets.
413
+ # ext type 1 = combined macro+label image, 2 = thumbnail.
414
+ assoc: List[TmapAssocImage] = []
415
+ ext_base = 0x3C + 12
416
+ for i in range(8):
417
+ etype, eoff, elen = struct.unpack_from(
418
+ "<iii", _read_at(f, ext_base + i * 12, 12), 0
419
+ )
420
+ if etype not in (1, 2) or eoff <= 0 or elen <= 0 or eoff + elen > file_size:
421
+ continue
422
+ if not _is_jpeg(_read_at(f, eoff, 3)):
423
+ continue
424
+ blob = _read_at(f, eoff, min(elen, 65536))
425
+ dims = _jpeg_size(blob) or (0, 0)
426
+ if etype == 2:
427
+ assoc.append(
428
+ TmapAssocImage("thumbnail", eoff, elen, dims[0], dims[1])
429
+ )
430
+ continue
431
+ # Type 1: combined image, label = left 1/3, macro = right 2/3.
432
+ w, h = dims
433
+ assoc.append(
434
+ TmapAssocImage("label", eoff, elen, max(1, w // 3), h)
435
+ )
436
+ assoc.append(
437
+ TmapAssocImage(
438
+ "macro",
439
+ eoff,
440
+ elen,
441
+ w - max(1, w // 3),
442
+ h,
443
+ )
444
+ )
445
+
446
+ # Many TMAP06 files store assoc images in the file tail:
447
+ # block 1 = thumbnail (e.g. 256 x 228), block 2 = combined
448
+ # macro+label (label = left 1/3, macro = right 2/3), each as
449
+ # [24B descriptor (pad, w, h, stride, bpp, pad) + JPEG]; block sizes
450
+ # are in header 0x88 / 0x8C and the tail start in header 0x44
451
+ # (= lTMAPDataEndPos).
452
+ if not assoc:
453
+ tail_start = struct.unpack_from("<I", magic_raw, 0x44)[0]
454
+ tail_len = struct.unpack_from("<I", magic_raw, 0x40)[0]
455
+ s1 = struct.unpack_from("<I", magic_raw, 0x88)[0]
456
+ s2 = struct.unpack_from("<I", magic_raw, 0x8C)[0]
457
+ if (
458
+ 0 < tail_start < file_size
459
+ and 0 < tail_len <= file_size - tail_start
460
+ and 24 < s1 < tail_len
461
+ and 24 < s2 <= tail_len - s1
462
+ ):
463
+ for name, desc_off, jpeg_len in (
464
+ ("thumbnail", tail_start, s1 - 24),
465
+ ("macro_label", tail_start + s1, s2 - 24),
466
+ ):
467
+ desc = _read_at(f, desc_off, 24)
468
+ _pad, w, h, _stride, _bpp, _extra = struct.unpack_from(
469
+ "<IIIIII", desc, 0
470
+ )
471
+ jpeg_off = desc_off + 24
472
+ if not (0 < jpeg_len <= file_size - jpeg_off):
473
+ continue
474
+ if not _is_jpeg(_read_at(f, jpeg_off, 3)):
475
+ continue
476
+ if w == 0 or h == 0:
477
+ dims = _jpeg_size(
478
+ _read_at(f, jpeg_off, min(jpeg_len, 8192))
479
+ ) or (0, 0)
480
+ w, h = dims
481
+ if name == "macro_label" and w >= 3:
482
+ third = w // 3
483
+ assoc.append(
484
+ TmapAssocImage(
485
+ name="label",
486
+ offset=jpeg_off,
487
+ size=jpeg_len,
488
+ width=third,
489
+ height=h,
490
+ crop=(0, 0, third, h),
491
+ )
492
+ )
493
+ name = "macro"
494
+ assoc.append(
495
+ TmapAssocImage(
496
+ name=name,
497
+ offset=jpeg_off,
498
+ size=jpeg_len,
499
+ width=w - third,
500
+ height=h,
501
+ crop=(third, 0, w, h),
502
+ )
503
+ )
504
+ continue
505
+ assoc.append(
506
+ TmapAssocImage(
507
+ name=name, offset=jpeg_off, size=jpeg_len, width=w, height=h
508
+ )
509
+ )
510
+
511
+ # LayerInfo table at 192.
512
+ li_base = 192
513
+ li_raw = _read_at(f, li_base, min(image_num, 100000) * 308)
514
+ blocks = {} # (n_img_col, n_img_row) -> layer info dict
515
+ for k in range(min(image_num, len(li_raw) // 308)):
516
+ base = k * 308
517
+ n_col, n_row = struct.unpack_from("<HH", li_raw, base + 8)
518
+ if n_col == 0xFFFF and n_row == 0xFFFF:
519
+ continue
520
+ n_x, n_y = struct.unpack_from("<ii", li_raw, base + 12)
521
+ tiles = []
522
+ for t in range(24):
523
+ tb = base + 20 + t * 12
524
+ layer_no, col, row = struct.unpack_from("<BBB", li_raw, tb)
525
+ off, length = struct.unpack_from("<II", li_raw, tb + 4)
526
+ if off > 0 and length > 0 and off + length <= file_size:
527
+ 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])
529
+
530
+ # ShrinkTileInfo right after the LayerInfo table.
531
+ st_base = li_base + image_num * 308
532
+ shrinks = []
533
+ st_raw = _read_at(f, st_base, min(shrink_tile_num, 100000) * 20)
534
+ for k in range(min(shrink_tile_num, len(st_raw) // 20)):
535
+ base = k * 20
536
+ _file_id, layer_no = struct.unpack_from("<BB", st_raw, base)
537
+ 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))
540
+
541
+ # Pyramid levels: ratio_step division from the level-0 canvas.
542
+ levels_meta = []
543
+ w, h = width, height
544
+ scale = scan_scale
545
+ while scale >= 1 and w > 0 and h > 0:
546
+ levels_meta.append((w, h))
547
+ w = (w + ratio_step - 1) // ratio_step
548
+ h = (h + ratio_step - 1) // ratio_step
549
+ scale = scale // ratio_step
550
+
551
+ def _tile_img(off: int, length: int) -> Optional[Image.Image]:
552
+ data = _read_at(f, off, length)
553
+ try:
554
+ return Image.open(io.BytesIO(data))
555
+ except Exception:
556
+ return None
557
+
558
+ # Determine layer-0 tile geometry from the first available tile.
559
+ tw = th = None
560
+ for info in blocks.values():
561
+ for layer_no, col, row, off, length in info["tiles"]:
562
+ if layer_no == 0:
563
+ im = _tile_img(off, length)
564
+ if im is not None:
565
+ tw, th = im.size
566
+ break
567
+ if tw:
568
+ break
569
+ if tw is None:
570
+ tw, th = tile_w, tile_h
571
+
572
+ # Build one TmapLevel per pyramid level. Level 0/1 tile lists are
573
+ # populated so iter_tiles()/caching can address individual tiles.
574
+ levels: List[TmapLevel] = []
575
+ for order, (lw, lh) in enumerate(levels_meta):
576
+ lvl = TmapLevel(
577
+ index=order,
578
+ downsample=float(ratio_step**order),
579
+ width=lw,
580
+ height=lh,
581
+ tile_width=tw * (ratio_step**order if order > 1 else 1),
582
+ tile_height=th * (ratio_step**order if order > 1 else 1),
583
+ )
584
+ levels.append(lvl)
585
+
586
+ for (n_col, n_row), blk in blocks.items():
587
+ n_x, n_y = blk["n_x"], blk["n_y"]
588
+ for layer_no, col, row, off, length in blk["tiles"]:
589
+ if layer_no == 0:
590
+ levels[0].tiles.append(
591
+ TmapTile(
592
+ level=0,
593
+ offset=off,
594
+ size=length,
595
+ x=n_x + col * tw,
596
+ y=n_y + row * th,
597
+ width=tw,
598
+ height=th,
599
+ )
600
+ )
601
+ elif layer_no == 1 and len(levels) > 1:
602
+ levels[1].tiles.append(
603
+ TmapTile(
604
+ level=1,
605
+ offset=off,
606
+ size=length,
607
+ x=n_x // ratio_step,
608
+ y=n_y // ratio_step,
609
+ width=tw,
610
+ height=th,
611
+ )
612
+ )
613
+
614
+ info = TmapFileInfo(
615
+ path=path,
616
+ magic=magic_raw[:6].decode("ascii", errors="replace"),
617
+ version=6,
618
+ levels=levels,
619
+ assoc_images=assoc,
620
+ tile_count=sum(len(v["tiles"]) for v in blocks.values()),
621
+ properties={
622
+ "tmap.version": "06",
623
+ "tmap.objective_power": str(scan_scale),
624
+ "tmap.pixel_size_mm": repr(pixel_size),
625
+ "tmap.img_col": str(img_col),
626
+ "tmap.img_row": str(img_row),
627
+ "tmap.img_width": str(img_w),
628
+ "tmap.img_height": str(img_h),
629
+ "tmap.tile_width": str(tile_w),
630
+ "tmap.tile_height": str(tile_h),
631
+ "tmap.blocks": str(len(blocks)),
632
+ "tmap.shrink_tiles": str(len(shrinks)),
633
+ "tmap.bkg_color": str(bkg_color),
634
+ },
635
+ # extra fields for read_region
636
+ )
637
+ info.__dict__["_blocks"] = blocks
638
+ info.__dict__["_shrinks"] = shrinks
639
+ info.__dict__["_ratio_step"] = ratio_step
640
+ info.__dict__["_tile_jpeg_size"] = (tw, th)
641
+ return info
642
+
643
+
644
+ def parse_tmap_file(path: str) -> TmapFileInfo:
645
+ """Parse a TMAP file and return structured info."""
646
+ with open(path, "rb") as f:
647
+ magic = f.read(8)
648
+ if len(magic) < 6 or magic[:4] != TMAP_MAGIC:
649
+ raise ValueError(f"Not a TMAP file: magic={magic[:8]!r}")
650
+ if magic[:6] == b"TMAP06":
651
+ return _parse_tmap06(path)
652
+ if magic[:6] == b"TMAP07":
653
+ return _parse_tmap07(path)
654
+ raise ValueError(f"Unsupported TMAP version: {magic[:6]!r}")