Skip to content

Commit cd90893

Browse files
kostrykinCopilot
andauthored
Update the ip_scale_image tool (#174)
* Migrate to newest dependency versions (some tests fail) * Immitate previous `giatools=0.1` behaviour * Update test data, changed due to new `scikit-image` version * Refactor UI * Refactor implementation * Fix bugs * Rename option * Fix bugs * Rename `non-uniform` to `explicit`, clean up code * Add test for illegal output format * Update test data * Refactor tests * Add stdout assertions * Fix bug in metadata computation * Add another test for non-binary RGB PNG * Add `anti_aliasing: False` output and assertions * Add test for 3-D data and isotropy scaling * Fix bug * Add stdout assertions * Add isotropy test for 2-D data * Remove bioformats2raw/.lint_skip * Remove all_tool_files.txt * Update help * Relax assertions using regex * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix typos * Update .shed.yml --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 85b0f6a commit cd90893

23 files changed

+568
-78
lines changed

tools/scale_image/.shed.yml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
categories:
22
- Imaging
3-
description: Scale image
4-
long_description: Scales image by a certain factor using nearest, bilinear or bicubic interpolation.
5-
name: scale_image
3+
4+
description: Scales an image by re-sampling the image data.
5+
6+
long_description: |
7+
Scales an image by re-sampling the image data.
8+
9+
The image is rescaled uniformly along all axes, or anisotropically if multiple scale factors are given.
10+
In addition, the metadata of an image can be used to automatically rescale the image to obtain isotropic pixels or voxels.
11+
12+
This operation preserves both the brightness of the image, and the range of values.
13+
14+
name: scale_image
615
owner: imgteam
7-
homepage_url: https://github.com/bmcv
16+
homepage_url: https://scikit-image.org/docs/0.25.x/auto_examples/transform/plot_rescale.html
817
remote_repository_url: https://github.com/BMCV/galaxy-image-analysis/tree/master/tools/scale_image/

tools/scale_image/scale_image.py

Lines changed: 248 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,272 @@
11
import argparse
2+
import json
23
import sys
4+
from typing import (
5+
Any,
6+
Literal,
7+
)
38

49
import giatools.io
510
import numpy as np
611
import skimage.io
712
import skimage.transform
813
import skimage.util
9-
from PIL import Image
1014

1115

12-
def scale_image(input_file, output_file, scale, order, antialias):
13-
Image.MAX_IMAGE_PIXELS = 50000 * 50000
14-
im = giatools.io.imread(input_file)
16+
def get_uniform_scale(
17+
img: giatools.Image,
18+
axes: Literal['all', 'spatial'],
19+
factor: float,
20+
) -> tuple[float, ...]:
21+
"""
22+
Determine a tuple of `scale` factors for uniform or spatially uniform scaling.
1523
16-
# Parse `--scale` argument
17-
if ',' in scale:
18-
scale = [float(s.strip()) for s in scale.split(',')]
19-
assert len(scale) <= im.ndim, f'Image has {im.ndim} axes, but scale factors were given for {len(scale)} axes.'
20-
scale = scale + [1] * (im.ndim - len(scale))
24+
Axes, that are not present in the original image data, are ignored.
25+
"""
26+
ignored_axes = [
27+
axis for axis_idx, axis in enumerate(img.axes)
28+
if axis not in img.original_axes or (
29+
factor < 1 and img.data.shape[axis_idx] == 1
30+
)
31+
]
32+
match axes:
2133

34+
case 'all':
35+
return tuple(
36+
[
37+
(factor if axis not in ignored_axes else 1)
38+
for axis in img.axes if axis != 'C'
39+
]
40+
)
41+
42+
case 'spatial':
43+
return tuple(
44+
[
45+
(factor if axis in 'YXZ' and axis not in ignored_axes else 1)
46+
for axis in img.axes if axis != 'C'
47+
]
48+
)
49+
50+
case _:
51+
raise ValueError(f'Unknown axes for uniform scaling: "{axes}"')
52+
53+
54+
def get_scale_for_isotropy(
55+
img: giatools.Image,
56+
sample: Literal['up', 'down'],
57+
) -> tuple[float, ...]:
58+
"""
59+
Determine a tuple of `scale` factors to establish spatial isotropy.
60+
61+
The `sample` parameter governs whether to up-sample or down-sample the image data.
62+
"""
63+
scale = [1] * (len(img.axes) - 1) # omit the channel axis
64+
z_axis, y_axis, x_axis = [
65+
img.axes.index(axis) for axis in 'ZYX'
66+
]
67+
68+
# Determine the pixel size of the image
69+
if 'resolution' in img.metadata:
70+
pixel_size = np.divide(1, img.metadata['resolution'])
71+
else:
72+
sys.exit('Resolution information missing in image metadata')
73+
74+
# Define unified transformation of pixel/voxel sizes to scale factors
75+
def voxel_size_to_scale(voxel_size: np.ndarray) -> list:
76+
match sample:
77+
case 'up':
78+
return (voxel_size / voxel_size.min()).tolist()
79+
case 'down':
80+
return (voxel_size / voxel_size.max()).tolist()
81+
case _:
82+
raise ValueError(f'Unknown value for sample: "{sample}"')
83+
84+
# Handle the 3-D case
85+
if img.data.shape[z_axis] > 1:
86+
87+
# Determine the voxel depth of the image
88+
if (voxel_depth := img.metadata.get('z_spacing', None)) is None:
89+
sys.exit('Voxel depth information missing in image metadata')
90+
91+
# Determine the XYZ scale factors
92+
scale[x_axis], scale[y_axis], scale[z_axis] = (
93+
voxel_size_to_scale(
94+
np.array([*pixel_size, voxel_depth]),
95+
)
96+
)
97+
98+
# Handle the 2-D case
2299
else:
23-
scale = float(scale)
24100

25-
# For images with 3 or more axes, the last axis is assumed to correspond to channels
26-
if im.ndim >= 3:
27-
scale = [scale] * (im.ndim - 1) + [1]
101+
# Determine the XY scale factors
102+
scale[x_axis], scale[y_axis] = (
103+
voxel_size_to_scale(
104+
np.array(pixel_size),
105+
)
106+
)
107+
108+
return tuple(scale)
109+
110+
111+
def get_aa_sigma_by_scale(scale: float) -> float:
112+
"""
113+
Determine the optimal size of the Gaussian filter for anti-aliasing.
114+
115+
See for details: https://scikit-image.org/docs/0.25.x/api/skimage.transform.html#skimage.transform.rescale
116+
"""
117+
return (1 / scale - 1) / 2 if scale < 1 else 0
118+
119+
120+
def get_new_metadata(
121+
old: giatools.Image,
122+
scale: float | tuple[float, ...],
123+
arr: np.ndarray,
124+
) -> dict[str, Any]:
125+
"""
126+
Determine the result metadata (copy and adapt).
127+
"""
128+
metadata = dict(old.metadata)
129+
scales = (
130+
[scale] * (len(old.axes) - 1) # omit the channel axis
131+
if isinstance(scale, float) else scale
132+
)
133+
134+
# Determine the original pixel size
135+
old_pixel_size = (
136+
np.divide(1, old.metadata['resolution'])
137+
if 'resolution' in old.metadata else (1, 1)
138+
)
139+
140+
# Determine the new pixel size and update metadata
141+
new_pixel_size = np.divide(
142+
old_pixel_size,
143+
(
144+
scales[old.axes.index('X')],
145+
scales[old.axes.index('Y')],
146+
),
147+
)
148+
metadata['resolution'] = tuple(1 / new_pixel_size)
28149

29-
# Do the scaling
30-
res = skimage.transform.rescale(im, scale, order, anti_aliasing=antialias, preserve_range=True)
150+
# Update the metadata for the new voxel depth
151+
old_voxel_depth = old.metadata.get('z_spacing', 1)
152+
metadata['z_spacing'] = old_voxel_depth / scales[old.axes.index('Z')]
153+
154+
return metadata
155+
156+
157+
def metadata_to_str(metadata: dict) -> str:
158+
tokens = list()
159+
for key in sorted(metadata.keys()):
160+
value = metadata[key]
161+
if isinstance(value, tuple):
162+
value = '(' + ', '.join([f'{val}' for val in value]) + ')'
163+
tokens.append(f'{key}: {value}')
164+
if len(metadata_str := ', '.join(tokens)) > 0:
165+
return metadata_str
166+
else:
167+
return 'has no metadata'
168+
169+
170+
def write_output(filepath: str, img: giatools.Image):
171+
"""
172+
Validate that the output file format is suitable for the image data, then write it.
173+
"""
174+
print('Output shape:', img.data.shape)
175+
print('Output axes:', img.axes)
176+
print('Output', metadata_to_str(img.metadata))
177+
178+
# Validate that the output file format is suitable for the image data
179+
if filepath.lower().endswith('.png'):
180+
if not frozenset(img.axes) <= frozenset('YXC'):
181+
sys.exit(f'Cannot write PNG file with axes "{img.axes}"')
182+
183+
# Write image data to the output file
184+
img.write(filepath)
185+
186+
187+
def scale_image(
188+
input_filepath: str,
189+
output_filepath: str,
190+
mode: Literal['uniform', 'explicit', 'isotropy'],
191+
order: int,
192+
anti_alias: bool,
193+
**cfg,
194+
):
195+
img = giatools.Image.read(input_filepath)
196+
print('Input axes:', img.original_axes)
197+
print('Input', metadata_to_str(img.metadata))
198+
199+
# Determine `scale` for scaling
200+
match mode:
201+
202+
case 'uniform':
203+
scale = get_uniform_scale(img, cfg['axes'], cfg['factor'])
204+
205+
case 'explicit':
206+
scale = tuple(
207+
[cfg.get(f'factor_{axis.lower()}', 1) for axis in img.axes if axis != 'C']
208+
)
209+
210+
case 'isotropy':
211+
scale = get_scale_for_isotropy(img, cfg['sample'])
212+
213+
case _:
214+
raise ValueError(f'Unknown mode: "{mode}"')
215+
216+
# Assemble remaining `rescale` parameters
217+
rescale_kwargs = dict(
218+
scale=scale,
219+
order=order,
220+
preserve_range=True,
221+
channel_axis=img.axes.index('C'),
222+
)
223+
if (anti_alias := anti_alias and (np.array(scale) < 1).any()):
224+
rescale_kwargs['anti_aliasing'] = anti_alias
225+
rescale_kwargs['anti_aliasing_sigma'] = tuple(
226+
[
227+
get_aa_sigma_by_scale(s) for s in scale
228+
] + [0] # `skimage.transform.rescale` also expects a value for the channel axis
229+
)
230+
else:
231+
rescale_kwargs['anti_aliasing'] = False
232+
233+
# Re-sample the image data to perform the scaling
234+
for key, value in rescale_kwargs.items():
235+
print(f'{key}: {value}')
236+
arr = skimage.transform.rescale(img.data, **rescale_kwargs)
31237

32238
# Preserve the `dtype` so that both brightness and range of values is preserved
33-
if res.dtype != im.dtype:
34-
if np.issubdtype(im.dtype, np.integer):
35-
res = res.round()
36-
res = res.astype(im.dtype)
239+
if arr.dtype != img.data.dtype:
240+
if np.issubdtype(img.data.dtype, np.integer):
241+
arr = arr.round()
242+
arr = arr.astype(img.data.dtype)
37243

38-
# Save result
39-
skimage.io.imsave(output_file, res)
244+
# Determine the result metadata and save result
245+
metadata = get_new_metadata(img, scale, arr)
246+
write_output(
247+
output_filepath,
248+
giatools.Image(
249+
data=arr,
250+
axes=img.axes,
251+
metadata=metadata,
252+
).squeeze()
253+
)
40254

41255

42256
if __name__ == "__main__":
43257
parser = argparse.ArgumentParser()
44-
parser.add_argument('input_file', type=argparse.FileType('r'), default=sys.stdin)
45-
parser.add_argument('out_file', type=argparse.FileType('w'), default=sys.stdin)
46-
parser.add_argument('--scale', type=str, required=True)
47-
parser.add_argument('--order', type=int, required=True)
48-
parser.add_argument('--antialias', default=False, action='store_true')
258+
parser.add_argument('input', type=str)
259+
parser.add_argument('output', type=str)
260+
parser.add_argument('params', type=str)
49261
args = parser.parse_args()
50262

51-
scale_image(args.input_file.name, args.out_file.name, args.scale, args.order, args.antialias)
263+
# Read the config file
264+
with open(args.params) as cfgf:
265+
cfg = json.load(cfgf)
266+
267+
# Perform scaling
268+
scale_image(
269+
args.input,
270+
args.output,
271+
**cfg,
272+
)

0 commit comments

Comments
 (0)