Coverage for src/precon3d/downscaler.py: 0%
90 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 03:50 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 03:50 +0000
1"""Downscale images"""
3# Default python modules
4import numpy as np
5from pathlib import Path
6import math
7from rich.progress import (
8 Progress,
9 BarColumn,
10 TextColumn,
11) # Import Rich progress componentsimport yaml # Assuming the configuration is in YAML format
12from PIL import Image
14Image.MAX_IMAGE_PIXELS = None
15import typer
16import time
17from typing import NamedTuple, Dict, Any
19# Local scripts
20import precon3d.utility as ut
21import precon3d.factory
23# pylint: disable=wildcard-import
24from precon3d.custom_types import *
25from precon3d._my_typer_cli import CustomCLIGroup, CustomCLICommand
27# CLI
28app = typer.Typer(
29 cls=CustomCLIGroup,
30 no_args_is_help=True,
31 short_help="Downscale images",
32)
35class DownscalerAttrs(NamedTuple):
36 """_summary_
38 Args:
39 NamedTuple (_type_): _description_
40 """
42 downscale_factor: float = None
43 downscale_tolerance: float = None
44 image_limit_factor: float = None
47def create_downscaler_attrs(data: Dict[str, Any]) -> DownscalerAttrs:
48 """Create DownscalerAttrs from a dictionary."""
49 return DownscalerAttrs(
50 downscale_factor=data["downscaler_attrs"]["resolution_output"][
51 "inplane"
52 ]
53 / data["downscaler_attrs"]["resolution_input"]["inplane"],
54 downscale_tolerance=data["downscaler_attrs"]["downscale_tolerance"],
55 image_limit_factor=data["downscaler_attrs"]["image_limit_factor"],
56 )
59def downscale_image_stack(config):
60 """Downscale a stack of images based on the provided configuration.
62 Args:
63 config (dict): Configuration parameters including image directory, output directory,
64 and downscaling settings.
65 """
67 general_attrs = precon3d.factory.create_general_attrs(config)
68 downscaler_attrs = create_downscaler_attrs(config)
70 # Get the list of images to process
71 input_image_list = ut.sorted_files(
72 general_attrs.input_directory, general_attrs.file_extension
73 )
74 print(
75 f"found {len(input_image_list)} images in {general_attrs.input_directory}"
76 )
78 downscaled_output_dir = general_attrs.output_directory
79 if downscaled_output_dir.is_dir() and any(downscaled_output_dir.iterdir()):
80 downscaled_filepaths = ut.sorted_files(
81 directory=general_attrs.output_directory,
82 file_extension=general_attrs.file_extension,
83 )
85 filtered_image_list = ut.remove_matching_filenames(
86 input_image_list, downscaled_filepaths
87 )
88 else:
89 filtered_image_list = input_image_list
91 # Create the target directory if it doesn't exist
92 downscaled_output_dir.mkdir(parents=True, exist_ok=True)
94 new_width, new_height = find_new_image_dimensions(
95 filtered_image_list[0], downscaler_attrs
96 )
97 print(f"scaling images by {downscaler_attrs.downscale_factor:.2f}")
99 # Use Rich's Progress for displaying progress
100 with Progress(
101 TextColumn("[progress.description]{task.description}"),
102 BarColumn(),
103 TextColumn("[progress.percentage]{task.percentage:>3.1f}%"),
104 TextColumn("[bold blue]{task.completed}/{task.total}"),
105 ) as progress:
106 task = progress.add_task(
107 "Processing Images...", total=len(filtered_image_list)
108 )
110 # Process each image in the list
111 for image_filepath in filtered_image_list:
112 # if counter % 50 == 0:
113 # print(f"\t\tDownscaling image {counter + 1} of {len(image_list)}")
114 relative_path = image_filepath.parent.relative_to(
115 general_attrs.input_directory
116 )
117 downscale_target_dir = downscaled_output_dir.joinpath(
118 relative_path
119 )
121 downscale_target_dir.mkdir(parents=True, exist_ok=True)
123 downscale_image(
124 image_filepath,
125 scale_factor=downscaler_attrs.downscale_factor,
126 new_width=new_width,
127 new_height=new_height,
128 downscale_target_dir=downscale_target_dir,
129 )
130 progress.update(task, advance=1) # Update the progress bar
133def find_new_image_dimensions(test_img_filepath: Path, attrs: DownscalerAttrs):
134 """Find the new dimensions for downscaling an image.
136 Args:
137 test_img_filepath (Path): The path to the test image.
138 attrs (DownscalerAttrs): Configuration parameters including resolution settings.
140 Returns:
141 tuple: new width, new height.
142 """
144 with Image.open(test_img_filepath) as img:
145 width, height = img.size
147 new_width = calculate_new_dimension(
148 width,
149 attrs.downscale_factor,
150 attrs.downscale_tolerance,
151 attrs.image_limit_factor,
152 attrs.image_limit_factor,
153 )
154 new_height = calculate_new_dimension(
155 height,
156 attrs.downscale_factor,
157 attrs.downscale_tolerance,
158 attrs.image_limit_factor,
159 attrs.image_limit_factor,
160 )
162 return new_width, new_height
165def calculate_new_dimension(
166 size: int,
167 downscale_factor: float,
168 downscale_tolerance: float,
169 min_size_factor: float,
170 max_size_factor: float,
171) -> int:
172 """Calculate new dimension that meets downscaling criteria.
174 Args:
175 size (int): The original size (width or height).
176 downscale_factor (float): The factor by which to downscale.
177 downscale_tolerance (float): The tolerance for downscaling.
178 min_size_factor (float): Minimum size factor for downscaling.
179 max_size_factor (float): Maximum size factor for downscaling.
181 Returns:
182 int: The new size that meets the downscaling criteria.
183 """
184 min_size = size * min_size_factor
185 max_size = size * max_size_factor
187 def adjust_size(new_size):
188 """Adjust the size to find a valid downscaled dimension."""
189 while not math.isclose(
190 new_size % downscale_factor, 0, abs_tol=downscale_tolerance
191 ):
192 if new_size < min_size:
193 return size # Return original size if no smaller solution
194 new_size -= 1
195 return new_size
197 new_size = adjust_size(size)
198 if new_size == size: # If no smaller solution found, try increasing
199 new_size = size
200 while not math.isclose(
201 new_size % downscale_factor, 0, abs_tol=downscale_tolerance
202 ):
203 if new_size > max_size:
204 raise ValueError(
205 "Could not find even dimensions within size limit range and downscale tolerance specified. Please relax constraints"
206 )
207 new_size += 1
208 return new_size
211def downscale_image(
212 image: Path,
213 scale_factor: float,
214 new_width: int,
215 new_height: int,
216 downscale_target_dir: Path,
217):
218 """Downscale a single image and save it to the target directory.
220 Args:
221 image (Path): The path to the image file.
222 width (int): Original width of the image.
223 new_width (int): New width after downscaling.
224 height (int): Original height of the image.
225 new_height (int): New height after downscaling.
226 new_slice_num (int): The index for naming the output file.
227 downscale_target_dir (str): Directory to save the downscaled image.
228 config (dict): Configuration parameters.
229 """
231 with Image.open(image) as img:
232 # width, height = img.size
234 # # # Crop original image prior to downscaling
235 # left, right = calculate_crop(width, new_width)
236 # top, bottom = calculate_crop(height, new_height)
238 # cropped_img = img.crop((left, top, right, bottom))
240 downscaled_img = img.resize(
241 (int(new_width / scale_factor), int(new_height / scale_factor)),
242 Image.Resampling.LANCZOS,
243 )
244 downscaled_img.save(downscale_target_dir.joinpath(image.name))
247def calculate_crop(original_size: int, new_size: int):
248 """Calculate the crop dimensions.
250 Args:
251 original_size (int): The original size (width or height).
252 new_size (int): The new size after downscaling.
254 Returns:
255 tuple: crop dimensions for each size (e.g. left or right).
256 """
258 # crop
259 if new_size < original_size:
260 left = int(math.floor((original_size - new_size) / 2))
261 right = left + new_size
262 else:
263 left = 0
264 right = original_size
266 return left, right
269@app.command(
270 cls=CustomCLICommand,
271 name="process_images",
272 short_help="downscale images files from user config",
273)
274def process_images(configfile: Path):
275 """
276 Downscale image stack using the provided configuration file.
278 Parameters
279 ----------
280 configfile : Path
281 The path to the YAML configuration file containing parameters for downscaling images.
283 Returns
284 -------
285 None
286 This function does not return a value. It performs image processing and outputs timing information.
288 Notes
289 -----
290 This function reads the configuration from the specified YAML file and uses it to downscale images.
291 The total time taken for the operation is printed upon completion.
293 Examples
294 --------
295 >>> process_images(Path("path/to/config.yml"))
296 *** Starting precon3d_downscale:process_images, config.yml ***
297 total time:
298 1969.47 seconds
299 32.82 minutes
300 0.55 hours
302 CLI
303 ---
304 >>> python -m precon3d.precon3d_downscale process_images "path/to/config.yml"
305 """
307 # cast to Path object if not already a Path object
308 if not isinstance(configfile, Path):
309 configfile = Path(configfile)
311 # parse and run prep
312 config = ut.read_config(configfile)
313 ut.current_date_and_time()
314 typer.echo(
315 f"*** Starting precon3d_downscale:process_images, {configfile.name} *** \n"
316 )
318 tic = time.perf_counter()
320 downscale_image_stack(config)
322 toc = time.perf_counter()
324 typer.echo(
325 f"""total time:
326 {toc - tic:0.2f} seconds
327 {(toc - tic)/60:0.2f} minutes
328 {(toc - tic)/3600:0.2f} hours"""
329 )
332@app.callback()
333def callback():
334 """
335 precon3d.downscaler downscales images.
336 """
339if __name__ == "__main__":
340 app()