Coverage for src/precon3d/utility.py: 29%

318 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 03:50 +0000

1"""This module holds utilties that can be reused across other modules, for example the UTC date/time stamp.""" 

2 

3# Default python modules 

4import re 

5import glob 

6from pathlib import Path 

7import os 

8import platform 

9import pytest 

10import typer 

11from PIL import Image 

12 

13Image.MAX_IMAGE_PIXELS = None 

14from concurrent.futures import ThreadPoolExecutor 

15 

16# import time 

17import skimage 

18import numpy as np 

19import datetime 

20import yaml 

21from enum import Enum, IntEnum 

22from typing import NamedTuple, Final, Dict, Any, List 

23 

24# pylint: disable=wildcard-import 

25from precon3d.custom_types import * 

26from precon3d._my_typer_cli import CustomCLIGroup, CustomCLICommand 

27 

28# CLI 

29app = typer.Typer( 

30 cls=CustomCLIGroup, 

31 no_args_is_help=True, 

32 short_help="Utilities for file management", 

33) 

34 

35 

36def valid_enum_entry(obj: Any, check_type: Enum) -> bool: 

37 """ 

38 Determine if an object is a member of an Enum class. 

39 

40 This function checks if an object is a member of an Enum class. 

41 

42 Parameters 

43 ---------- 

44 obj : Any 

45 The object to check. 

46 check_type : Enum 

47 The Enum class to check against. 

48 

49 Returns 

50 ------- 

51 bool 

52 True if the object is a member of the Enum class, False otherwise. 

53 """ 

54 return obj in check_type._value2member_map_ 

55 

56 

57def user_settings() -> UserSettings: 

58 """Constructs a UserSettings NamedTuple.""" 

59 fin: Final[str] = "precon3d_user_settings.yml" 

60 user_home = Path.home() 

61 pin = user_home.joinpath(fin) 

62 # assert pin.is_file(), f"File not found: {pin}" 

63 

64 config_dict = read_config(pin) 

65 # for k, v in config_dict.items(): 

66 # print(f"\nChecking key: {k}, value: {v}") 

67 # assert Path(v).exists(), f"Does not exist: {v}" 

68 

69 us = UserSettings( 

70 fiji_app=Path(config_dict["fiji_app"]), 

71 home=Path(config_dict["home"]), 

72 scratch=Path(config_dict["scratch"]), 

73 ) 

74 

75 return us 

76 

77 

78# UTILITY TYPES - enumerate these to avoid magic numbers 

79class RGB(IntEnum): 

80 """Constant axis ordering in Python for color images 

81 stored in 3D arrays (rows,cols,channels) ordering. 

82 The channels of the color are ordered R, G, B.""" 

83 

84 R: int = 0 

85 G: int = 1 

86 B: int = 2 

87 

88 

89# UTILITY FUNCTIONS 

90def current_date_and_time() -> str: 

91 """print date and time for logging""" 

92 

93 now = datetime.now() 

94 formatted_now = now.strftime("%m/%d/%Y %H:%M:%S") 

95 

96 print(formatted_now) 

97 

98 return formatted_now 

99 

100 

101def natural_key(string_): 

102 """https://stackoverflow.com/questions/2545532/python-analog-of-phps-natsort-function-sort-a-list-using-a-natural-order-alg""" 

103 return [int(s) if s.isdigit() else s for s in re.split(r"(\d+)", string_)] 

104 

105 

106def convert_gray_to_8bit(image_array, gamma=1.0): 

107 """ 

108 Convert a NumPy array image to 8-bit grayscale while preserving the histogram and applying gamma correction. 

109 """ 

110 if image_array.dtype == np.uint16: 

111 # Normalize the 16-bit array to the range 0-1 

112 image_array = image_array / 65535.0 

113 elif image_array.dtype == np.uint32: 

114 # Normalize the 32-bit array to the range 0-1 

115 image_array = image_array / 4294967295.0 

116 elif image_array.dtype == np.float32 or image_array.dtype == np.float64: 

117 if np.max(image_array) < 256: 

118 image_array = image_array / 255.0 

