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
« 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."""
3# Default python modules
4import re
5import glob
6from pathlib import Path
7import os
8import platform
9import pytest
10import typer
11from PIL import Image
13Image.MAX_IMAGE_PIXELS = None
14from concurrent.futures import ThreadPoolExecutor
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
24# pylint: disable=wildcard-import
25from precon3d.custom_types import *
26from precon3d._my_typer_cli import CustomCLIGroup, CustomCLICommand
28# CLI
29app = typer.Typer(
30 cls=CustomCLIGroup,
31 no_args_is_help=True,
32 short_help="Utilities for file management",
33)
36def valid_enum_entry(obj: Any, check_type: Enum) -> bool:
37 """
38 Determine if an object is a member of an Enum class.
40 This function checks if an object is a member of an Enum class.
42 Parameters
43 ----------
44 obj : Any
45 The object to check.
46 check_type : Enum
47 The Enum class to check against.
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_
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}"
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}"
69 us = UserSettings(
70 fiji_app=Path(config_dict["fiji_app"]),
71 home=Path(config_dict["home"]),
72 scratch=Path(config_dict["scratch"]),
73 )
75 return us
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."""
84 R: int = 0
85 G: int = 1
86 B: int = 2
89# UTILITY FUNCTIONS
90def current_date_and_time() -> str:
91 """print date and time for logging"""
93 now = datetime.now()
94 formatted_now = now.strftime("%m/%d/%Y %H:%M:%S")
96 print(formatted_now)
98 return formatted_now
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_)]
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)
131 # Apply histogram equalization
132 # image_array = skimage.exposure.equalize_hist(image_array)
134 # Apply gamma correction
135 if gamma != 1.0:
136 image_array = skimage.exposure.adjust_gamma(image_array, gamma)
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 )
144 # Scale to 8-bit
145 image_array = (image_array * 255).astype(np.uint8)
146 return image_array
149def read_config(config_file_path: Path) -> dict:
150 """
151 Read a YAML configuration file and return its contents as a dictionary.
153 Parameters
154 ----------
155 config_file_path : Path
156 The path to the YAML configuration file to be read.
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.
164 Raises
165 ------
166 FileNotFoundError
167 If the specified file does not exist.
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
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 """
194 n_files_found = len(sorted_files(directory, file_extension))
195 print(f"Found {n_files_found} {file_extension} files in {directory}")
197 return n_files_found
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
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
210 Returns:
211 file_list: sorted sequence of Path objects of specified file extension
213 """
215 if directory.exists() is False:
216 raise FileNotFoundError(f"Sorry, {directory} not found.")
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]
223 return file_list
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.
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.
236 Returns:
237 List[Path]: A list of file paths from filelist_source with no matching file names in filelist_dest.
238 """
240 # Create a set of file names (stems) from the destination file list
241 dest_filenames = {file.stem for file in filelist_dest}
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]
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 ]
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 )
257 return filtered_filelist
260def downselect_paths(path: Path, keyword: str) -> list[Path]:
261 """downselect the list of Paths and only
262 keep the Paths containing the keyword"""
264 # paths = path.rglob("*") #
265 paths = glob.glob(f"{str(path.as_posix())}/**/", recursive=True)
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)
275 # for path in downselected_paths:
276 # path = Pat
278 return downselected_paths
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)
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.
290 Parameters
291 ----------
292 directory : Path
293 The path to the directory to be deleted.
295 Returns
296 -------
297 None
299 Examples
300 --------
301 >>> from pathlib import Path
302 >>> directory = Path("path/to/directory")
303 >>> rmdir(directory)
304 """
306 if not directory.exists():
307 return
309 for item in directory.iterdir():
310 if item.is_dir():
311 rmdir(item)
312 else:
313 item.unlink()
314 directory.rmdir()
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.
322 Parameters
323 ----------
324 s : str
325 A string representation of a list of numbers, e.g., "[1.0, 2.5, 3.3]".
327 Returns
328 -------
329 list of float
330 A list containing the float values extracted from the input string.
332 Examples
333 --------
334 >>> string_to_float_list("[1.0, 2.5, 3.3]")
335 [1.0, 2.5, 3.3]
337 >>> string_to_float_list("[4.0, 5.1]")
338 [4.0, 5.1]
340 >>> string_to_float_list("[]")
341 []
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]
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
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
370 CLI
371 ---
372 >>> python -m precon3d.utility prepend_filenames "path/to/config.yml" .extension 'prefix'
373 """
375 file_paths = sorted_files(directory=directory, file_extension=extension)
377 print(f"Found {len(file_paths)} '{extension}' files")
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)
385 os.rename(each_file, new_file_path)
387 print(f"all files prepended with '{prefix}' ")
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
398 e.g. my_file_01.tif, my_file_01.tif,... to
399 0001.tif, 0002.tif
401 Args:
402 directory: _description_
403 extension: _description_
405 CLI
406 ---
407 >>> python -m precon3d.utility rename_files_sequentially "path/to/config.yml" .extension
408 """
410 file_paths = sorted_files(directory=directory, file_extension=extension)
412 print(f"Found {len(file_paths)} '{extension}' files")
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)
420 os.rename(each_file, new_file_path)
422 print("all files sequentially renamed")
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.
434 Args:
435 input_dir (Path): The directory containing the images to check.
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.
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")
448 # Count the number of images found
449 n_images = len(filepaths_list)
450 print(f"Found {n_images} images")
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)
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))
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)
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
475def determine_image_type(file_name: str) -> str:
476 """
477 Determines whether the image is 'Bright' or 'Dark' based on the filename.
479 Parameters:
480 file_name: The name of the file.
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"
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.
502 Parameters:
503 base_dir: The base directory containing the images.
504 """
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)
511 # Skip files with unknown type
512 if image_type == "Unknown":
513 print(f"Skipping file with unknown type: {file_path}")
514 continue
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)
520 # Define the new file path in the appropriate subdirectory
521 new_file_path = type_subdir / file_path.name
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}")
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.
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)
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
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 )
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.
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.
581 Returns:
582 None: Outputs the results directly to the console.
583 """
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
596 # Use regular expression to find numbers after 'Slice_'
597 regex = re.compile(pattern)
598 numbers: List[int] = []
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)
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.")
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.
628 Parameters:
629 filename: The filename from which to extract the significant parts.
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 ""
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.
651 Parameters:
652 dir1 (Path): Path to the first directory.
653 dir2 (Path): Path to the second directory.
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)
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")}
666 # Find missing files
667 missing_files = files1 - files2
669 print(
670 f"Checking which files ({len(missing_files)}) in \n\t{dir1}\nare missing in \n\t{dir2}"
671 )
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")
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.
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.
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.
694 Returns:
695 None: The function modifies the images in place and saves the cropped versions
696 back to the same directory.
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.
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.
708 Notes:
709 - This function only processes `.tif` images.
710 - Ensure you have write permissions for the directory to save the cropped images.
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.")
719 height, width = new_shape
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
729 # Calculate cropping box
730 left = (img_width - width) // 2
731 top = (img_height - height) // 2
732 right = left + width
733 bottom = top + height
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
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 )
752 # Perform cropping
753 cropped_img = img.crop((left, top, right, bottom))
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}")
761def image_min_extent(input_dir: Path):
762 """Retrieve the minimum image dimensions from a list of file paths."""
764 filepaths_list = sorted_files(input_dir, ".tif")
766 n_images = len(filepaths_list)
767 print(f"Found {n_images} images")
769 def get_image_size(filepath):
770 with Image.open(filepath) as img:
771 return img.size # Returns (width, height)
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))
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")
782 return tuple(min_image_extent)
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.
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.
795 Parameters:
796 directory: The path to the directory to search within.
798 Returns:
799 list: A list of unique subfolder names at the lowest level.
801 """
803 # Convert the input path to a Path object
804 base_path = Path(directory)
806 # Set to hold the unique names of the lowest level subfolders
807 unique_subfolder_names = set()
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()
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)
823 # Return the set of unique subfolder names
824 return sorted(list(unique_subfolder_names))
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.
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').
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)
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 )
856 # Check equivalent number of files
857 bf_imgs = sorted_files(bf_dir, file_extension)
858 df_imgs = sorted_files(df_dir, file_extension)
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 )
866 # Ensure output directory exists
867 output_dir.mkdir(parents=True, exist_ok=True)
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)
880 # Take the maximum of each pixel
881 merged_array = np.maximum(bf_array, df_array)
883 # Convert the merged array back to an image
884 merged_img = Image.fromarray(merged_array)
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)
891 print(f"Merged images saved to {output_dir}")
894def run_on_local_machine(func):
895 """pytest only on local machine
897 Args:
898 func (_type_): function to be tested
899 """
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()
908 return wrapper_func
911@app.callback()
912def callback():
913 """
914 precon3d.utility provides tools for file management.
915 """
918if __name__ == "__main__":
919 app()