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 +1 -0
- tsio/cli.py +618 -0
- tsio-1.0.0.dist-info/METADATA +530 -0
- tsio-1.0.0.dist-info/RECORD +7 -0
- tsio-1.0.0.dist-info/WHEEL +4 -0
- tsio-1.0.0.dist-info/entry_points.txt +2 -0
- tsio-1.0.0.dist-info/licenses/LICENSE.txt +674 -0
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__)
|