119 elif np.max(image_array) > 255 and np.max(image_array) < 65535: 

120 image_array = image_array / 65535.0 

121 elif np.max(image_array) > 65536 and np.max(image_array) < 4294967295: 

122 image_array = image_array / 4294967295.0 

123 else: 

124 image_array = image_array / np.max(image_array) 

125 # Ensure the float array is in the range 0-1 

126 image_array = np.clip(image_array, 0, 1) 

127 else: 

128 # If the image is already 8-bit, no conversion is needed 

129 return image_array.astype(np.uint8) 

130 

131 # Apply histogram equalization 

132 # image_array = skimage.exposure.equalize_hist(image_array) 

133 

134 # Apply gamma correction 

135 if gamma != 1.0: 

136 image_array = skimage.exposure.adjust_gamma(image_array, gamma) 

137 

138 # Rescale intensity to use the full range of 8-bit 

139 p_low, p_high = np.percentile(image_array, (0.01, 99.99)) 

140 image_array = skimage.exposure.rescale_intensity( 

141 image_array, in_range=(p_low, p_high) 

142 ) 

143 

144 # Scale to 8-bit 

145 image_array = (image_array * 255).astype(np.uint8) 

146 return image_array 

147 

148 

149def read_config(config_file_path: Path) -> dict: 

150 """ 

151 Read a YAML configuration file and return its contents as a dictionary. 

152 

153 Parameters 

154 ---------- 

155 config_file_path : Path 

156 The path to the YAML configuration file to be read. 

157 

158 Returns 

159 ------- 

160 dict 

161 A dictionary containing the contents of the YAML file. 

162 If the file cannot be read or is not valid YAML, returns None. 

163 

164 Raises 

165 ------ 

166 FileNotFoundError 

167 If the specified file does not exist. 

168 

169 Examples 

170 -------- 

171 >>> config = read_config(Path("config.yml")) 

172 >>> print(config) 

173 {'key': 'value'} 

174 """ 

175 with open(config_file_path, "r", encoding="utf-8") as stream: 

176 try: 

177 config_dict = yaml.safe_load(stream) 

178 # print(config_dict) 

179 except yaml.YAMLError as exc: 

180 raise exc 

181 return config_dict 

182 

183 

184@app.command( 

185 cls=CustomCLICommand, 

186 name="n_files", 

187 short_help="Get the number of files with the corresponding extension in the directory.", 

188) 

189def n_files(directory: Path, file_extension: str): 

190 """ 

191 how many files of are in the directory with the corresponding file extension 

192 """ 

193 

194 n_files_found = len(sorted_files(directory, file_extension)) 

195 print(f"Found {n_files_found} {file_extension} files in {directory}") 

196 

197 return n_files_found 

198 

199 

200def sorted_files(directory: Path, file_extension: str) -> list[Path]: 

201 """ 

202 Sort files in a directory using a natural key (ie 1,2,...10,11,12... not 1,10,11,12,...,2...) 

203 returning list of Path objects 

204 

205 Args: 

206 directory: folder containing images 

207 file_extension: a suffix at the end of a computer file. 

208 It comes after the period and is usually two to four characters 

209 

210 Returns: 

211 file_list: sorted sequence of Path objects of specified file extension 

212 

213 """ 

214 

215 if directory.exists() is False: 

216 raise FileNotFoundError(f"Sorry, {directory} not found.") 

217 

218 file_list = list(directory.rglob(f"*{file_extension}")) 

219 file_list = [str(item) for item in file_list] 

220 file_list = sorted(file_list, key=natural_key) 

221 file_list = [Path(item) for item in file_list] 

222 

223 return file_list 

224 

225 

226def remove_matching_filenames( 

227 filelist_source: list[Path], filelist_dest: list[Path] 

228) -> list[Path]: 

229 """ 

230 Removes files from filelist_source that have a matching file name in filelist_dest. 

231 

232 Parameters: 

233 filelist_source (List[Path]): List of source file paths. 

234 filelist_dest (List[Path]): List of destination file paths to compare against. 

235 

236 Returns: 

237 List[Path]: A list of file paths from filelist_source with no matching file names in filelist_dest. 

238 """ 

