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.
- tmapslide/__init__.py +56 -0
- tmapslide/_cache.py +37 -0
- tmapslide/_exceptions.py +19 -0
- tmapslide/_slide.py +553 -0
- tmapslide/_tmapformat.py +654 -0
- tmapslide-0.2.0.dist-info/METADATA +142 -0
- tmapslide-0.2.0.dist-info/RECORD +9 -0
- tmapslide-0.2.0.dist-info/WHEEL +4 -0
- tmapslide-0.2.0.dist-info/licenses/LICENSE +21 -0
tmapslide/_tmapformat.py
ADDED
|
@@ -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}")
|