Coverage for src/precon3d/post_stitch_utils.py: 0%
335 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
1import numpy.typing as npt
2from typing import NamedTuple
3from pathlib import Path
4import skimage
5import numpy as np
6import time
7import typer
8import imageio
9import scipy
10from PIL import Image
12Image.MAX_IMAGE_PIXELS = None
13import tifffile
14import re
15from itertools import islice
16from itertools import pairwise
18from joblib import Parallel, delayed
19from concurrent.futures import ThreadPoolExecutor
21import precon3d.utility as ut
23# pylint: disable=wildcard-import
24from precon3d._my_typer_cli import CustomCLIGroup, CustomCLICommand
26# CLI
27app = typer.Typer(
28 cls=CustomCLIGroup,
29 no_args_is_help=True,
30 short_help="Prepare polarized images",
31)
34def rotate_image_deg(image_path: Path, output_dir: Path):
35 """Use XXXdeg in filename to rotate images
37 Args:
38 image_path (Path): The path to the image file.
40 Returns:
41 Image: The rotated image.
42 """
44 # Convert Path to string if not already, to use regex
45 fname_str = str(image_path.stem)
47 # Extract the degree information from the filename using regex
48 match = re.search(r"(\d{1,3})deg", fname_str)
49 if match:
50 degrees = int(match.group(1))
51 else:
52 raise ValueError("No degree information found in filename.")
54 # Load the image
55 with Image.open(image_path) as img:
56 print(f"rotating {image_path.stem} ccw {degrees} degrees")
58 # Rotate the image. The expand flag is used to resize the output to fit the new orientation
59 rotated_img = img.rotate(degrees, expand=False)
61 # save the rotated image or return it
62 save_path = output_dir.joinpath(f"rotated_{image_path.name}")
64 rotated_img.save(save_path)
67def rotate_image_deg_imageio(image_path: Path, degrees):
68 # Read the image
69 img = imageio.v3.imread(image_path)
71 rot_img = scipy.ndimage.rotate(img, degrees, reshape=True, mode="constant")
73 # # Calculate the rotation in radians
74 # radians = np.deg2rad(degrees)
76 # # Perform the rotation
77 # rotated_img = imageio.core.functions.rotate(img, radians, resize=True)
78 fpath_npy = image_path.parent.joinpath(f"rotated_{image_path.stem}.npy")
79 # fpath_tif = image_path.parent.joinpath(f"rotated_{image_path.name}")
81 # Save the rotated image
82 # imageio.v3.imwrite(fpath_tif, rot_img)
83 np.save(fpath_npy, rot_img)
86def rotate_and_save_tiff(
87 image_path, output_path, angle, clip_to_uint8=False, reshape=True
88):
89 """
90 Rotate a high bit-depth TIFF image and save the result.
92 Args:
93 - image_path (str): Path to the input TIFF image.
94 - output_path (str): Path where the rotated image will be saved.
95 - angle (float): Rotation angle in degrees. Positive for counter-clockwise rotation.
96 - reshape (bool): Whether to expand the output image's shape to fit the entire rotated image.
97 """
98 # Read the image
99 with tifffile.TiffFile(image_path) as tif:
100 img_array = tif.asarray()
102 # Rotate the image
103 rotated_img_array = scipy.ndimage.rotate(
104 img_array, angle, reshape=reshape, order=1, mode="constant"
105 )
107 # Find the bounding box of non-zero values
108 non_zero_coords = np.argwhere(
109 rotated_img_array > 0
110 ) # Assuming non-zero values are the content
111 y_min, x_min = non_zero_coords.min(axis=0)
112 y_max, x_max = (
113 non_zero_coords.max(axis=0) + 1
114 ) # +1 because slice end index is exclusive
116 # Crop the image
117 cropped_img_array = rotated_img_array[y_min:y_max, x_min:x_max]
119 # Clip values to the 0-255 range and convert to uint8
120 if clip_to_uint8:
121 cropped_img_array = np.clip(cropped_img_array, 0, 255).astype(np.uint8)
123 # Save the rotated image
124 tifffile.imwrite(output_path, cropped_img_array, photometric="minisblack")
127@app.command(name="rotate_images_by_filename")
128def rotate_images_by_filename(input_dir: Path, output_dir: Path):
129 """
130 rotate images in a directory using the XXXdeg in the filename
131 """
133 mosaic_image_file_list = ut.sorted_files(input_dir, "tif")
135 # Ensure output directory exists
136 output_dir.mkdir(parents=True, exist_ok=True)
138 for each_image in mosaic_image_file_list:
140 # rotate_image_deg(each_image)
141 # Convert Path to string if not already, to use regex
142 fname_str = str(each_image.stem)
143 # print(f"Rotating {fname_str}")
145 # Extract the degree information from the filename using regex
146 match = re.search(r"(\d{1,3})deg", fname_str)
147 if match:
148 degrees = int(match.group(1))
149 else:
150 raise ValueError("No degree information found in filename.")
152 print(f"Applying {degrees} deg rotation to {fname_str}")
154 # output_path = each_image.parent.joinpath("Rotated",f"rotated_{each_image.name}")
155 output_filepath = output_dir.joinpath(f"rotated_{each_image.name}")
157 # rotate_image_deg_imageio(each_image, degrees)
158 rotate_and_save_tiff(
159 each_image, output_path=output_filepath, angle=degrees
160 )
163def rotate_and_save_single_image_by_filename(
164 input_image: Path, output_dir: Path
165):
166 """
167 rotate images in a directory using the XXXdeg in the filename
168 """
170 # rotate_image_deg(each_image)
171 # Convert Path to string if not already, to use regex
172 fname_str = str(input_image.stem)
173 # print(f"Rotating {input_image.name}")
175 # Extract the degree information from the filename using regex
176 match = re.search(r"(\d{1,3})deg", fname_str)
177 if match:
178 degrees = int(match.group(1))
179 else:
180 raise ValueError("No degree information found in filename.")
182 print(f"Applying {degrees} deg rotation to {input_image.name}")
184 # output_path = each_image.parent.joinpath("Rotated",f"rotated_{each_image.name}")
185 output_filepath = output_dir.joinpath(f"rotated_{input_image.name}")
187 # rotate_image_deg_imageio(each_image, degrees)
188 rotate_and_save_tiff(
189 input_image, output_path=output_filepath, angle=degrees
190 )
193def rotate_save_pad_single_image_by_filename(
194 input_image: Path, max_image_extent: np.ndarray, output_dir: Path
195):
196 """
197 rotate images in a directory using the XXXdeg in the filename
198 """
200 # rotate_image_deg(each_image)
201 # Convert Path to string if not already, to use regex
202 fname_str = str(input_image.stem)
203 # print(f"Rotating {input_image.name}")
205 # Extract the degree information from the filename using regex
206 match = re.search(r"(\d{1,3})deg", fname_str)
207 if match:
208 degrees = int(match.group(1))
209 else:
210 raise ValueError("No degree information found in filename.")
212 # output_path = each_image.parent.joinpath("Rotated",f"rotated_{each_image.name}")
213 output_filepath = output_dir.joinpath(
214 f"{input_image.stem}_pad_rot{input_image.suffix}"
215 )
217 # rotate_image_deg_imageio(each_image, degrees)
218 # rotate_and_save_tiff(input_image, output_path=output_filepath,angle=degrees)
219 # Read the image
220 with tifffile.TiffFile(input_image) as tif:
221 img_array = tif.asarray()
223 # Rotate the image
224 rotated_img_array = scipy.ndimage.rotate(
225 img_array, degrees, reshape=True, order=1, mode="constant"
226 )
228 # Ensure the image does not exceed the maximum dimensions
229 cropped_img_array = rotated_img_array[
230 : min(rotated_img_array.shape[0], max_image_extent[0]),
231 : min(rotated_img_array.shape[1], max_image_extent[1]),
232 ]
234 # # Find the bounding box of non-zero values
235 # non_zero_coords = np.argwhere(rotated_img_array > 0) # Assuming non-zero values are the content
236 # y_min, x_min = non_zero_coords.min(axis=0)
237 # y_max, x_max = non_zero_coords.max(axis=0) + 1 # +1 because slice end index is exclusive
239 # # Crop the image
240 # cropped_img_array = rotated_img_array[y_min:y_max, x_min:x_max]
242 # pad_height = max_image_extent[0] - cropped_img_array.shape[0]
243 # pad_width = max_image_extent[1] - cropped_img_array.shape[1]
245 # # Calculate padding for height and width
246 # pad_top = pad_height // 2
247 # pad_bottom = pad_height - pad_top
248 # pad_left = pad_width // 2
249 # pad_right = pad_width - pad_left
251 # Calculate necessary padding
252 pad_height = max(0, max_image_extent[0] - cropped_img_array.shape[0])
253 pad_width = max(0, max_image_extent[1] - cropped_img_array.shape[1])
255 # Calculate padding for height and width
256 pad_top = pad_height // 2
257 pad_bottom = pad_height - pad_top
258 pad_left = pad_width // 2
259 pad_right = pad_width - pad_left
261 # Ensure padding does not exceed the image dimensions
262 pad_top = min(pad_top, max_image_extent[0])
263 pad_bottom = min(pad_bottom, max_image_extent[0] - pad_top)
264 pad_left = min(pad_left, max_image_extent[1])
265 pad_right = min(pad_right, max_image_extent[1] - pad_left)
267 # Apply padding
268 padded_image = np.pad(
269 cropped_img_array,
270 ((pad_top, pad_bottom), (pad_left, pad_right)),
271 mode="constant",
272 constant_values=0,
273 )
275 # Save the padded image
276 skimage.io.imsave(output_filepath, padded_image, check_contrast=False)
278 print(
279 f"Saved {output_filepath.name} after a {degrees} deg rotation and padded to {max_image_extent}"
280 )
283def pad_images_to_max_extent(input_dir: Path, output_dir: Path):
284 """Pad all images to the same size, the size is the max extent found in the images
286 Args:
287 input_dir (Path): Directory containing input images.
288 output_dir (Path): Directory where padded images will be saved.
289 """
290 filepaths_list = ut.sorted_files(
291 directory=input_dir, file_extension=".tif"
292 )
293 n_images = len(filepaths_list)
294 print(f"Found {n_images} images")
296 # Collect all images and their shapes
297 all_images_shape = []
298 for item in filepaths_list:
299 each_image = skimage.io.imread(item)
300 all_images_shape.append(each_image.shape)
302 # Determine the maximum extent in each dimension
303 max_image_extent = np.max(all_images_shape, axis=0)
304 print(f"\tPadding images to {max_image_extent} pixels")
306 # Ensure output directory exists
307 output_dir.mkdir(parents=True, exist_ok=True)
309 # Pad and save images
310 for each_image_path in filepaths_list:
311 each_image = skimage.io.imread(each_image_path)
312 pad_height = max_image_extent[0] - each_image.shape[0]
313 pad_width = max_image_extent[1] - each_image.shape[1]
315 # Calculate padding for height and width
316 pad_top = pad_height // 2
317 pad_bottom = pad_height - pad_top
318 pad_left = pad_width // 2
319 pad_right = pad_width - pad_left
321 # Apply padding
322 padded_image = np.pad(
323 each_image,
324 ((pad_top, pad_bottom), (pad_left, pad_right)),
325 mode="constant",
326 constant_values=0,
327 )
329 # Save the padded image
330 tif_out_path = output_dir.joinpath(each_image_path.name)
331 skimage.io.imsave(tif_out_path, padded_image, check_contrast=False)
333 # print("Padding and saving completed.")
336def image_max_extent(filepaths_list: list[Path]):
337 """
338 Retrieve the maximum image dimensions from a list of file paths.
340 Leverages PIL Image package for quick access to image width and height.
341 """
342 n_images = len(filepaths_list)
343 print(f"Found {n_images} images")
345 def get_image_size(filepath):
346 with Image.open(filepath) as img:
347 return img.size # Returns (width, height)
349 # Use ThreadPoolExecutor to parallelize the retrieval of image sizes
350 with ThreadPoolExecutor() as executor:
351 all_image_sizes = list(executor.map(get_image_size, filepaths_list))
353 # Convert (width, height) to (height, width) and find max dimensions
354 all_image_sizes = [(height, width) for width, height in all_image_sizes]
355 max_image_extent = np.max(all_image_sizes, axis=0)
356 print(f"Padding images to {max_image_extent} pixels")
358 return max_image_extent
361def pad_and_save_image_equal(
362 filename: Path, max_image_extent: np.ndarray, output_dir: Path
363):
364 """To come"""
366 input_image = skimage.io.imread(filename)
367 pad_height = max_image_extent[0] - input_image.shape[0]
368 pad_width = max_image_extent[1] - input_image.shape[1]
370 # Calculate padding for height and width
371 pad_top = pad_height // 2
372 pad_bottom = pad_height - pad_top
373 pad_left = pad_width // 2
374 pad_right = pad_width - pad_left
376 # Apply padding
377 if input_image.ndim == 3 and input_image.shape[2] == 3:
378 padded_image = np.pad(
379 input_image,
380 (
381 (pad_top, pad_bottom),
382 (pad_left, pad_right),
383 (0, 0),
384 ), # No padding for the channel dimension
385 mode="constant",
386 constant_values=0,
387 ) # grayscale
388 else:
389 padded_image = np.pad(
390 input_image,
391 ((pad_top, pad_bottom), (pad_left, pad_right)),
392 mode="constant",
393 constant_values=0,
394 )
396 # Save the padded image
397 output_filename = f"padded_{filename.name}"
398 tif_out_path = output_dir.joinpath(output_filename)
399 skimage.io.imsave(tif_out_path, padded_image, check_contrast=False)
402def filter_and_sort_file_paths(
403 file_paths: list[Path], keyword: str
404) -> list[Path]:
405 """
406 Filters a list of file paths to include only those that contain a specific directory keyword and sorts the result.
408 Args:
409 file_paths (list of str): The list of file paths to filter.
410 keyword (str): The directory keyword to filter by.
412 Returns:
413 list of Path: A sorted list containing only the file paths that include the keyword.
414 """
415 # Convert strings to Path objects and filter
416 filtered_paths = [
417 Path(path) for path in file_paths if keyword in Path(path).parts
418 ]
420 # Sort the filtered paths
421 sorted_filtered_paths = sorted(filtered_paths, key=lambda x: x.as_posix())
423 return sorted_filtered_paths
426def append_deg_suffix_and_rename(filenames: list[Path], directory_path: Path):
427 # Define the degree suffixes in a list
428 deg_suffixes = [
429 "000deg",
430 "015deg",
431 "030deg",
432 "045deg",
433 "060deg",
434 "075deg",
435 "090deg",
436 "105deg",
437 "120deg",
438 "135deg",
439 "150deg",
440 "165deg",
441 "180deg",
442 ]
444 # Process each filename
445 updated_filenames = []
446 for i, filename in enumerate(filenames):
447 # Calculate the modulo 13 of the index (i + 1) to match the desired mapping
448 mod_value = i % 13
449 suffix = deg_suffixes[mod_value]
451 # Remove existing duplicate suffixes
452 stem = filename.stem
453 while stem.endswith(suffix):
454 stem = stem[
455 : -(len(suffix) + 1)
456 ] # Remove the suffix and underscore
458 # Append the corresponding degree suffix to the cleaned filename
459 new_filename = f"{stem}_{suffix}{filename.suffix}"
460 updated_filenames.append(new_filename)
462 # Full path for old and new filenames
463 old_file_path = directory_path / filename
464 new_file_path = directory_path / new_filename
466 # Rename the file
467 old_file_path.rename(new_file_path)
469 return updated_filenames
472@app.command(
473 cls=CustomCLICommand,
474 name="rotate_images_by_filename_parallel",
475 short_help="rotate images by filename in parallel",
476)
477def rotate_images_by_filename_parallel(
478 input_dir: Path, output_dir: Path, ncores: int = 4
479):
480 """
481 rotate images in a directory using the XXXdeg in the filename
482 """
484 image_file_list = ut.sorted_files(input_dir, "tif")
486 # Ensure output directory exists
487 output_dir.mkdir(parents=True, exist_ok=True)
489 # rotate and save images, parallel
490 # Parallel(n_jobs=ncores)(delayed(rotate_and_save_single_image_by_filename)(each_image_path, output_dir) for each_image_path in image_file_list)
491 # Parallel(n_jobs=ncores)(delayed(rotate_image_deg)(each_image_path, output_dir) for each_image_path in image_file_list)
492 Parallel(n_jobs=ncores)(
493 delayed(rotate_and_save_single_image_by_filename)(
494 each_image_path, output_dir
495 )
496 for each_image_path in image_file_list
497 )
499 print("Image rotations completed.")
502@app.command(
503 cls=CustomCLICommand,
504 name="pad_images_to_max_extent_parallel",
505 short_help="pad images to maximum extent in parallel",
506)
507def pad_images_to_max_extent_parallel(
508 input_dir: Path, output_dir: Path, ncores: int = 4
509):
510 """Pad all images to the same size, the size is the max extent determined from all the images
512 Args:
513 input_dir (Path): Directory containing input images.
514 output_dir (Path): Directory where padded images will be saved.
515 """
516 # filepaths_list = ut.sorted_files(
517 # directory=input_dir, file_extension=".tif"
518 # )
520 filepaths_list = ut.sorted_files(
521 directory=input_dir, file_extension=".tif"
522 )
524 max_image_extent = image_max_extent(filepaths_list)
526 # Ensure output directory exists
527 output_dir.mkdir(parents=True, exist_ok=True)
529 # Pad and save images, parallel
531 Parallel(n_jobs=ncores)(
532 delayed(pad_and_save_image_equal)(
533 each_image_path, max_image_extent, output_dir
534 )
535 for each_image_path in filepaths_list
536 )
538 print("Padding and saving completed.")
541def combined_image_padding_and_rotation_parallel(
542 input_dir: Path,
543 max_image_extent: np.ndarray,
544 output_dir: Path,
545 ncores: int = 4,
546):
547 """To come"""
549 filepaths_list = ut.sorted_files(
550 directory=input_dir, file_extension=".tif"
551 )
553 # Ensure output directory exists
554 output_dir.mkdir(parents=True, exist_ok=True)
556 # Pad and save images, parallel
557 Parallel(n_jobs=ncores)(
558 delayed(rotate_save_pad_single_image_by_filename)(
559 each_image_path, max_image_extent, output_dir
560 )
561 for each_image_path in filepaths_list
562 )
564 print("Padding, rotation, and saving completed.")
567@app.callback()
568def callback():
569 """
570 precon3d.pol_prep can prepare polarized images by padding and rotating in a parallel operation. Colorization also available.
571 """
574import matplotlib.pyplot as plt
577def visualize_rgb(rgb_image: npt.NDArray):
578 """plot the images side by side"""
580 H, W, _ = rgb_image.shape
582 # 1) Normalize each component map to [0,1] for display
583 C_norm = np.empty_like(rgb_image, dtype=float)
584 for i in range(3):
585 comp = rgb_image[:, :, i]
586 lo, hi = comp.min(), comp.max()
587 if hi > lo:
588 C_norm[:, :, i] = (comp - lo) / (hi - lo)
589 else:
590 C_norm[:, :, i] = comp
592 # 2) Build the RGB composite
593 rgb_composite = C_norm # channel 0→R, 1→G, 2→B
595 # 3) Build three “pure‐R/G/B” versions of each component
596 colored = []
597 for i in range(3):
598 # zero‐plane
599 zero = np.zeros((H, W), dtype=float)
600 if i == 0:
601 # comp 0 in red channel
602 rgb = np.stack([C_norm[:, :, 0], zero, zero], axis=-1)
603 elif i == 1:
604 # comp 1 in green
605 rgb = np.stack([zero, C_norm[:, :, 1], zero], axis=-1)
606 else:
607 # comp 2 in blue
608 rgb = np.stack([zero, zero, C_norm[:, :, 2]], axis=-1)
609 colored.append(rgb)
611 # 4) Plot the 1×4 montage
612 fig, axes = plt.subplots(1, 4, figsize=(4 * 4, 4))
614 # Panel 1: RGB composite
615 ax = axes[0]
616 ax.imshow(rgb_composite, vmin=0, vmax=1)
617 ax.set_title("Composite (RGB)")
618 ax.axis("off")
620 # Panels 2–4: individual comps in R/G/B
621 titles = ["Component 1 → Red", "Component 2 → Green", "Component 3 → Blue"]
622 for ax, img, title in zip(axes[1:], colored, titles):
623 ax.imshow(img, vmin=0, vmax=1)
624 ax.set_title(title)
625 ax.axis("off")
627 plt.tight_layout()
628 plt.show()
631def visualize_spectra(spectra: npt.NDArray):
632 """plot the spectra, color as RGB"""
634 n_channels = spectra.shape[0]
635 channels = np.arange(n_channels) # or your actual channel‐wavelength array
637 plt.figure(figsize=(6, 4))
638 plt.plot(channels, spectra[:, 0], color="red", lw=2, label="Comp 1")
639 plt.plot(channels, spectra[:, 1], color="green", lw=2, label="Comp 2")
640 plt.plot(channels, spectra[:, 2], color="blue", lw=2, label="Comp 3")
642 plt.xlabel("Channel")
643 plt.ylabel("Intensity")
644 plt.title("Recovered Spectra")
645 plt.legend()
646 plt.grid(True)
647 plt.tight_layout()
648 plt.show()
651import os
652from skimage import io
653from skimage.util import img_as_float
656def load_pol_images(
657 img_dir: Path, file_extension: str = ".tif"
658) -> npt.NDArray:
659 """load and reshape images stack data from directory"""
661 # Create an ImageCollection
662 # image_collection = io.ImageCollection(os.path.join(img_dir, '*.tif')) # Adjust the file extension as needed
663 # image_collection = io.ImageCollection(ut.sorted_files(img_dir, file_extension))
665 # Optionally, convert images to float format
666 # image_collection_float = [img_as_float(image) for image in image_collection]
668 # Example: Print the number of images loaded and their shapes
669 # print(f'Number of images loaded: {len(image_collection)}')
670 # for i, img in enumerate(image_collection_float):
671 # print(f'Image {i}: shape {img.shape}')
673 image_stack_paths = ut.sorted_files(img_dir, file_extension)
675 # Load the shading images into a list, using imageio for image reading
676 imagestack = [
677 imageio.v3.imread(each_image) for each_image in image_stack_paths
678 ]
680 # Reshape the loaded images into a 2D array where each row represents a flattened image
681 reshaped_images = np.array([img_as_float(img) for img in imagestack])
683 img = np.asarray(reshaped_images)
685 img = np.transpose(img, (1, 2, 0))
687 return img
690from sklearn.decomposition import PCA, NMF
693def varimax(Phi, gamma=1.0, q=20, tol=1e-6):
694 """
695 Perform varimax (orthogonal) rotation on the loadings matrix Phi.
696 Input:
697 Phi = (p, k) loadings
698 gamma = 1.0 for classic varimax
699 q = max iterations
700 tol = convergence tolerance
701 Returns:
702 rotated loadings, rotation matrix R
703 """
704 p, k = Phi.shape
705 R = np.eye(k)
706 d = 0
707 for i in range(q):
708 Lambda = Phi.dot(R)
709 # the varimax update
710 u, s, vh = np.linalg.svd(
711 Phi.T.dot(
712 np.power(Lambda, 3)
713 - (gamma / p) * Lambda.dot(np.diag(np.sum(Lambda**2, axis=0)))
714 )
715 )
716 R = u.dot(vh)
717 d_new = np.sum(s)
718 if d != 0 and (d_new - d) < tol:
719 break
720 d = d_new
721 return Phi.dot(R), R
724@app.command(
725 cls=CustomCLICommand,
726 name="colorize_pca_varimax",
727 short_help="Colorize with PCA",
728)
729def colorize_pca_varimax(img_dir: Path):
730 """colorize the polarized iamge stack with pca and varimax"""
732 # cast to Path object if not already a Path object
733 if not isinstance(img_dir, Path):
734 img_dir = Path(img_dir)
736 img = load_pol_images(img_dir)
738 H, W, C = img.shape
739 # reshape into (n_pixels, n_channels)
740 X = img.reshape(-1, 13) # shape = (H*W, 13)
742 n_components = 3 # for RGB
744 # 3a. fit PCA
745 pca = PCA(n_components=n_components)
746 scores = pca.fit_transform(X) # (H*W, 3) principal component scores
747 loadings = pca.components_.T # (13, 3) unrotated loadings
749 # 3b. rotate loadings
750 rot_loadings, R = varimax(loadings) # (13, 3), rotation R
751 # rotated scores
752 rot_scores = X.dot(rot_loadings) # (H*W, 3)
754 # reshape back to images
755 score_imgs = rot_scores.reshape(H, W, n_components)
757 visualize_rgb(score_imgs)
758 visualize_spectra(rot_loadings)
760 # Normalize before saving
761 score_imgs_norm = np.empty_like(score_imgs, dtype=float)
762 for i in range(3):
763 comp = score_imgs[:, :, i]
764 lo, hi = comp.min(), comp.max()
765 if hi > lo:
766 score_imgs_norm[:, :, i] = (comp - lo) / (hi - lo)
767 else:
768 score_imgs_norm[:, :, i] = comp
770 # io.imsave(img_dir.parent.joinpath('rgb_pca_image.png'), (score_imgs * 255).astype(np.uint8), check_contrast=False)
771 imageio.v3.imwrite(
772 img_dir.parent.joinpath(f"{img_dir.name}_rgb_pca_image.tif"),
773 (score_imgs_norm * 255).astype(np.uint8),
774 )
777def mcr_als(X, C_init, S_init, max_iter=100, tol=1e-6):
778 """
779 Basic non‐negative MCR‐ALS.
780 X : (n_samples, n_vars)
781 C_init : (n_samples, n_comp)
782 S_init : (n_vars, n_comp)
783 """
784 C = C_init.copy()
785 S = S_init.copy()
786 for it in range(max_iter):
787 S_old = S.copy()
789 # 1) update spectra: X = C @ S.T ⇒ S.T = pinv(C) @ X
790 S = (np.linalg.pinv(C) @ X).T
791 S[S < 0] = 0
793 # 2) update concentrations: X = C @ S.T ⇒ C = X @ pinv(S.T)
794 C = X @ np.linalg.pinv(S.T)
795 C[C < 0] = 0
797 # 3) convergence on S
798 delta = np.linalg.norm(S - S_old) / np.linalg.norm(S_old)
799 if delta < tol:
800 print(f"MCR‐ALS converged in {it+1} iter (Δ={delta:.2e})")
801 break
803 return C, S
806def mcr_als_lstsq(X, C_init, S_init, max_iter=100, tol=1e-6):
807 C = C_init.copy()
808 S = S_init.copy()
809 for it in range(max_iter):
810 S_old = S.copy()
812 # 1) solve C @ S.T = X ⇒ S.T = lstsq(C, X)
813 S = np.linalg.lstsq(C, X, rcond=None)[0].T
814 S[S < 0] = 0
816 # 2) solve S @ C.T = X.T ⇒ C.T = lstsq(S, X.T)
817 C = np.linalg.lstsq(S, X.T, rcond=None)[0].T
818 C[C < 0] = 0
820 delta = np.linalg.norm(S - S_old) / np.linalg.norm(S_old)
821 if delta < tol:
822 print(f"MCR‐ALS converged in {it+1} iter (Δ={delta:.2e})")
823 break
825 return C, S
828@app.command(
829 cls=CustomCLICommand,
830 name="colorize_mcr_als",
831 short_help="Colorize with MCR",
832)
833def colorize_mcr_als(img_dir: Path):
834 """colorize the polarized iamge stack with mcr and als"""
836 # cast to Path object if not already a Path object
837 if not isinstance(img_dir, Path):
838 img_dir = Path(img_dir)
840 img = load_pol_images(img_dir)
842 H, W, C = img.shape
843 # reshape into (n_pixels, n_channels)
844 X = img.reshape(-1, 13) # shape = (H*W, 13)
846 n_components = 3 # for RGB
848 # 4a. initialize with NMF (non‐negative matrix factorization)
849 nmf = NMF(
850 n_components=n_components,
851 init="random",
852 random_state=0,
853 max_iter=500,
854 tol=1e-4,
855 )
856 C_init = nmf.fit_transform(X) # (H*W, 3) initial concentration profiles
857 S_init = nmf.components_ # (3, 13) initial spectra
859 # transpose S_init so spectra are columns (13, 3)
860 S_init = S_init.T
862 C_mcr, S_mcr = mcr_als(X, C_init, S_init)
864 # reshape concentrations back to image form
865 C_imgs = C_mcr.reshape(H, W, n_components)
867 visualize_rgb(C_imgs)
868 visualize_spectra(S_mcr)
870 # io.imsave(img_dir.parent.joinpath('rgb_mcr_image.png'), (C_imgs * 255).astype(np.uint8), check_contrast=False)
871 imageio.v3.imwrite(
872 img_dir.parent.joinpath(f"{img_dir.name}_rgb_mcr_image.tif"),
873 (C_imgs * 255).astype(np.uint8),
874 )
877def apply_loading(img_dir: Path, loadings: npt.NDArray):
878 """use a known loading to color polarized images"""
880 # cast to Path object if not already a Path object
881 if not isinstance(img_dir, Path):
882 img_dir = Path(img_dir)
884 img = load_pol_images(img_dir)
886 H, W, C = img.shape
887 # reshape into (n_pixels, n_channels)
888 X = img.reshape(-1, 13) # shape = (H*W, 13)
890 rng = np.random.default_rng()
891 sampled_element = rng.choice(X, size=500, axis=0)
893 n_components = 3 # for RGB
895 # 4a. initialize with NMF (non‐negative matrix factorization)
896 nmf = NMF(
897 n_components=n_components,
898 init="random",
899 random_state=0,
900 max_iter=500,
901 tol=1e-4,
902 )
903 C_init = nmf.fit_transform(
904 sampled_element
905 ) # (H*W, 3) initial concentration profiles
906 S_init = nmf.components_ # (3, 13) initial spectra
908 # transpose S_init so spectra are columns (13, 3)
909 S_init = S_init.T
911 C_mcr, S_mcr = mcr_als(sampled_element, C_init, S_init)
913 rot_scores = X.dot(S_mcr) # (H*W, 3)
914 score_imgs = rot_scores.reshape(H, W, n_components)