239 

240 # Create a set of file names (stems) from the destination file list 

241 dest_filenames = {file.stem for file in filelist_dest} 

242 

243 # Filter out files from the source list whose names appear in the destination list 

244 # filtered_filelist = [file for file in filelist_source if file.stem not in dest_filenames] 

245 

246 # Filter out files from the source list whose names are substrings of any name in the destination list 

247 filtered_filelist = [ 

248 file 

249 for file in filelist_source 

250 if not any(file.stem in dest_name for dest_name in dest_filenames) 

251 ] 

252 

253 print( 

254 f"Filtering the input files for existence is destination directory:\n\t{len(filelist_source)} files in {filelist_source[0].parent}\n\t{len(filelist_dest)} files in {filelist_dest[0].parent}\n\t{len(filtered_filelist)} files for processing after filtering" 

255 ) 

256 

257 return filtered_filelist 

258 

259 

260def downselect_paths(path: Path, keyword: str) -> list[Path]: 

261 """downselect the list of Paths and only 

262 keep the Paths containing the keyword""" 

263 

264 # paths = path.rglob("*") # 

265 paths = glob.glob(f"{str(path.as_posix())}/**/", recursive=True) 

266 

267 # downselected_paths = glob.glob(f"{paths}/*{keyword}*/") 

268 downselected_paths = [] 

269 for each_path in paths: 

270 glob_path = glob.glob(f"{each_path}/*{keyword}*/") 

271 if glob_path: 

272 for path in glob_path: 

273 downselected_paths.append(Pat) 

274 

275 # for path in downselected_paths: 

276 # path = Pat 

277 

278 return downselected_paths 

279 

280 

281def rmdir(directory: Path) -> None: 

282 """ 

283 Recursively delete a directory and all its contents. 

284 (credit to: https://stackoverflow.com/questions/13118029/deleting-folders-in-python-recursively/49782093#49782093) 

285 

286 

287 This function deletes the specified directory and all its contents, including 

288 subdirectories and files. If the directory does not exist, the function does nothing. 

289 

290 Parameters 

291 ---------- 

292 directory : Path 

293 The path to the directory to be deleted. 

294 

295 Returns 

296 ------- 

297 None 

298 

299 Examples 

300 -------- 

301 >>> from pathlib import Path 

302 >>> directory = Path("path/to/directory") 

303 >>> rmdir(directory) 

304 """ 

305 

306 if not directory.exists(): 

307 return 

308 

309 for item in directory.iterdir(): 

310 if item.is_dir(): 

311 rmdir(item) 

312 else: 

313 item.unlink() 

314 directory.rmdir() 

315 

316 

317# Custom function to convert the string to a list of floats 

318def string_to_float_list(s): 

319 """ 

320 Convert a string representation of a list into a list of floats. 

321 

322 Parameters 

323 ---------- 

324 s : str 

325 A string representation of a list of numbers, e.g., "[1.0, 2.5, 3.3]". 

326 

327 Returns 

328 ------- 

329 list of float 

330 A list containing the float values extracted from the input string. 

331 

332 Examples 

333 -------- 

334 >>> string_to_float_list("[1.0, 2.5, 3.3]") 

335 [1.0, 2.5, 3.3] 

336 

337 >>> string_to_float_list("[4.0, 5.1]") 

338 [4.0, 5.1] 

339 

340 >>> string_to_float_list("[]") 

341 [] 

342 

343 >>> string_to_float_list("[10, 20, 30]") 

344 [10.0, 20.0, 30.0] 

345 """ 

346 numbers_str = s.strip("[]").split() 

347 return [float(num) for num in numbers_str] 

348 

349 

350def yes_no(question): 

351 """Simple Yes/No Function.""" 

352 prompt = f"{question} (y/n): " 

353 ans = input(prompt).strip().lower() 

354 if ans not in ["y", "n"]: 

355 print(f"{ans} is invalid, please try again...") 

356 return yes_no(question) 

357 if ans == "y": 

