tomio 0.1.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.
tomio/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ from . import volume, configfile, labelfile, interp
2
+
3
+ # backward compatible name
4
+ config = configfile
5
+
6
+ volread = volume.read
7
+ volwrite = volume.write
8
+ read_volume = volume.read
9
+ write_volume = volume.write
10
+ tovolume = volume.tovolume
11
+ asvolume = volume.asvolume
12
+ resample = volume.resample
13
+ Volume = volume.Volume
14
+ Geometry = volume.Geometry
15
+
16
+ read_config = configfile.read
17
+ write_config = configfile.write
18
+
19
+ read_labels = labelfile.read
20
+ write_labels = labelfile.write
21
+
tomio/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
tomio/cli.py ADDED
@@ -0,0 +1,154 @@
1
+ """ command-line interface
2
+
3
+ tomio convert IN OUT
4
+ tomio resample IN OUT (--like REF | --spacing SX SY SZ) [--method METHOD]
5
+ tomio info IN
6
+ """
7
+ import sys
8
+ import argparse
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+
13
+ from . import volume, imagefile
14
+
15
+ METHODS = ["nearest", "linear", "spline3", "labels", "roi"]
16
+
17
+
18
+ class UserError(Exception):
19
+ """error reported to the user without traceback"""
20
+
21
+
22
+ def main(argv=None):
23
+ """run the command line, return the exit code"""
24
+ parser = get_parser()
25
+ try:
26
+ args = parser.parse_args(argv)
27
+ except SystemExit as exc:
28
+ # --help, or invalid arguments (argparse message already printed)
29
+ return exc.code
30
+ try:
31
+ args.func(args)
32
+ except (UserError, FileNotFoundError, ValueError, RuntimeError, ImportError, NotImplementedError) as exc:
33
+ print(f"tomio: error: {exc}", file=sys.stderr)
34
+ return 1
35
+ return 0
36
+
37
+
38
+ def get_parser():
39
+ parser = argparse.ArgumentParser(prog="tomio", description="Convert, resample and inspect medical image volumes.")
40
+ commands = parser.add_subparsers(title="commands", required=True, metavar="COMMAND")
41
+
42
+ # convert
43
+ convert = commands.add_parser("convert", help="convert an image file to another format", description=(
44
+ "Convert an image file to another format (from the file extensions, e.g. .mha, .nii.gz, .nrrd). "
45
+ "Pixel data, geometry, metadata, vector and complex pixels are kept."))
46
+ convert.add_argument("input", help="input image file")
47
+ convert.add_argument("output", help="output image file")
48
+ add_output_options(convert)
49
+ convert.set_defaults(func=run_convert)
50
+
51
+ # resample
52
+ resample = commands.add_parser("resample", help="resample a volume to a new geometry", description=(
53
+ "Resample a volume to the geometry of another volume, or to a new spacing (same extent)."))
54
+ resample.add_argument("input", help="input image file")
55
+ resample.add_argument("output", help="output image file")
56
+ target = resample.add_mutually_exclusive_group(required=True)
57
+ target.add_argument("--like", metavar="REF", help="reference image file: use its geometry (header only)")
58
+ target.add_argument("--spacing", metavar=("SX", "SY", "SZ"), nargs=3, type=float, help="new spacing")
59
+ resample.add_argument("--method", choices=METHODS, help=(
60
+ "interpolation method; default: nearest for integer data, spline3 otherwise; "
61
+ "labels: label maps, label by label; roi: masks/ROIs"))
62
+ resample.add_argument("--fill", type=float, default=0.0, help="value outside the input volume (default: 0, may be nan)")
63
+ add_output_options(resample)
64
+ resample.set_defaults(func=run_resample)
65
+
66
+ # info
67
+ info = commands.add_parser("info", help="print the header of an image file", description=(
68
+ "Print shape, pixel type and geometry of an image file (the pixel data is not read)."))
69
+ info.add_argument("input", help="input image file")
70
+ info.add_argument("--metadata", action="store_true", help="also print the metadata")
71
+ info.set_defaults(func=run_info)
72
+ return parser
73
+
74
+
75
+ def add_output_options(parser):
76
+ parser.add_argument("--dtype", help="cast the output pixel values (numpy dtype, e.g. float32, uint8)")
77
+ parser.add_argument("--compression-level", type=int, metavar="N", help="compression level (e.g. 1-9 for .nii.gz)")
78
+ parser.add_argument("-f", "--force", action="store_true", help="overwrite the output file if it exists")
79
+
80
+
81
+ # commands
82
+
83
+ def run_convert(args):
84
+ check_files(args)
85
+ imagefile.convert_image(args.input, args.output, dtype=get_dtype(args.dtype), compression_level=args.compression_level)
86
+
87
+
88
+ def run_resample(args):
89
+ check_files(args)
90
+ # complex data: 2-component vector image (as written by tomio)
91
+ as_complex = imagefile.read_info(args.input)["components"] == 2
92
+ vol = volume.read(args.input, as_complex=as_complex)
93
+ if args.like is not None:
94
+ check_input(args.like)
95
+ header = volume.read_header(args.like)
96
+ target = dict(like=volume.Geometry(shape=header["shape"][:3], spacing=header["spacing"], origin=header["origin"], transform=header["transform"]))
97
+ else:
98
+ target = dict(spacing=args.spacing)
99
+ kwargs = {} if args.method in ("labels", "roi") else dict(fill=args.fill)
100
+ out = volume.resample(vol, method=args.method, **target, **kwargs)
101
+ dtype = get_dtype(args.dtype)
102
+ if dtype is not None:
103
+ out = out.astype(dtype)
104
+ volume.write(args.output, out, compression_level=args.compression_level)
105
+
106
+
107
+ def run_info(args):
108
+ check_input(args.input)
109
+ header = volume.read_header(args.input)
110
+ info = imagefile.read_info(args.input)
111
+ geometry = volume.Geometry(shape=header["shape"][:3], spacing=header["spacing"], origin=header["origin"], transform=header["transform"])
112
+ lines = [
113
+ ("file", args.input),
114
+ ("shape", " x ".join(str(n) for n in header["shape"])),
115
+ ("pixel type", info["pixel_type"]),
116
+ ("spacing", format_values(geometry.spacing)),
117
+ ("origin", format_values(geometry.origin)),
118
+ ("orientation", f'{volume.get_orientation(geometry)} ("from" convention), {volume.get_orientation(geometry, convention="to")} ("to" convention)'),
119
+ ("directions", "; ".join(format_values(vec) for vec in geometry.transform)),
120
+ ("extent", format_values(geometry.extent)),
121
+ ("metadata", f"{len(header['metadata'])} keys"),
122
+ ]
123
+ width = max(len(name) for name, _ in lines)
124
+ for name, value in lines:
125
+ print(f"{name:<{width}} {value}")
126
+ if args.metadata:
127
+ for key, value in sorted(header["metadata"].items()):
128
+ print(f" {key} = {value}")
129
+
130
+
131
+ # utilities
132
+
133
+ def check_input(filename):
134
+ if not Path(filename).is_file():
135
+ raise UserError(f"input file not found: {filename}")
136
+
137
+
138
+ def check_files(args):
139
+ check_input(args.input)
140
+ if Path(args.output).exists() and not args.force:
141
+ raise UserError(f"output file exists (use --force to overwrite): {args.output}")
142
+
143
+
144
+ def get_dtype(name):
145
+ if name is None:
146
+ return None
147
+ try:
148
+ return np.dtype(name)
149
+ except TypeError:
150
+ raise UserError(f"invalid dtype: {name}") from None
151
+
152
+
153
+ def format_values(values):
154
+ return "(" + ", ".join(f"{v:.6g}" for v in values) + ")"
tomio/configfile.py ADDED
@@ -0,0 +1,138 @@
1
+ # coding=utf-8
2
+ """ config file i/o (JSON, YAML; TOML: read only) """
3
+ import re
4
+ import json
5
+ import math
6
+ import numbers
7
+ import yaml
8
+ from collections import abc
9
+ from pathlib import Path
10
+ import numpy as np
11
+
12
+
13
+ KINDS = {"json": "json", "yaml": "yaml", "yml": "yaml", "toml": "toml"}
14
+
15
+
16
+ def get_kind(path):
17
+ """config file type ("json", "yaml" or "toml") from path suffix"""
18
+ ext = path.suffix[1:].lower()
19
+ if ext not in KINDS:
20
+ raise ValueError(f"Unknown config file type: {path}")
21
+ return KINDS[ext]
22
+
23
+
24
+ def read(path):
25
+ """read config file (.json, .yaml, .yml or .toml; no suffix: .json)"""
26
+ path = Path(path)
27
+ if not path.suffix:
28
+ path = path.with_suffix(".json")
29
+ kind = get_kind(path)
30
+
31
+ if kind == "toml":
32
+ tomllib = import_tomllib()
33
+ with open(path, "rb") as fp:
34
+ return untame(tomllib.load(fp))
35
+
36
+ with open(path, "r", encoding="utf-8") as fp:
37
+ if kind == "json":
38
+ return untame(json.load(fp))
39
+ else:
40
+ # safe: no Python-specific tags
41
+ return untame(yaml.safe_load(fp))
42
+
43
+
44
+ def import_tomllib():
45
+ """TOML parser: tomllib (Python >= 3.11), or tomli (same API)"""
46
+ try:
47
+ import tomllib
48
+ except ImportError:
49
+ try:
50
+ import tomli as tomllib
51
+ except ImportError:
52
+ raise ImportError("Reading TOML files requires Python >= 3.11, or `tomli` (pip install tomli)") from None
53
+ return tomllib
54
+
55
+
56
+ def write(path, config, kind="json"):
57
+ """write config file
58
+
59
+ The file type is set by the path suffix (.json, .yaml or .yml; .toml is read-only).
60
+ If the path has no suffix, `kind` is used and added as suffix.
61
+ """
62
+ path = Path(path)
63
+ if not path.suffix:
64
+ path = path.with_suffix(f".{kind}")
65
+ kind = get_kind(path)
66
+ if kind == "toml":
67
+ raise ValueError(f"Writing TOML files is not supported (read-only): {path}")
68
+ config = tame(config)
69
+
70
+ with open(path, "w", encoding="utf-8") as fp:
71
+ if kind == "json":
72
+ json.dump(config, fp)
73
+ else:
74
+ yaml.safe_dump(config, fp, default_flow_style=False)
75
+
76
+
77
+ def tame(element):
78
+ """convert config elements to standard types
79
+
80
+ dict, list, str, None, bool, int, float
81
+ numpy arrays become lists, complex numbers become strings: "3 + 4i"
82
+ """
83
+ if element is None or isinstance(element, str):
84
+ return element
85
+ elif isinstance(element, (bool, np.bool_)): # before int: bool is an int
86
+ return bool(element)
87
+ elif isinstance(element, numbers.Integral):
88
+ return int(element)
89
+ elif isinstance(element, numbers.Real):
90
+ return float(element)
91
+ elif isinstance(element, numbers.Complex):
92
+ return format_complex(element)
93
+ elif isinstance(element, np.ndarray):
94
+ # tolist: nested lists, or a scalar for 0-d arrays
95
+ return tame(element.tolist())
96
+ elif isinstance(element, abc.Mapping):
97
+ return {tame(key): tame(value) for key, value in element.items()}
98
+ elif isinstance(element, abc.Sequence):
99
+ return [tame(item) for item in element]
100
+ else:
101
+ return str(element)
102
+
103
+
104
+ def untame(element):
105
+ """reverse of tame: convert complex number strings ("3 + 4i") to complex"""
106
+ if isinstance(element, str):
107
+ return parse_complex(element, default=element)
108
+ elif isinstance(element, dict):
109
+ return {key: untame(value) for key, value in element.items()}
110
+ elif isinstance(element, list):
111
+ return [untame(item) for item in element]
112
+ return element
113
+
114
+
115
+ # complex numbers
116
+
117
+ _NUMBER = r"(?:(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?|inf|nan)"
118
+ _RE_COMPLEX = re.compile(rf"^\s*([-+]?{_NUMBER})\s*([-+])\s*({_NUMBER})i\s*$")
119
+
120
+
121
+ def format_complex(value):
122
+ """format complex number as "3 + 4i" """
123
+ value = complex(value)
124
+
125
+ def fmt(x):
126
+ return str(int(x)) if x.is_integer() else repr(x)
127
+
128
+ sign = "-" if math.copysign(1, value.imag) < 0 else "+"
129
+ return f"{fmt(value.real)} {sign} {fmt(abs(value.imag))}i"
130
+
131
+
132
+ def parse_complex(string, default=None):
133
+ """parse complex number formatted as "3 + 4i" (return `default` if not one)"""
134
+ match = _RE_COMPLEX.match(string)
135
+ if not match:
136
+ return default
137
+ real, sign, imag = match.groups()
138
+ return complex(float(real), float(sign + imag))
tomio/imagefile.py ADDED
@@ -0,0 +1,141 @@
1
+ """ image file i/o with SimpleITK
2
+
3
+ arrays are in ITK index order (x, y, z, ...); vector images have the components in the last axis
4
+ """
5
+ import SimpleITK as sitk
6
+ import numpy as np
7
+
8
+
9
+ def write_image(filename, array, *, use_compression=True, compression_level=None, **kwargs):
10
+ """write array (x, y, z, ...) to image file
11
+
12
+ kwargs: spacing, origin, transform, metadata, is_vector (see `to_image`)
13
+ compression_level: if set (format-dependent, e.g. 1-9 for gzip), implies use_compression
14
+ """
15
+ im = to_image(array, **kwargs)
16
+ if compression_level is None:
17
+ sitk.WriteImage(im, str(filename), useCompression=use_compression)
18
+ else:
19
+ sitk.WriteImage(im, str(filename), useCompression=True, compressionLevel=compression_level)
20
+
21
+
22
+ def to_image(array, *, spacing=None, origin=None, transform=None, metadata=None, is_vector=False):
23
+ """convert array (x, y, z, ...) to sitk Image
24
+
25
+ is_vector: the last axis of `array` contains the components of a vector image
26
+ spacing, origin, transform: 3d geometry (identity for the extra dimensions)
27
+ """
28
+ array = np.asarray(array)
29
+ if np.issubdtype(array.dtype, np.bool_):
30
+ array = array.astype('uint8')
31
+ if is_vector:
32
+ # numpy order: reversed image axes, then components
33
+ n = array.ndim - 1
34
+ im = sitk.GetImageFromArray(np.transpose(array, (*reversed(range(n)), n)), isVector=True)
35
+ else:
36
+ # isVector=False: otherwise SimpleITK may guess a vector image from the shape
37
+ im = sitk.GetImageFromArray(array.T, isVector=False)
38
+ ndim = im.GetDimension()
39
+ if spacing is not None:
40
+ im.SetSpacing(pad(spacing, ndim, 1))
41
+ if origin is not None:
42
+ im.SetOrigin(pad(origin, ndim, 0))
43
+ if transform is not None:
44
+ # transform: columns of the direction matrix D
45
+ direction = np.eye(max(ndim, 3))
46
+ direction[:3, :3] = np.asarray(transform).reshape(3, 3).T
47
+ im.SetDirection(direction[:ndim, :ndim].ravel())
48
+ if metadata is not None:
49
+ for key, value in metadata.items():
50
+ im.SetMetaData(str(key), str(value))
51
+ return im
52
+
53
+
54
+ def read_image(filename):
55
+ """read image file, return array (x, y, z, ...) and header (3d geometry and metadata)
56
+
57
+ vector images: components are in the last axis
58
+ """
59
+ return from_image(sitk.ReadImage(str(filename)))
60
+
61
+
62
+ def from_image(im):
63
+ """convert sitk Image to array (x, y, z, ...) and header (3d geometry and metadata)
64
+
65
+ vector images: components are in the last axis
66
+ """
67
+ ndim = im.GetDimension()
68
+
69
+ array = sitk.GetArrayFromImage(im)
70
+ if array.ndim > ndim:
71
+ # vector image: (reversed image axes, components)
72
+ array = np.transpose(array, (*reversed(range(ndim)), ndim))
73
+ else:
74
+ array = array.T
75
+
76
+ return array, get_header(im)
77
+
78
+
79
+ def read_header(filename):
80
+ """read image file header only (no pixel data)
81
+
82
+ returns: shape (x, y, z, ..., [components]), 3d geometry and metadata
83
+ """
84
+ reader = sitk.ImageFileReader()
85
+ reader.SetFileName(str(filename))
86
+ reader.ReadImageInformation()
87
+
88
+ shape = tuple(reader.GetSize())
89
+ if reader.GetNumberOfComponents() > 1:
90
+ shape += (reader.GetNumberOfComponents(),)
91
+ return dict(shape=shape, **get_header(reader))
92
+
93
+
94
+ def convert_image(source, target, *, dtype=None, use_compression=True, compression_level=None):
95
+ """convert image file to another format (from the file extensions)
96
+
97
+ pixel data, geometry, metadata, vector and complex pixels are kept
98
+ dtype: cast pixel values (numpy dtype)
99
+ """
100
+ im = sitk.ReadImage(str(source))
101
+ if dtype is not None:
102
+ array = sitk.GetArrayFromImage(im).astype(dtype)
103
+ is_vector = im.GetNumberOfComponentsPerPixel() > 1 and array.ndim > im.GetDimension()
104
+ cast = sitk.GetImageFromArray(array, isVector=is_vector)
105
+ cast.CopyInformation(im)
106
+ for key in im.GetMetaDataKeys():
107
+ cast.SetMetaData(key, im.GetMetaData(key))
108
+ im = cast
109
+ if compression_level is None:
110
+ sitk.WriteImage(im, str(target), useCompression=use_compression)
111
+ else:
112
+ sitk.WriteImage(im, str(target), useCompression=True, compressionLevel=compression_level)
113
+
114
+
115
+ def read_info(filename):
116
+ """pixel type (e.g. "32-bit float"), number of components and image dimension (header only)"""
117
+ reader = sitk.ImageFileReader()
118
+ reader.SetFileName(str(filename))
119
+ reader.ReadImageInformation()
120
+ return dict(
121
+ pixel_type=sitk.GetPixelIDValueAsString(reader.GetPixelID()),
122
+ components=reader.GetNumberOfComponents(),
123
+ dimension=reader.GetDimension(),
124
+ )
125
+
126
+
127
+ def get_header(src):
128
+ """3d geometry and metadata of a sitk Image or ImageFileReader"""
129
+ ndim = src.GetDimension()
130
+ spacing = src.GetSpacing()[:3]
131
+ origin = src.GetOrigin()[:3]
132
+ # GetDirection: direction matrix D, row-major; transform: columns of D
133
+ transform = np.reshape(src.GetDirection(), (ndim, ndim))[:3, :3].T
134
+ metadata = {key: src.GetMetaData(key) for key in src.GetMetaDataKeys()}
135
+ return dict(spacing=spacing, origin=origin, transform=transform, metadata=metadata)
136
+
137
+
138
+ def pad(values, ndim, fill):
139
+ """pad 3d values for images with more than 3 dimensions"""
140
+ values = list(values)
141
+ return values + [fill] * (ndim - len(values))