tsio 1.0.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.
tsio/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __app_name__: str = "tsio"
tsio/cli.py ADDED
@@ -0,0 +1,618 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import cv2
4
+ import filetype
5
+ import importlib.metadata
6
+ import logging
7
+ import numpy as np
8
+ import os
9
+ import platform
10
+ import typer
11
+
12
+ from enum import Enum
13
+ from filetype.types.image import Dcm, Jpeg, Png, Tiff
14
+ from multiprocess.pool import Pool
15
+ from pathlib import Path
16
+ from pydantic import BaseModel, model_validator, ValidationError
17
+ from pydicom import dcmread, iter_pixels
18
+ from rich import print
19
+ from rsciio import digitalmicrograph, emd
20
+ from rsciio.image import (
21
+ file_reader as image_file_reader,
22
+ file_writer as image_file_writer,
23
+ )
24
+ from rsciio.tiff import file_reader as tiff_file_reader
25
+ from rsciio.utils import rgb
26
+ from tqdm import tqdm
27
+ from tsio import __app_name__
28
+ from typing import Any, Literal
29
+ from typing_extensions import Self
30
+
31
+ LOGGER: logging.Logger = logging.getLogger(__name__)
32
+
33
+
34
+ class UnsupportedFileType(Exception):
35
+ def __init__(self, src: Path):
36
+ self.src = src
37
+
38
+
39
+ class Dm3(filetype.Type):
40
+ MIME = "application/vnd.gatan.dm3"
41
+ EXTENSION = ".dm3"
42
+
43
+ def __init__(self):
44
+ super(Dm3, self).__init__(mime=Dm3.MIME, extension=Dm3.EXTENSION)
45
+
46
+ def match(self, buf) -> bool:
47
+ # First 4 bytes are version number = 3
48
+ # Next 4 bytes are the file size
49
+ # Last 4 bytes are "endian"
50
+ LOGGER.debug(f"{buf[:18]=}")
51
+ if len(buf) < 17:
52
+ return False
53
+ version_number = int.from_bytes(buf[0:4], byteorder="big")
54
+ LOGGER.debug(f"{version_number=}")
55
+ if version_number == 3:
56
+ file_size = int.from_bytes(buf[4:8], byteorder="big")
57
+ LOGGER.debug(f"{file_size=}")
58
+ if file_size <= 18:
59
+ return False
60
+ little_endian_int = int.from_bytes(buf[8:12], byteorder="big")
61
+ if little_endian_int != 0 and little_endian_int != 1:
62
+ return False
63
+ else:
64
+ is_sorted = bool(buf[12])
65
+ LOGGER.debug(f"{is_sorted=}")
66
+ is_open = buf[13]
67
+ LOGGER.debug(f"{is_open=}")
68
+ tags_count = int.from_bytes(buf[14:18], byteorder="big")
69
+ LOGGER.debug(f"{tags_count=}")
70
+ return tags_count > 0
71
+ else:
72
+ return False
73
+
74
+
75
+ class Dm4(filetype.Type):
76
+ MIME = "application/vnd.gatan.dm4"
77
+ EXTENSION = ".dm4"
78
+
79
+ def __init__(self):
80
+ super(Dm4, self).__init__(mime=Dm4.MIME, extension=Dm4.EXTENSION)
81
+
82
+ def match(self, buf) -> bool:
83
+ # First 4 bytes are version number = 4
84
+ # Next 8 bytes are the file size
85
+ # Last 4 bytes are "endian"
86
+ LOGGER.debug(f"{buf[:20]=}")
87
+ if len(buf) < 20:
88
+ return False
89
+ version_number = int.from_bytes(buf[0:4], byteorder="big")
90
+ LOGGER.debug(f"{version_number=}")
91
+ if version_number == 4:
92
+ file_size = int.from_bytes(buf[4:12], byteorder="big")
93
+ LOGGER.debug(f"{file_size=}")
94
+ if file_size <= 20:
95
+ return False
96
+ little_endian_int = int.from_bytes(buf[12:16], byteorder="big")
97
+ LOGGER.debug(f"{little_endian_int=}")
98
+ if little_endian_int != 0 and little_endian_int != 1:
99
+ return False
100
+ else:
101
+ is_sorted = bool(buf[12])
102
+ LOGGER.debug(f"{is_sorted=}")
103
+ is_open = buf[13]
104
+ LOGGER.debug(f"{is_open=}")
105
+ tags_count = int.from_bytes(buf[14:18], byteorder="big")
106
+ LOGGER.debug(f"{tags_count=}")
107
+ return tags_count > 0
108
+ else:
109
+ return False
110
+
111
+
112
+ class Emd(filetype.Type):
113
+ MIME = "application/vnd.velox.emd"
114
+ EXTENSION = ".emd"
115
+
116
+ def __init__(self):
117
+ super(Emd, self).__init__(mime=Emd.MIME, extension=Emd.EXTENSION)
118
+
119
+ def match(self, buf) -> bool:
120
+ # Velox EMD is a HDF5 file.
121
+ return (
122
+ len(buf) > 7
123
+ and buf[0] == 0x89
124
+ and buf[1] == 0x48
125
+ and buf[2] == 0x44
126
+ and buf[3] == 0x46
127
+ and buf[4] == 0x0D
128
+ and buf[5] == 0x0A
129
+ and buf[6] == 0x1A
130
+ and buf[7] == 0x0A
131
+ )
132
+
133
+
134
+ filetype.add_type(Dm3())
135
+ filetype.add_type(Dm4())
136
+ filetype.add_type(Emd())
137
+
138
+
139
+ logging.getLogger("PIL.Image").setLevel(logging.WARNING)
140
+
141
+ app = typer.Typer(pretty_exceptions_show_locals=False)
142
+
143
+
144
+ class BitDepths(Enum):
145
+ EIGHT = 8
146
+ SIXTEEN = 16
147
+
148
+ @property
149
+ def type(self) -> str:
150
+ TYPE_MAP = {BitDepths.EIGHT: "uint8", BitDepths.SIXTEEN: "uint16"}
151
+ return TYPE_MAP[self]
152
+
153
+ @property
154
+ def max_pixel_intensity(self) -> int:
155
+ MAX_MAP = {BitDepths.EIGHT: 255, BitDepths.SIXTEEN: 65535}
156
+ return MAX_MAP[self]
157
+
158
+
159
+ class ToFormats(Enum):
160
+ JPEG = "jpeg"
161
+ PNG = "png"
162
+ TIFF = "tiff"
163
+
164
+ @property
165
+ def mime_type(self) -> str:
166
+ MIME_TYPES = {
167
+ ToFormats.JPEG: Jpeg.MIME,
168
+ ToFormats.PNG: Png.MIME,
169
+ ToFormats.TIFF: Tiff.MIME,
170
+ }
171
+ return MIME_TYPES[self]
172
+
173
+ @property
174
+ def ext(self) -> str:
175
+ EXTS = {
176
+ ToFormats.JPEG: "." + Jpeg.EXTENSION,
177
+ ToFormats.PNG: "." + Png.EXTENSION,
178
+ ToFormats.TIFF: "." + Tiff.EXTENSION,
179
+ }
180
+ return EXTS[self]
181
+
182
+ @property
183
+ def is_alpha_supported(self) -> bool:
184
+ return self != ToFormats.JPEG
185
+
186
+ @property
187
+ def is_gray_supported(self) -> bool:
188
+ return self != ToFormats.JPEG
189
+
190
+
191
+ class Output(BaseModel):
192
+ bit_depth: BitDepths
193
+ path: Path | None
194
+ format: ToFormats
195
+
196
+ @staticmethod
197
+ def is_gray(img: np.ndarray) -> bool:
198
+ return len(img.shape) == 2
199
+
200
+ @staticmethod
201
+ def is_rgb(img: np.ndarray) -> bool:
202
+ return len(img.shape) == 3
203
+
204
+ @staticmethod
205
+ def is_rgba(img: np.ndarray) -> bool:
206
+ return Output.is_rgb(img) and img.shape[2] == 4
207
+
208
+ @staticmethod
209
+ def normalize(img: np.ndarray) -> np.ndarray:
210
+ max_pixel_intensity = int(np.max(img))
211
+ LOGGER.debug(f"{max_pixel_intensity=}")
212
+ min_pixel_intensity = int(np.min(img))
213
+ if max_pixel_intensity == min_pixel_intensity:
214
+ min_pixel_intensity = 0
215
+ LOGGER.debug(f"{min_pixel_intensity=}")
216
+ normalization_factor = abs(max_pixel_intensity - min_pixel_intensity)
217
+ LOGGER.debug(f"{normalization_factor=}")
218
+ if normalization_factor > 0:
219
+ return ((img - min_pixel_intensity) / normalization_factor).astype(
220
+ np.float32
221
+ )
222
+ else:
223
+ return img.astype(np.float32)
224
+
225
+ def convert(self, img: np.ndarray) -> np.ndarray:
226
+ if Output.is_gray(img) and not self.format.is_gray_supported:
227
+ return cv2.cvtColor(
228
+ img,
229
+ cv2.COLOR_GRAY2RGB,
230
+ )
231
+ elif Output.is_rgba(img) and not self.format.is_alpha_supported:
232
+ return cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)
233
+ else:
234
+ return img
235
+
236
+ def cast(self, img: np.ndarray) -> np.ndarray:
237
+ rgbx_or_gray_img = rgb.rgbx2regular_array(img, show_progressbar=False)
238
+ rgbx_or_gray_float_img = Output.normalize(rgbx_or_gray_img)
239
+ rgbx_or_gray_int_img = self.scale(rgbx_or_gray_float_img)
240
+ return self.convert(rgbx_or_gray_int_img)
241
+
242
+ def destination(self, src: Path) -> Path:
243
+ return src.resolve().parent if self.path is None else self.path
244
+
245
+ def scale(self, img: np.ndarray) -> np.ndarray:
246
+ return np.round(img * self.bit_depth.max_pixel_intensity).astype(
247
+ self.bit_depth.type
248
+ )
249
+
250
+ @model_validator(mode="after")
251
+ def check_supported_bit_depth(self) -> Self:
252
+ SUPPORTED_MAP = {
253
+ ToFormats.JPEG: [BitDepths.EIGHT],
254
+ ToFormats.PNG: [BitDepths.EIGHT, BitDepths.SIXTEEN],
255
+ ToFormats.TIFF: [BitDepths.EIGHT, BitDepths.SIXTEEN],
256
+ }
257
+ if self.bit_depth in SUPPORTED_MAP[self.format]:
258
+ return self
259
+ else:
260
+ raise ValueError(
261
+ (
262
+ f"The {self.bit_depth.value}-bit depth is not supported "
263
+ f"for the {self.format.value.upper()} output format."
264
+ )
265
+ )
266
+
267
+
268
+ class Configuration(BaseModel):
269
+ delete_original: bool
270
+ extras: dict[str, Any] | None
271
+ output: Output
272
+ silent: bool
273
+ src: Path
274
+
275
+
276
+ def print_validation_error(err: ValidationError):
277
+ for e in err.errors():
278
+ msg = e["msg"].removeprefix("Value ")
279
+ print(f"\n[bold red]ERROR![/bold red] {msg}")
280
+
281
+
282
+ def map_verbosity(count: int) -> str:
283
+ log_level = "INFO"
284
+ if count >= 1:
285
+ log_level = "DEBUG"
286
+ if count >= 2:
287
+ logging.getLogger("rsciio").setLevel(logging.INFO)
288
+ if count >= 3:
289
+ logging.getLogger("rsciio").setLevel(logging.DEBUG)
290
+ return log_level
291
+
292
+
293
+ def version_callback(value: bool):
294
+ if value:
295
+ version = importlib.metadata.version(__app_name__)
296
+ print(f"{__app_name__} {version}")
297
+ raise typer.Exit()
298
+
299
+
300
+ def write(
301
+ pages: list[dict[str, Any]],
302
+ src: Path,
303
+ output: Output,
304
+ silent: bool,
305
+ delete_original: bool = False,
306
+ ):
307
+ LOGGER.debug(f"{src=}")
308
+ LOGGER.debug(f"{output=}")
309
+ LOGGER.debug(f"{silent=}")
310
+ LOGGER.debug(f"{delete_original=}")
311
+ destination = output.destination(src)
312
+ pages_count = len(pages)
313
+ LOGGER.debug(f"{pages_count=}")
314
+ src_file_stem = src.stem
315
+ if pages_count > 1:
316
+ destination = destination.joinpath(src_file_stem)
317
+ os.makedirs(destination, exist_ok=True)
318
+ LOGGER.debug(f"{src_file_stem=}")
319
+ for page_index, page in enumerate(
320
+ tqdm(
321
+ pages,
322
+ total=pages_count,
323
+ desc=src.name,
324
+ disable=silent or pages_count == 1,
325
+ bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}",
326
+ )
327
+ ):
328
+ LOGGER.debug(f"{page_index=}")
329
+ if pages_count > 1:
330
+ output_file = destination.joinpath(str(page_index)).with_suffix(
331
+ output.format.ext
332
+ )
333
+ else:
334
+ output_file = destination.joinpath(src_file_stem).with_suffix(
335
+ output.format.ext
336
+ )
337
+ LOGGER.debug(f"{output_file=}")
338
+ page["data"] = output.cast(page["data"])
339
+ for axis in page["axes"]:
340
+ if "navigate" not in axis:
341
+ axis["navigate"] = None
342
+ image_file_writer(output_file, page)
343
+ if delete_original:
344
+ src.unlink(missing_ok=True)
345
+
346
+
347
+ def run_dcm(cfg: Configuration):
348
+ LOGGER.debug(f"{cfg=}")
349
+ write(
350
+ [
351
+ {
352
+ "data": img,
353
+ "axes": [],
354
+ "index_in_array": None,
355
+ "metadata": {},
356
+ "original_metadata": {},
357
+ }
358
+ for img in iter_pixels(dcmread(cfg.src))
359
+ ],
360
+ cfg.src,
361
+ cfg.output,
362
+ cfg.silent,
363
+ delete_original=cfg.delete_original,
364
+ )
365
+
366
+
367
+ def run_dm(cfg: Configuration):
368
+ LOGGER.debug(f"{cfg=}")
369
+ try:
370
+ write(
371
+ digitalmicrograph.file_reader(cfg.src),
372
+ cfg.src,
373
+ cfg.output,
374
+ cfg.silent,
375
+ delete_original=cfg.delete_original,
376
+ )
377
+ except NotImplementedError as error:
378
+ LOGGER.warning(f"Skipped '{cfg.src}' because: '{str(error)}'")
379
+ except Exception as error:
380
+ LOGGER.error(f"Skipped '{cfg.src}' because: '{str(error)}'")
381
+
382
+
383
+ def run_emd(cfg: Configuration):
384
+ LOGGER.debug(f"{cfg=}")
385
+ if cfg.extras is None:
386
+ detector = 0
387
+ else:
388
+ detector = cfg.extras.get("detector", 0)
389
+ LOGGER.debug(f"{detector=}")
390
+ try:
391
+ emd_data = emd.file_reader(cfg.src, lazy=True, select_type="images")
392
+ LOGGER.debug(f"{emd_data=}")
393
+ LOGGER.debug(f"{len(emd_data)=}")
394
+ if len(emd_data) == 0:
395
+ raise Exception("No image data")
396
+ if "data" not in emd_data[detector]:
397
+ raise Exception("No data field in EMD file")
398
+ dask_data = emd_data[detector]["data"]
399
+ LOGGER.debug(f"{dask_data=}")
400
+ data = dask_data.compute(close_file=True)
401
+ LOGGER.debug(f"{data.shape=}")
402
+ if len(data.shape) == 2:
403
+ pages_count = 1
404
+ pages = [{"data": data, "axes": emd_data[detector]["axes"]}]
405
+ else:
406
+ pages_count = data.shape[0]
407
+ LOGGER.debug(f"{pages_count=}")
408
+ pages = [
409
+ {"data": data[i, ...], "axes": emd_data[detector]["axes"]}
410
+ for i in range(pages_count)
411
+ ]
412
+ write(
413
+ pages,
414
+ cfg.src,
415
+ cfg.output,
416
+ cfg.silent,
417
+ delete_original=cfg.delete_original,
418
+ )
419
+ except Exception as error:
420
+ LOGGER.error(f"Skipped '{cfg.src}' because: '{str(error)}'")
421
+
422
+
423
+ def run_png(cfg: Configuration):
424
+ LOGGER.debug(f"{cfg=}")
425
+ write(
426
+ image_file_reader(cfg.src),
427
+ cfg.src,
428
+ cfg.output,
429
+ cfg.silent,
430
+ delete_original=cfg.delete_original,
431
+ )
432
+
433
+
434
+ def run_tiff(cfg: Configuration):
435
+ LOGGER.debug(f"{cfg=}")
436
+ write(
437
+ tiff_file_reader(cfg.src, multipage_as_list=True),
438
+ cfg.src,
439
+ cfg.output,
440
+ cfg.silent,
441
+ delete_original=cfg.delete_original,
442
+ )
443
+
444
+
445
+ def expand_sources(
446
+ paths: list[Path],
447
+ output: Output,
448
+ silent: bool,
449
+ delete_original: bool = False,
450
+ extras: dict[str, Any] | None = None,
451
+ ) -> list[Configuration]:
452
+ LOGGER.debug(f"{paths=}")
453
+ LOGGER.debug(f"{output=}")
454
+ LOGGER.debug(f"{silent=}")
455
+ LOGGER.debug(f"{delete_original=}")
456
+ LOGGER.debug(f"{extras=}")
457
+ sources = []
458
+ for path in paths:
459
+ if path.is_dir():
460
+ sources.extend(
461
+ [
462
+ Configuration(
463
+ delete_original=delete_original,
464
+ extras=extras,
465
+ output=output,
466
+ silent=silent,
467
+ src=path.joinpath(p),
468
+ )
469
+ for p in os.listdir(path)
470
+ if path.joinpath(p).is_file()
471
+ ]
472
+ )
473
+ else:
474
+ sources.append(
475
+ Configuration(
476
+ delete_original=delete_original,
477
+ extras=extras,
478
+ output=output,
479
+ silent=silent,
480
+ src=path,
481
+ )
482
+ )
483
+ return sources
484
+
485
+
486
+ def run(cfg: Configuration):
487
+ LOGGER.debug(f"{cfg=}")
488
+ kind = filetype.guess(cfg.src)
489
+ LOGGER.debug(f"{kind=}")
490
+ if kind is None:
491
+ raise UnsupportedFileType(cfg.src)
492
+ else:
493
+ SUPPORTED_MAP = {
494
+ Dcm.MIME: run_dcm,
495
+ Dm3.MIME: run_dm,
496
+ Dm4.MIME: run_dm,
497
+ Emd.MIME: run_emd,
498
+ Png.MIME: run_png,
499
+ Tiff.MIME: run_tiff,
500
+ }
501
+ runner = SUPPORTED_MAP.get(kind.mime)
502
+ if runner is None:
503
+ raise UnsupportedFileType(cfg.src)
504
+ else:
505
+ runner(cfg)
506
+
507
+
508
+ PROGRESS_BAR_FORMAT: str = "{l_bar}{bar}| {n_fmt}/{total_fmt}"
509
+
510
+ DELETE_ORIGINAL_OPT: bool = typer.Option(
511
+ False,
512
+ "-D",
513
+ "--delete-original",
514
+ help="Deletes the original file after conversion.",
515
+ )
516
+ NUM_CPUS_OPT: int | None = typer.Option(
517
+ None,
518
+ "-n",
519
+ "--num-cpus",
520
+ help="The number of CPU cores to use for parallel execution.",
521
+ )
522
+ OUTPUT_OPT: Path | None = typer.Option(
523
+ None, "-o", "--output", help="Destination for output file(s)."
524
+ )
525
+ PATHS_ARG: list[Path] = typer.Argument(help="The original source files.")
526
+ SILENT_OPT: bool = typer.Option(
527
+ False, "-S", "--silent", help="Disables the progress bars."
528
+ )
529
+ TO_BIT_DEPTH_OPT: Literal[8, 16] = typer.Option(
530
+ 8,
531
+ "-b",
532
+ "--to-bit-depth",
533
+ help="The bit depth for the output file.",
534
+ )
535
+ TO_FORMAT_OPT: ToFormats = typer.Option(
536
+ ToFormats.JPEG.value,
537
+ "-t",
538
+ "--to",
539
+ case_sensitive=False,
540
+ help="The output file format.",
541
+ )
542
+ VERBOSE_OPT: int = typer.Option(
543
+ 0,
544
+ "--verbose",
545
+ "-v",
546
+ help="Print debugging statements.",
547
+ count=True,
548
+ )
549
+ VERSION_OPT: bool | None = typer.Option(
550
+ None,
551
+ "--version",
552
+ help="Prints the version.",
553
+ callback=version_callback,
554
+ is_eager=True,
555
+ )
556
+
557
+
558
+ @app.command()
559
+ def main(
560
+ paths: list[Path] = PATHS_ARG,
561
+ delete_original: bool = DELETE_ORIGINAL_OPT,
562
+ num_cpus: int | None = NUM_CPUS_OPT,
563
+ output: Path | None = OUTPUT_OPT,
564
+ silent: bool = SILENT_OPT,
565
+ to_bit_depth: int = TO_BIT_DEPTH_OPT,
566
+ to_format: ToFormats = TO_FORMAT_OPT,
567
+ verbose: int = VERBOSE_OPT,
568
+ version: bool | None = VERSION_OPT,
569
+ ):
570
+ logging.basicConfig(level=map_verbosity(verbose))
571
+ LOGGER.debug(f"{delete_original=}")
572
+ LOGGER.debug(f"{paths=}")
573
+ LOGGER.debug(f"{num_cpus=}")
574
+ LOGGER.debug(f"{output=}")
575
+ LOGGER.debug(f"{silent=}")
576
+ LOGGER.debug(f"{to_bit_depth=}")
577
+ LOGGER.debug(f"{to_format=}")
578
+ LOGGER.debug(f"{verbose=}")
579
+ LOGGER.debug(f"{version=}")
580
+ try:
581
+ sources = expand_sources(
582
+ paths,
583
+ Output(
584
+ bit_depth=BitDepths(to_bit_depth),
585
+ format=to_format,
586
+ path=output,
587
+ ),
588
+ silent,
589
+ delete_original=delete_original,
590
+ )
591
+ if platform.system().lower() == "darwin":
592
+ _ = list(
593
+ tqdm(
594
+ map(run, sources),
595
+ bar_format=PROGRESS_BAR_FORMAT,
596
+ disable=silent,
597
+ total=len(sources),
598
+ )
599
+ )
600
+ else:
601
+ with Pool(num_cpus) as pool:
602
+ _ = list(
603
+ tqdm(
604
+ pool.imap(run, sources),
605
+ bar_format=PROGRESS_BAR_FORMAT,
606
+ disable=silent,
607
+ total=len(sources),
608
+ )
609
+ )
610
+ except UnsupportedFileType as err:
611
+ LOGGER.warning(f"The {err.src} file is not supported. Skipping!")
612
+ except ValidationError as err:
613
+ print_validation_error(err)
614
+ raise typer.Exit(code=1)
615
+
616
+
617
+ if __name__ == "__main__":
618
+ app(prog_name=__app_name__)