358 return True 

359 return False 

360 

361 

362@app.command( 

363 cls=CustomCLICommand, 

364 name="prepend_filenames", 

365 short_help="Rename files in directory by adding some prefix.", 

366) 

367def prepend_filenames(directory: Path, extension: str, prefix: str): 

368 """Rename files in directory 

369 

370 CLI 

371 --- 

372 >>> python -m precon3d.utility prepend_filenames "path/to/config.yml" .extension 'prefix' 

373 """ 

374 

375 file_paths = sorted_files(directory=directory, file_extension=extension) 

376 

377 print(f"Found {len(file_paths)} '{extension}' files") 

378 

379 for each_file in file_paths: 

380 # Check if it's a file (not a directory) 

381 if os.path.isfile(each_file): 

382 new_file_name = f"{prefix}{each_file.stem}{extension}" 

383 new_file_path = each_file.parent.joinpath(new_file_name) 

384 

385 os.rename(each_file, new_file_path) 

386 

387 print(f"all files prepended with '{prefix}' ") 

388 

389 

390@app.command( 

391 cls=CustomCLICommand, 

392 name="rename_files_sequentially", 

393 short_help="Rename files in directory sequentially (e.g. 0001.tif, 0002.tif, etc.).", 

394) 

395def rename_files_sequentially(directory: Path, extension: str): 

396 """Rename files in directory starting from 0 to n_files 

397 

398 e.g. my_file_01.tif, my_file_01.tif,... to 

399 0001.tif, 0002.tif 

400 

401 Args: 

402 directory: _description_ 

403 extension: _description_ 

404 

405 CLI 

406 --- 

407 >>> python -m precon3d.utility rename_files_sequentially "path/to/config.yml" .extension 

408 """ 

409 

410 file_paths = sorted_files(directory=directory, file_extension=extension) 

411 

412 print(f"Found {len(file_paths)} '{extension}' files") 

413 

414 for count, each_file in enumerate(file_paths): 

415 # Check if it's a file (not a directory) 

416 if os.path.isfile(each_file): 

417 new_file_name = f"{count:04d}{extension}" 

418 new_file_path = os.path.join(directory, new_file_name) 

419 

420 os.rename(each_file, new_file_path) 

421 

422 print("all files sequentially renamed") 

423 

424 

425@app.command( 

426 cls=CustomCLICommand, 

427 name="check_image_sizes", 

428 short_help="Check if all images in the directory have the same dimensions.", 

429) 

430def check_image_sizes(input_dir: Path) -> tuple[int, int] | None: 

431 """ 

432 Check if all images in the directory have the same dimensions. 

433 

434 Args: 

435 input_dir (Path): The directory containing the images to check. 

436 

437 Returns: 

438 tuple[int, int] | None: The dimensions (width, height) if all images have the same size, 

439 None if images have different sizes. 

440 

441 Raises: 

442 FileNotFoundError: If the input directory does not exist or is not accessible. 

443 ValueError: If no images are found in the specified directory. 

444 """ 

445 # Retrieve the list of image file paths with the specified extension 

446 filepaths_list = sorted_files(input_dir, ".tif") 

447 

448 # Count the number of images found 

449 n_images = len(filepaths_list) 

450 print(f"Found {n_images} images") 

451 

452 # Helper function to retrieve the size of an image 

453 def get_image_size(filepath: Path) -> tuple[int, int]: 

454 with Image.open(filepath) as img: 

455 return img.size # Returns (width, height) 

456 

457 # Use ThreadPoolExecutor to parallelize the retrieval of image sizes 

458 with ThreadPoolExecutor() as executor: 

459 all_image_sizes = list(executor.map(get_image_size, filepaths_list)) 

460 

461 # Check if all images have the same size 

462 first_image_size = all_image_sizes[0] 

463 all_same_size = all(size == first_image_size for size in all_image_sizes) 

464 

465 if all_same_size: 

466 print(f"All images have the same size: {first_image_size}") 

467 return first_image_size 

468 else: 

469 print("Images have different sizes:") 

