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

1"""Downscale images""" 

2 

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 

13 

14Image.MAX_IMAGE_PIXELS = None 

15import typer 

16import time 

17from typing import NamedTuple, Dict, Any 

18 

19# Local scripts 

20import precon3d.utility as ut 

21import precon3d.factory 

22 

23# pylint: disable=wildcard-import 

24from precon3d.custom_types import * 

25from precon3d._my_typer_cli import CustomCLIGroup, CustomCLICommand 

26 

27# CLI 

28app = typer.Typer( 

29 cls=CustomCLIGroup, 

30 no_args_is_help=True, 

31 short_help="Downscale images", 

32) 

33 

34 

35class DownscalerAttrs(NamedTuple): 

36 """_summary_ 

37 

38 Args: 

39 NamedTuple (_type_): _description_ 

40 """ 

41 

42 downscale_factor: float = None 

43 downscale_tolerance: float = None 

44 image_limit_factor: float = None 

45 

46 

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 ) 

57 

58 

59def downscale_image_stack(config): 

60 """Downscale a stack of images based on the provided configuration. 

61 

62 Args: 

63 config (dict): Configuration parameters including image directory, output directory, 

64 and downscaling settings. 

65 """ 

66 

67 general_attrs = precon3d.factory.create_general_attrs(config) 

68 downscaler_attrs = create_downscaler_attrs(config) 

69 

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 ) 

77 

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 ) 

84 

85 filtered_image_list = ut.remove_matching_filenames( 

86 input_image_list, downscaled_filepaths 

87 ) 

88 else: 

89 filtered_image_list = input_image_list 

90 

91 # Create the target directory if it doesn't exist 

92 downscaled_output_dir.mkdir(parents=True, exist_ok=True) 

93 

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}") 

98 

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 ) 

109 

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 ) 

120 

121 downscale_target_dir.mkdir(parents=True, exist_ok=True) 

122 

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 

131 

132 

133def find_new_image_dimensions(test_img_filepath: Path, attrs: DownscalerAttrs): 

134 """Find the new dimensions for downscaling an image. 

135 

136 Args: 

137 test_img_filepath (Path): The path to the test image. 

138 attrs (DownscalerAttrs): Configuration parameters including resolution settings. 

139 

140 Returns: 

141 tuple: new width, new height. 

142 """ 

143 

144 with Image.open(test_img_filepath) as img: 

145 width, height = img.size 

146 

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 ) 

161 

162 return new_width, new_height 

163 

164 

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. 

173 

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. 

180 

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 

186 

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 

196 

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 

209 

210 

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. 

219 

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 """ 

230 

231 with Image.open(image) as img: 

232 # width, height = img.size 

233 

234 # # # Crop original image prior to downscaling 

235 # left, right = calculate_crop(width, new_width) 

236 # top, bottom = calculate_crop(height, new_height) 

237 

238 # cropped_img = img.crop((left, top, right, bottom)) 

239 

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)) 

245 

246 

247def calculate_crop(original_size: int, new_size: int): 

248 """Calculate the crop dimensions. 

249 

250 Args: 

251 original_size (int): The original size (width or height). 

252 new_size (int): The new size after downscaling. 

253 

254 Returns: 

255 tuple: crop dimensions for each size (e.g. left or right). 

256 """ 

257 

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 

265 

266 return left, right 

267 

268 

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. 

277 

278 Parameters 

279 ---------- 

280 configfile : Path 

281 The path to the YAML configuration file containing parameters for downscaling images. 

282 

283 Returns 

284 ------- 

285 None 

286 This function does not return a value. It performs image processing and outputs timing information. 

287 

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. 

292 

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 

301 

302 CLI 

303 --- 

304 >>> python -m precon3d.precon3d_downscale process_images "path/to/config.yml" 

305 """ 

306 

307 # cast to Path object if not already a Path object 

308 if not isinstance(configfile, Path): 

309 configfile = Path(configfile) 

310 

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 ) 

317 

318 tic = time.perf_counter() 

319 

320 downscale_image_stack(config) 

321 

322 toc = time.perf_counter() 

323 

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 ) 

330 

331 

332@app.callback() 

333def callback(): 

334 """ 

335 precon3d.downscaler downscales images. 

336 """ 

337 

338 

339if __name__ == "__main__": 

340 app()