470 for idx, size in enumerate(all_image_sizes, start=1): 

471 print(f"\tImage {idx}: {size}") 

472 return None 

473 

474 

475def determine_image_type(file_name: str) -> str: 

476 """ 

477 Determines whether the image is 'Bright' or 'Dark' based on the filename. 

478 

479 Parameters: 

480 file_name: The name of the file. 

481 

482 Returns: 

483 str: 'Bright' if the filename contains 'Bright', 'Dark' if it contains 'Dark', or 'Unknown'. 

484 """ 

485 if "bright" in file_name.lower(): 

486 return "Bright" 

487 elif "dark" in file_name.lower(): 

488 return "Dark" 

489 return "Unknown" 

490 

491 

492@app.command( 

493 cls=CustomCLICommand, 

494 name="reorganize_images_by_type", 

495 short_help="Separating images into 'Bright' and 'Dark' subdirectories", 

496) 

497def reorganize_images_by_type(base_dir: Path): 

498 """ 

499 Reorganizes images by separating them into 'Bright' and 'Dark' subdirectories 

500 without resizing and without prepending any values to the filenames. 

501 

502 Parameters: 

503 base_dir: The base directory containing the images. 

504 """ 

505 

506 # Iterate through all .tif files in the base directory (not subdirectories) 

507 for file_path in base_dir.glob("*.tif"): 

508 # Determine whether the image is 'Bright' or 'Dark' 

509 image_type = determine_image_type(file_path.name) 

510 

511 # Skip files with unknown type 

512 if image_type == "Unknown": 

513 print(f"Skipping file with unknown type: {file_path}") 

514 continue 

515 

516 # Create the subdirectory for the image type (Bright/Dark) 

517 type_subdir = base_dir / image_type 

518 type_subdir.mkdir(parents=True, exist_ok=True) 

519 

520 # Define the new file path in the appropriate subdirectory 

521 new_file_path = type_subdir / file_path.name 

522 

523 # Move the file to the new location 

524 try: 

525 file_path.rename(new_file_path) 

526 print(f"Moved: {file_path} to {new_file_path}") 

527 except FileNotFoundError as e: 

528 print(f"Error moving {file_path}: {e}") 

529 except Exception as e: 

530 print(f"An error occurred while moving {file_path}: {e}") 

531 

532 

533@app.command( 

534 cls=CustomCLICommand, 

535 name="reorganize_images_by_keyword", 

536 short_help="Reorganize images by creating a subdirectory based on a user-defined keyword found in the filenames", 

537) 

538def reorganize_images_by_keyword(base_dir: Path, keyword: str): 

539 """ 

540 Reorganize images by creating a subdirectory based on a user-defined keyword found in the filenames. 

541 

542 Parameters: 

543 base_dir: The base directory containing the images. 

544 keyword: The keyword to look for in the filenames. 

545 """ 

546 # Create the subdirectory for the specified keyword 

547 keyword_subdir = base_dir / keyword 

548 keyword_subdir.mkdir(parents=True, exist_ok=True) 

549 

550 # Iterate through all .tif files in the base directory and its subdirectories 

551 for file_path in base_dir.rglob("*.tif"): 

552 # Check if the keyword is in the filename 

553 if keyword in file_path.name: 

554 # Define the new file path in the appropriate subdirectory 

555 new_file_path = keyword_subdir / file_path.name 

556 

557 # Move the file to the new location 

558 try: 

559 file_path.rename(new_file_path) 

560 print(f"Moved: {file_path} to {new_file_path}") 

561 except FileNotFoundError as e: 

562 print(f"Error moving {file_path}: {e}") 

563 except Exception as e: 

564 print(f"An error occurred while moving {file_path}: {e}") 

565 else: 

566 print( 

567 f"Keyword '{keyword}' not found in filename: {file_path.name}" 

568 ) 

569 

570 

571def find_missing_files( 

572 directory: str, pattern: str = r"Slice_(\d+)_.*\.tif" 

573) -> None: 

574 """ 

575 Scans a directory for files matching a specific pattern and identifies any missing files in a numeric sequence. 

576 

577 Parameters: 

578 directory: The path to the directory containing the files to be checked. 

579 pattern: The regular expression pattern used to extract the numeric part from the filenames. 

580 

581 Returns: 

582 None: Outputs the results directly to the console. 

583 """ 

584 

585 print(f"Checking {directory} for missing files") 

586 try: 

587 # List all files in the directory 

588 files = os.listdir(directory) 

589 except FileNotFoundError: 

590 print(f"The directory {directory} does not exist.") 

591 return 

592 except PermissionError: 

593 print(f"Permission denied to access the directory {directory}.") 

594 return 

595 

596 # Use regular expression to find numbers after 'Slice_' 

597 regex = re.compile(pattern) 

598 numbers: List[int] = [] 

599 

600 for file in files: 

601 match = regex.search(file) 

602 if match: 

603 number = int( 

604 match.group(1) 

605 ) # Convert the number part to an integer 

606 numbers.append(number) 

607 

608 # Find missing numbers in the sequence 

609 if numbers: 

610 start, end = min(numbers), max(numbers) 

611 full_set = set(range(start, end + 1)) 

612 missing = full_set - set(numbers) 

613 if missing: 

614 print("Missing files:") 

615 for m in sorted(missing): 

616 print(f"Slice_{m:04d}_...") 

617 else: 

618 print("No files are missing.") 

619 else: 

620 print("No relevant files found.") 

621 

622 

623def extract_significant_part(filename: str) -> str: 

624 """ 

625 Extracts the significant parts of the filename using a regular expression. 

626 Assumes the significant parts include 'run' and 'Slice_' with the sequence number. 

627 

628 Parameters: 

629 filename: The filename from which to extract the significant parts. 

630 

631 Returns: 

632 str: The significant parts of the filename. 

633 """ 

634 # match = re.search(r'(run\d+).*?(Slice_\d+)', filename) 

635 match = re.search(r"(Slice_\d+)", filename) 

636 if match: 

637 # Concatenate the significant parts for comparison 

638 return "_".join(match.groups()) 

639 return "" 

640 

641 

642@app.command( 

643 cls=CustomCLICommand, 

644 name="compare_directories", 

645 short_help="Compares files in two directories to identify any missing files", 

646) 

647def compare_directories(dir1: Path, dir2: Path) -> None: 

648 """ 

649 Compares files in two directories and prints out which files are missing in the second directory. 

650 

651 Parameters: 

652 dir1 (Path): Path to the first directory. 

653 dir2 (Path): Path to the second directory. 

654 

655 Returns: 

656 None: Outputs the results directly to the console. 

657 """ 

658 # Ensure the inputs are Path objects 

659 dir1 = Path(dir1) 

660 dir2 = Path(dir2) 

661 

662 # Extract significant parts of filenames for comparison 

663 files1 = {extract_significant_part(f.name) for f in dir1.glob("*.tif")} 

664 files2 = {extract_significant_part(f.name) for f in dir2.glob("*.tif")} 

665 

666 # Find missing files 

667 missing_files = files1 - files2 

668 

669 print( 

670 f"Checking which files ({len(missing_files)}) in \n\t{dir1}\nare missing in \n\t{dir2}" 

671 ) 

672 

673 if missing_files: 

674 print(f"\nMissing files in {dir2}:") 

675 for file in sorted(missing_files): 

676 print(file) 

677 else: 

678 print(f"\nNo files are missing in {dir2}. \n{len(files2)} total files") 

679 

680 

681def crop_to_dim(image_dir: Path, new_shape: tuple): 

682 """ 

683 Crop all .tif images in the specified directory evenly to the specified dimensions. 

684 

685 This function processes all `.tif` image files in the given directory, cropping them 

686 to the specified width and height. The cropping is performed symmetrically 

687 from the center of the image, with adjustments for odd dimensions by cropping 

688 extra pixels from the right and bottom. 

689 

690 Parameters: 

691 dir: The directory containing the `.tif` images to be cropped. Must be a valid Path object. 

692 new_shape (tuple): A tuple specifying the desired dimensions (width, height) for the cropped images. 

693 

694 Returns: 

695 None: The function modifies the images in place and saves the cropped versions 

696 back to the same directory. 

697 

698 Raises: 

699 ValueError: If `new_shape` is not a tuple of two positive integers. 

700 FileNotFoundError: If the specified directory does not exist. 

701 Exception: If an image file cannot be processed. 

702 

703 Example: 

704 >>> from pathlib import Path 

705 >>> crop_to_dim(Path("/path/to/images"), (200, 200)) 

706 Crops all `.tif` images in the directory "/path/to/images" to 200x200 pixels. 

707 

708 Notes: 

709 - This function only processes `.tif` images. 

710 - Ensure you have write permissions for the directory to save the cropped images. 

711 

712 """ 

713 # Validate inputs 

714 if not isinstance(image_dir, Path): 

715 raise TypeError("The 'image_dir' parameter must be a Path object.") 

716 if not image_dir.exists(): 

717 raise FileNotFoundError(f"The directory '{image_dir}' does not exist.") 

718 

719 height, width = new_shape 

720 

721 # Process each .tif image in the directory 

722 for image_path in image_dir.iterdir(): 

723 if image_path.is_file() and image_path.suffix.lower() == ".tif": 

724 try: 

725 with Image.open(image_path) as img: 

726 # Get original dimensions 

727 img_width, img_height = img.size 

728 

729 # Calculate cropping box 

730 left = (img_width - width) // 2 

731 top = (img_height - height) // 2 

732 right = left + width 

733 bottom = top + height 

734 

735 # Adjust for odd dimensions 

736 if img_width % 2 != 0: 

737 right -= 1 # Crop extra pixel from the right 

738 if img_height % 2 != 0: 

739 bottom -= 1 # Crop extra pixel from the bottom 

740 

741 # Ensure the crop box is within bounds 

742 if ( 

743 left < 0 

744 or top < 0 

745 or right > img_width 

746 or bottom > img_height 

747 ): 

748 raise ValueError( 

749 f"Cannot crop image {image_path.name} to dimensions {new_shape}." 

750 ) 

751 

752 # Perform cropping 

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

754 

755 # Save the cropped image back to the same file 

756 cropped_img.save(image_path) 

757 except Exception as e: 

758 print(f"Error processing file {image_path}: {e}") 

759 

760 

761def image_min_extent(input_dir: Path): 

762 """Retrieve the minimum image dimensions from a list of file paths.""" 

763 

764 filepaths_list = sorted_files(input_dir, ".tif") 

765 

766 n_images = len(filepaths_list) 

767 print(f"Found {n_images} images") 

768 

769 def get_image_size(filepath): 

770 with Image.open(filepath) as img: 

771 return img.size # Returns (width, height) 

772 

773 # Use ThreadPoolExecutor to parallelize the retrieval of image sizes 

774 with ThreadPoolExecutor() as executor: 

775 all_image_sizes = list(executor.map(get_image_size, filepaths_list)) 

776 

777 # Convert (width, height) to (height, width) and find min dimensions 

778 all_image_sizes = [(height, width) for width, height in all_image_sizes] 

779 min_image_extent = np.min(all_image_sizes, axis=0) 

780 print(f"\tMin extent: {min_image_extent} pixels") 

781 

782 return tuple(min_image_extent) 

783 

784 

785def get_unique_lowest_level_subfolder_names(directory): 

786 """ 

787 Retrieve the unique names of the lowest level subfolders within a specified directory 

788 and return them as a list. 

789 

790 This function traverses all subdirectories of the given directory and identifies 

791 the lowest level subfolders (i.e., those that do not contain any other subdirectories). 

792 It returns a list of unique names of these lowest level subfolders, ensuring that 

793 each name appears only once in the list. 

794 

795 Parameters: 

796 directory: The path to the directory to search within. 

797 

798 Returns: 

799 list: A list of unique subfolder names at the lowest level. 

800 

801 """ 

802 

803 # Convert the input path to a Path object 

804 base_path = Path(directory) 

805 

806 # Set to hold the unique names of the lowest level subfolders 

807 unique_subfolder_names = set() 

808 

809 # Verify that the base path exists and is a directory 

810 if not base_path.exists() or not base_path.is_dir(): 

811 print( 

812 f"The specified path {directory} does not exist or is not a directory." 

813 ) 

814 return set() 

815 

816 # Walk through the directory tree using rglob to find all directories 

817 for path in base_path.rglob("*"): 

818 # Check if the current path is a directory and does not contain any subdirectories 

819 if path.is_dir() and not any(p.is_dir() for p in path.iterdir()): 

820 # Add the name of the directory to the set (automatically handles duplicates) 

821 unique_subfolder_names.add(path.name) 

822 

823 # Return the set of unique subfolder names 

824 return sorted(list(unique_subfolder_names)) 

825 

826 

827@app.command( 

828 cls=CustomCLICommand, 

829 name="merge_bf_df", 

830 short_help="Merge brightfield images and darkfield images", 

831) 

832def merge_bf_df( 

833 bf_dir: Path, df_dir: Path, output_dir: Path, file_extension: str = ".tif" 

834) -> None: 

835 """ 

836 Merge brightfield images and darkfield images by taking the max of the pixels. 

837 

838 Args: 

839 bf_dir (Path): Directory containing brightfield images. 

840 df_dir (Path): Directory containing darkfield images. 

841 output_dir (Path): Directory to save the merged images. 

842 file_extension (str): File extension of the images (default is '.tif'). 

843 

844 Raises: 

845 ValueError: If the images in BF and DF directories have mismatched sizes or counts. 

846 """ 

847 # Check all images have the same size 

848 bf_sizes = check_image_sizes(bf_dir) 

849 df_sizes = check_image_sizes(df_dir) 

850 

851 if bf_sizes != df_sizes: 

852 raise ValueError( 

853 f"Image size mismatch: BF directory has size {bf_sizes}, DF directory has size {df_sizes}" 

854 ) 

855 

856 # Check equivalent number of files 

857 bf_imgs = sorted_files(bf_dir, file_extension) 

858 df_imgs = sorted_files(df_dir, file_extension) 

859 

860 if len(bf_imgs) != len(df_imgs): 

861 compare_directories(bf_dir, df_dir) 

862 raise ValueError( 

863 f"BF directory has {len(bf_imgs)} images, DF directory has {len(df_imgs)} images" 

864 ) 

865 

866 # Ensure output directory exists 

867 output_dir.mkdir(parents=True, exist_ok=True) 

868 

869 # Process and merge images 

870 for bf_img_path, df_img_path in zip(bf_imgs, df_imgs): 

871 # Open BF and DF images 

872 with ( 

873 Image.open(bf_img_path) as bf_img, 

874 Image.open(df_img_path) as df_img, 

875 ): 

876 # Convert images to NumPy arrays for pixel-wise operations 

877 bf_array = np.array(bf_img) 

878 df_array = np.array(df_img) 

879 

880 # Take the maximum of each pixel 

881 merged_array = np.maximum(bf_array, df_array) 

882 

883 # Convert the merged array back to an image 

884 merged_img = Image.fromarray(merged_array) 

885 

886 # Save the merged image to the output directory 

887 output_file_name = bf_img_path.name # Use the BF image's filename 

888 output_file_path = output_dir / output_file_name 

889 merged_img.save(output_file_path) 

890 

891 print(f"Merged images saved to {output_dir}") 

892 

893 

894def run_on_local_machine(func): 

895 """pytest only on local machine 

896 

897 Args: 

898 func (_type_): function to be tested 

899 """ 

900 

901 def wrapper_func(): 

902 current_machine = platform.uname().node.lower() 

903 test_machines = ["s1059904"] 

904 if current_machine not in test_machines: 

905 pytest.skip("Run on Local Machine Only.") 

906 func() 

907 

908 return wrapper_func 

909 

910 

911@app.callback() 

912def callback(): 

913 """ 

914 precon3d.utility provides tools for file management. 

915 """ 

916 

917 

918if __name__ == "__main__": 

919 app()