Coverage for src/precon3d/shading_correction.py: 16%

271 statements  

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

1# shading correction 

2import os 

3from sys import platform 

4import shutil 

5import skimage 

6import skimage.io as skio 

7import imageio 

8from pathlib import Path 

9import numpy as np 

10import time 

11import random 

12from dataclasses import dataclass, field 

13from joblib import Parallel, delayed 

14 

15 

16from numpy.typing import NDArray 

17from typing import List, Dict, Any, NamedTuple 

18from scipy.stats import wasserstein_distance, chisquare 

19from sklearn.decomposition import NMF 

20from subprocess import Popen, DEVNULL, PIPE 

21import subprocess 

22import typer 

23 

24# import dask.array as da 

25# import dask_image.imread 

26 

27# from PIL import Image 

28from tqdm import tqdm 

29 

30# local libraries 

31import precon3d.utility as ut 

32import precon3d.mosaic_utils 

33import precon3d.factory as gen_types 

34 

35# pylint: disable=wildcard-import 

36from precon3d.custom_types import * 

37from precon3d._my_typer_cli import CustomCLIGroup, CustomCLICommand 

38 

39# CLI 

40app = typer.Typer( 

41 cls=CustomCLIGroup, 

42 no_args_is_help=True, 

43 short_help="Estimate shading correction from tiles", 

44) 

45 

46 

47def subimages_similar_by_ssim( 

48 ref_image, found_image, n_subimages, ssim_thresh 

49): 

50 """Compare subimages are also similar 

51 

52 divide into equal sized sub images and compare they are all similar 

53 """ 

54 height, width = ref_image.shape[:2] 

55 subimg_height = height // n_subimages 

56 subimg_width = width // n_subimages 

57 

58 # Pre-calculate the slicing indices to avoid doing it in the loop 

59 slice_indices = [ 

60 (y, x) 

61 for y in range(0, height, subimg_height) 

62 for x in range(0, width, subimg_width) 

63 if (y + subimg_height <= height and x + subimg_width <= width) 

64 ] 

65 

66 ssim_scores = [] 

67 for y, x in slice_indices: 

68 if found_image.shape[-1] == 3: # COLOR 

69 ref_subimg = ref_image[ 

70 y : y + subimg_height, x : x + subimg_width, : 

71 ].squeeze() 

72 found_subimg = found_image[ 

73 y : y + subimg_height, x : x + subimg_width, : 

74 ].squeeze() 

75 

76 # Calculate SSIM for the current pair of subimages 

77 score, _ = skimage.metrics.structural_similarity( 

78 ref_subimg, 

79 found_subimg, 

80 data_range=65535, 

81 full=True, 

82 channel_axis=-1, 

83 ) 

84 else: 

85 ref_subimg = ref_image[ 

86 y : y + subimg_height, x : x + subimg_width 

87 ].squeeze() 

88 found_subimg = found_image[ 

89 y : y + subimg_height, x : x + subimg_width 

90 ].squeeze() 

91 

92 # Calculate SSIM for the current pair of subimages 

93 score, _ = skimage.metrics.structural_similarity( 

94 ref_subimg, 

95 found_subimg, 

96 data_range=65535, 

97 full=True, 

98 ) 

99 ssim_scores.append(score) 

100 

101 # Check if all SSIM scores are above the threshold 

102 return all(score > ssim_thresh for score in ssim_scores) 

103 

104 

105def downselect_tiles( 

106 reorganized_tile_dir: Path, 

107 ref_ch: str, 

108 ref_img_idx: int = 0, 

109 ssim_thresh: float = 0.9, 

110 n_subimages: int = 10, 

111) -> bool: 

112 """Downselect tiles organized already by channels""" 

113 

114 # get channel subdirectories 

115 channel_subdirs = [ 

116 ch for ch in reorganized_tile_dir.iterdir() if ch.is_dir() 

117 ] 

118 

119 # get the file paths 

120 all_file_paths = { 

121 subdir.name: ut.sorted_files(subdir, ".tif") 

122 for subdir in channel_subdirs 

123 } 

124 

125 print(f"Found {len(channel_subdirs)} channels") 

126 # Dictionary to hold the count of file paths for each subdir 

127 file_path_counts = { 

128 subdir: len(paths) for subdir, paths in all_file_paths.items() 

129 } 

130 

131 # Printing the number of file paths for each subdir 

132 for subdir, count in file_path_counts.items(): 

133 print(f"\t{subdir}: {count} file(s)") 

134 

135 # Get an iterator of all list lengths 

136 lengths = (len(paths) for paths in all_file_paths.values()) 

137 

138 # Get the length of the first list to compare against others 

139 first_length = next( 

140 lengths, None 

141 ) # Returns None if no elements are present 

142 

143 # Check if all lengths are the same as the first length 

144 all_same_length = all(length == first_length for length in lengths) 

145 if all_same_length: 

146 print("all folders have the same number of files") 

147 else: 

148 # Raise an exception with a message indicating the problem 

149 # raise ValueError('Folders have different numbers of files. Each folder must contain the same number of files.') 

150 

151 file_counts = { 

152 subdir: len(paths) for subdir, paths in all_file_paths.items() 

153 } 

154 error_message = ( 

155 "Folders have different numbers of files: " 

156 + ", ".join(f"{k}: {v} files" for k, v in file_counts.items()) 

157 ) 

158 raise ValueError(error_message) 

159 

160 ref_filepath = all_file_paths.get(ref_ch)[ref_img_idx] 

161 ref_image = skio.imread(ref_filepath) 

162 

163 print( 

164 f"Downselecting tiles in {reorganized_tile_dir} by {ref_ch} channel\nUsing {ref_filepath} as reference" 

165 ) 

166 

167 for each_ch_subdir in channel_subdirs: 

168 all_file_paths[each_ch_subdir.name] = ut.sorted_files( 

169 each_ch_subdir, "tif" 

170 ) # each_ch_subdir.iterdir()# sort naturally for the files in the directories 

171 

172 # downselect based on the reference channel 

173 for idx, each_image in enumerate(all_file_paths.get(ref_ch)): 

174 found_image = skio.imread(each_image) 

175 

176 if found_image.shape[-1] == 3: # COLOR 

177 ssim_score = skimage.metrics.structural_similarity( 

178 ref_image.squeeze(), 

179 found_image.squeeze(), 

180 data_range=65535, 

181 full=True, 

182 channel_axis=-1, 

183 ) 

184 else: 

185 ssim_score = skimage.metrics.structural_similarity( 

186 ref_image.squeeze(), 

187 found_image.squeeze(), 

188 data_range=65535, 

189 full=True, 

190 ) 

191 

192 # remove if not similar 

193 if ( 

194 ssim_score[0] <= ssim_thresh 

195 ): # not similar based on the ssim for subimages 

196 print( 

197 f"DIFFERENT: SSIM score = {ssim_score[0]} ({each_image.stem})" 

198 ) 

199 # delete specific file from all dirs 

200 for subdir_name, file_path in all_file_paths.items(): 

201 file_to_delete = file_path[idx] # use index to specify 

202 

203 print(f"\t{file_to_delete.stem} deleted from {subdir_name}") 

204 

205 file_to_delete.unlink() 

206 

207 else: # overall, images look similar, then divide into subimages and compare subimages 

208 print( 

209 f"SIMILAR, check subimages: SSIM score = {ssim_score[0]} ({each_image.stem})" 

210 ) 

211 

212 subimages_similar = subimages_similar_by_ssim( 

213 ref_image, found_image, n_subimages, ssim_thresh 

214 ) 

215 

216 if subimages_similar is False: 

217 # not similar based on the ssim for subtiles 

218 

219 print( 

220 f"\tFound differences when tiles divided into {n_subimages*n_subimages} smaller pieces" 

221 ) 

222 

223 # breakpoint() 

224 

225 # delete specific file from all dirs 

226 for subdir_name, file_path in all_file_paths.items(): 

227 file_to_delete = file_path[idx] # use index to specify 

228 

229 print( 

230 f"\t{file_to_delete.stem} deleted from {subdir_name}" 

231 ) 

232 # print(f"\t\t{ssim_list}") 

233 

234 file_to_delete.unlink() 

235 

236 

237@app.command( 

238 cls=CustomCLICommand, 

239 name="downselect_tiles_in_directory", 

240 short_help="Use a SSIM threshold to downselect tiles", 

241) 

242def downselect_tiles_in_directory( 

243 reorganized_tile_dir: Path, 

244 ref_img_idx: int = 0, 

245 ssim_thresh: float = 0.9, 

246 n_subimages: int = 10, 

247) -> bool: 

248 """Downselect tiles organized already by channels""" 

249 

250 # get the file paths 

251 

252 all_file_paths = ut.sorted_files( 

253 reorganized_tile_dir, ".tif" 

254 ) # each_ch_subdir.iterdir()# sort naturally for the files in the directories 

255 ref_image = skio.imread(all_file_paths[ref_img_idx]) 

256 

257 # downselect based on the reference channel 

258 for each_image in all_file_paths: 

259 found_image = skio.imread(each_image) 

260 

261 if found_image.shape[-1] == 3: # COLOR 

262 ssim_score = skimage.metrics.structural_similarity( 

263 ref_image.squeeze(), 

264 found_image.squeeze(), 

265 data_range=65535, 

266 full=True, 

267 channel_axis=-1, 

268 ) 

269 else: 

270 ssim_score = skimage.metrics.structural_similarity( 

271 ref_image.squeeze(), 

272 found_image.squeeze(), 

273 data_range=65535, 

274 full=True, 

275 ) 

276 

277 # remove if not similar 

278 if ( 

279 ssim_score[0] <= ssim_thresh 

280 ): # not similar based on the ssim for subimages 

281 print( 

282 f"DIFFERENT: SSIM score = {ssim_score[0]} ({each_image.stem})" 

283 ) 

284 # delete specific file 

285 each_image.unlink() 

286 

287 else: # overall, images look similar, then divide into subimages and compare subimages 

288 print( 

289 f"SIMILAR, check subimages: SSIM score = {ssim_score[0]} ({each_image.stem})" 

290 ) 

291 

292 subimages_similar = subimages_similar_by_ssim( 

293 ref_image, found_image, n_subimages, ssim_thresh 

294 ) 

295 

296 if subimages_similar is False: 

297 # not similar based on the ssim for subtiles 

298 

299 print( 

300 f"\tFound differences when tiles divided into {n_subimages*n_subimages} smaller pieces" 

301 ) 

302 

303 # delete specific file 

304 each_image.unlink() 

305 

306 

307def downselect_tiles_in_directory_parallel( 

308 reorganized_tile_dir: Path, 

309 ref_img_idx: int = 0, 

310 ssim_thresh: float = 0.9, 

311 n_subimages: int = 10, 

312 n_cores: int = 8, 

313) -> bool: 

314 """Downselect tiles organized already by channels""" 

315 

316 # get the file paths 

317 

318 all_file_paths = ut.sorted_files( 

319 reorganized_tile_dir, ".tif" 

320 ) # each_ch_subdir.iterdir()# sort naturally for the files in the directories 

321 ref_image = skio.imread(all_file_paths[ref_img_idx]) 

322 

323 # downselect based on the reference channel, parallel 

324 def _compare_image_with_ssim(comparison_image: Path): 

325 """tocome""" 

326 

327 found_image = skio.imread(comparison_image) 

328 

329 if found_image.shape[-1] == 3: # COLOR 

330 ssim_score = skimage.metrics.structural_similarity( 

331 ref_image.squeeze(), 

332 found_image.squeeze(), 

333 data_range=65535, 

334 full=True, 

335 channel_axis=-1, 

336 ) 

337 else: 

338 ssim_score = skimage.metrics.structural_similarity( 

339 ref_image.squeeze(), 

340 found_image.squeeze(), 

341 data_range=65535, 

342 full=True, 

343 ) 

344 

345 # remove if not similar 

346 if ( 

347 ssim_score[0] <= ssim_thresh 

348 ): # not similar based on the ssim for subimages 

349 print( 

350 f"DIFFERENT: SSIM score = {ssim_score[0]} ({comparison_image.stem})" 

351 ) 

352 # delete specific file 

353 comparison_image.unlink() 

354 

355 else: # overall, images look similar, then divide into subimages and compare subimages 

356 print( 

357 f"SIMILAR, check subimages: SSIM score = {ssim_score[0]} ({each_image.stem})" 

358 ) 

359 

360 subimages_similar = subimages_similar_by_ssim( 

361 ref_image, found_image, n_subimages, ssim_thresh 

362 ) 

363 

364 if subimages_similar is False: 

365 # not similar based on the ssim for subtiles 

366 

367 print( 

368 f"\tFound differences when tiles divided into {n_subimages*n_subimages} smaller pieces" 

369 ) 

370 

371 # delete specific file 

372 comparison_image.unlink() 

373 

374 Parallel(n_jobs=n_cores)( 

375 delayed(_compare_image_with_ssim)(each_image) 

376 for each_image in all_file_paths 

377 ) 

378 

379 

380def downselect_tiles_using_reference_channel( 

381 reorganized_tile_dir: Path, 

382 ref_ch: str, 

383 ref_image: Path, 

384 ssim_thresh: float = 0.9, 

385 n_subimages: int = 10, 

386) -> bool: 

387 """Downselect tiles organized already by channels""" 

388 

389 ref_image = skio.imread(ref_image) 

390 

391 # get channel subdirectories 

392 channel_subdirs = [ 

393 ch for ch in reorganized_tile_dir.iterdir() if ch.is_dir() 

394 ] 

395 

396 # get the file paths 

397 all_file_paths = {subdir.name: [] for subdir in channel_subdirs} 

398 for each_ch_subdir in channel_subdirs: 

399 all_file_paths[each_ch_subdir.name] = ut.sorted_files( 

400 each_ch_subdir, "tif" 

401 ) # each_ch_subdir.iterdir()# sort naturally for the files in the directories 

402 

403 # check number of files are the same 

404 n_files = [len(file_paths) for file_paths in all_file_paths.values()] 

405 if len(set(n_files)) == 1: 

406 print( 

407 f"The channel dirs: {list(all_file_paths.keys())}, all contain {list(set(n_files))} files\n" 

408 ) 

409 else: 

410 print( 

411 f"The channel dirs: {list(all_file_paths.keys())}, contain {n_files} files\n" 

412 ) 

413 return 

414 

415 # downselect based on the reference channel 

416 for idx, each_image in enumerate(all_file_paths.get(ref_ch)): 

417 found_image = skio.imread(each_image) 

418 

419 if found_image.shape[-1] == 3: # COLOR 

420 ssim_score = skimage.metrics.structural_similarity( 

421 ref_image.squeeze(), 

422 found_image.squeeze(), 

423 data_range=65535, 

424 full=True, 

425 channel_axis=-1, 

426 ) 

427 else: 

428 ssim_score = skimage.metrics.structural_similarity( 

429 ref_image.squeeze(), 

430 found_image.squeeze(), 

431 data_range=65535, 

432 full=True, 

433 ) 

434 

435 # remove if not similar 

436 if ( 

437 ssim_score[0] <= ssim_thresh 

438 ): # not similar based on the ssim for subimages 

439 print( 

440 f"DIFFERENT: SSIM score = {ssim_score[0]} ({each_image.stem})" 

441 ) 

442 # delete specific file from all dirs 

443 for subdir_name, file_path in all_file_paths.items(): 

444 file_to_delete = file_path[idx] # use index to specify 

445 

446 print(f"\t{file_to_delete.stem} deleted from {subdir_name}") 

447 

448 file_to_delete.unlink() 

449 

450 else: # overall, images look similar, then divide into subimages and compare subimages 

451 print( 

452 f"SIMILAR, check subimages: SSIM score = {ssim_score[0]} ({each_image.stem})" 

453 ) 

454 

455 if n_subimages == 1: 

456 continue 

457 

458 subimages_similar = subimages_similar_by_ssim( 

459 ref_image, found_image, n_subimages, ssim_thresh 

460 ) 

461 

462 if subimages_similar is False: 

463 # not similar based on the ssim for subtiles 

464 

465 print( 

466 f"\tFound differences when tiles divided into {n_subimages*n_subimages} smaller pieces" 

467 ) 

468 

469 # breakpoint() 

470 

471 # delete specific file from all dirs 

472 for subdir_name, file_path in all_file_paths.items(): 

473 file_to_delete = file_path[idx] # use index to specify 

474 

475 print( 

476 f"\t{file_to_delete.stem} deleted from {subdir_name}" 

477 ) 

478 # print(f"\t\t{ssim_list}") 

479 

480 file_to_delete.unlink() 

481 

482 

483# def nmf_on_images(images: da.Array) -> np.ndarray: 

484# """ 

485# Apply NMF to a set of images and return the flatfield correction. 

486 

487# Parameters: 

488# - images: A Dask array of images. 

489 

490# Returns: 

491# - A NumPy array representing the flatfield correction. 

492# """ 

493# # Reshape images for NMF: (n_samples, n_features) 

494# reshaped_images = images.reshape((images.shape[0], -1)) 

495 

496# # Convert to NumPy array (compute the Dask array) 

497# reshaped_images_np = reshaped_images.compute() 

498 

499# # Apply NMF 

500# model = NMF(n_components=1, init="random", random_state=0) 

501# _ = model.fit_transform(reshaped_images_np) 

502# h = model.components_ 

503 

504# # Assume the first image's shape is representative 

505# flatfield = h.reshape(images.shape[1:]) 

506 

507# # Normalize the flatfield 

508# flatfield_mean = np.mean(flatfield) 

509# if flatfield_mean != 0: 

510# flatfield /= flatfield_mean 

511 

512# return flatfield.astype(np.float32) 

513 

514 

515# def process_in_parts(shading_tiles_dir: Path, n_parts: int) -> np.ndarray: 

516# """ 

517# Split the reading and processing of images into n_parts, apply NMF to each part, 

518# and average the results to get the final flatfield correction. 

519 

520# Parameters: 

521# - shading_tiles_dir: Path to the directory containing the shading images. 

522# - n_parts: Number of parts to split the dataset into. 

523 

524# Returns: 

525# - A NumPy array representing the averaged flatfield correction. 

526# """ 

527# # Load all image paths 

528# # image_paths = list(shading_tiles_dir.glob('*.tif')) 

529# image_paths = ut.sorted_files(shading_tiles_dir, ".tif") 

530# total_images = len(image_paths) 

531# images_per_part = total_images // n_parts 

532 

533# flatfields = [] 

534 

535# for part in range(n_parts): 

536# print(f"Part {part+1} of {n_parts}") 

537 

538# start_idx = part * images_per_part 

539# end_idx = ( 

540# (part + 1) * images_per_part 

541# if part < n_parts - 1 

542# else total_images 

543# ) 

544 

545# # Use Dask to lazily load a subset of images 

546# # images = dask_image.imread.imread(image_paths[start_idx:end_idx]) 

547# images = [ 

548# dask_image.imread.imread(each_path)[None, ...] 

549# for each_path in image_paths[start_idx:end_idx] 

550# ] 

551# image_stack = da.concatenate(images, axis=0) 

552# # Apply NMF and get the flatfield correction for this part 

553# flatfield = nmf_on_images(image_stack) 

554# flatfields.append(flatfield) 

555 

556# return flatfields 

557# # # Average the flatfield corrections from all parts 

558# # averaged_flatfield = np.mean(flatfields, axis=0) 

559 

560# # return averaged_flatfield 

561 

562 

563# def estimate_shading_correction_with_dask(shading_tiles_dir: Path) -> NDArray: 

564# """ 

565# Estimate the shading correction for a set of large images using Non-negative Matrix Factorization (NMF), 

566# with optimizations for memory constraints by using Dask arrays for lazy loading and processing. 

567 

568# Parameters 

569# ---------- 

570# shading_tiles_dir : Path 

571# The directory path where the large shading images are stored. These images are used to compute the shading correction. 

572 

573# Returns 

574# ------- 

575# NDArray 

576# A 2D numpy array representing the estimated flatfield correction image. This image is used to correct 

577# other images for shading effects, normalized so its mean is centered around 1. 

578 

579# Notes 

580# ----- 

581# - This function is optimized for handling large images that do not fit into memory by using Dask for lazy evaluation. 

582# - The function assumes that all shading images are of the same dimensions and are stored in '.tif' format. 

583# - The flatfield correction is derived from the NMF decomposition of the shading images, specifically from the H matrix, 

584# and is normalized to have a mean of 1 for consistent lighting correction. 

585# """ 

586 

587# # Use dask_image to lazily load the images as a Dask array 

588# images = dask_image.imread.imread(str(shading_tiles_dir / '*.tif')) 

589 

590# # Since Dask operates lazily, computations are only triggered upon calling compute(). 

591# # Here, we reshape the images into a 2D array where each row is a flattened image. 

592# # Note: This step might need adjustments based on the actual memory constraints and image sizes. 

593# reshaped_images = images.reshape((images.shape[0], -1)).compute() 

594 

595# # Apply NMF on the reshaped images to find the flatfield correction 

596# # Note: NMF is performed on the in-memory NumPy array since sklearn's NMF does not directly support Dask arrays. 

597# model = NMF(n_components=1, init='random', random_state=0) 

598# W = model.fit_transform(reshaped_images) 

599# H = model.components_ 

600 

601# # Reshape the H matrix to get the flatfield correction image 

602# # The first image's shape is used as a reference for reshaping 

603# flatfield_shape = images.shape[1:] # Ignoring the number of images, just getting one image's shape 

604# flatfield = H.reshape(flatfield_shape) 

605 

606# # Normalize the flatfield so its mean is centered around 1 

607# flatfield_mean = np.mean(flatfield) 

608# if flatfield_mean != 0: 

609# flatfield /= flatfield_mean 

610 

611# print(f'Estimated the flatfield ({flatfield.shape[0]}x{flatfield.shape[1]} pixels) for normalization with non-negative matrix factorization.') 

612 

613# return flatfield 

614 

615 

616@app.command( 

617 cls=CustomCLICommand, 

618 name="estimate_shading_correction", 

619 short_help="Estimate flatfield with NNMF", 

620) 

621def estimate_shading_correction( 

622 shading_tiles_dir: Path, n_images_limit: int = 800 

623): 

624 """ 

625 Estimate the shading correction for a set of images using Non-negative Matrix Factorization (NMF). 

626 

627 This function takes a directory containing shading images, loads them, and applies NMF to estimate 

628 a flatfield correction image. The flatfield image is normalized so that its mean is centered around 1. 

629 This normalization is crucial for subsequent image processing steps, ensuring that the corrected images 

630 have uniform lighting conditions. 

631 

632 Parameters 

633 ---------- 

634 shading_tiles_dir : Path 

635 The directory path where the shading images are stored. These images are used to compute the shading correction. 

636 

637 Returns 

638 ------- 

639 NDArray 

640 A 2D numpy array representing the estimated flatfield correction image. This image is used to correct 

641 other images for shading effects. 

642 

643 Notes 

644 ----- 

645 - The function assumes that all shading images are of the same dimensions. 

646 - The shading images should be in '.tif' format. 

647 - The function uses Non-negative Matrix Factorization (NMF) to decompose the shading images into a basis 

648 component (W) and coefficient matrix (H). The flatfield correction is derived from these components. 

649 

650 Examples 

651 -------- 

652 >>> shading_correction = estimate_shading_correction(Path('/path/to/shading/images')) 

653 >>> print(shading_correction.shape) 

654 (1024, 1024) # Example output, the actual size depends on the input images. 

655 """ 

656 

657 # Retrieve a sorted list of shading image files from the specified directory 

658 shading_images = ut.sorted_files(shading_tiles_dir, ".tif") 

659 

660 if len(shading_images) > n_images_limit: 

661 print( 

662 f"Woah, found {len(shading_images)} images in {shading_tiles_dir}, selecting random subset of {n_images_limit} images\n" 

663 ) 

664 shading_images = random.sample(shading_images, n_images_limit) 

665 else: 

666 print(f"Found {len(shading_images)} images in {shading_tiles_dir}\n") 

667 

668 # Load the shading images into a list, using imageio for image reading 

669 images = [ 

670 imageio.v3.imread(each_shading_image) 

671 for each_shading_image in tqdm(shading_images) 

672 ] 

673 

674 ff_shape = images[0].shape 

675 # Reshape the loaded images into a 2D array where each row represents a flattened image 

676 reshaped_images = np.array([img.flatten() for img in images]) 

677 

678 del images 

679 

680 # Initialize and fit the NMF model to the reshaped images 

681 # n_components=1 indicates we are reducing the image set to 1 component 

682 model = NMF(n_components=1, init="random", random_state=0) 

683 W = model.fit_transform(reshaped_images) 

684 

685 del reshaped_images 

686 

687 # Basis matrix 

688 H = model.components_ # Coefficient matrix 

689 

690 # Reshape the H matrix to the original image shape to get the flatfield correction 

691 flatfield = H.reshape(ff_shape) 

692 

693 # Normalize the flatfield so its mean is centered around 1 

694 flatfield_mean = np.mean(flatfield) 

695 if flatfield_mean != 0: 

696 flatfield /= flatfield_mean 

697 

698 print( 

699 f"Estimated the flatfield ({flatfield.shape[0]}x{flatfield.shape[1]} pixels) for normalization with non-negative matrix factorization." 

700 ) 

701 

702 imageio.v3.imwrite( 

703 shading_tiles_dir.parent.joinpath("nnmf_shading_correction.tif"), 

704 flatfield.astype(np.float32), 

705 ) 

706 

707 # return flatfield 

708 

709 

710import imageio.v3 as iio 

711import tempfile 

712 

713 

714def estimate_shading_correction_v2( 

715 shading_tiles_dir: Path, 

716 n_images_limit: int = 800, 

717) -> np.ndarray: 

718 """ 

719 Estimate the flatfield (shading correction) from a directory of TIFF tiles 

720 using rank‐1 NMF, but without ever loading all images into RAM at once. 

721 

722 Parameters 

723 ---------- 

724 shading_tiles_dir : Path 

725 Directory containing the '.tif' shading‐tile images. 

726 n_images_limit : int, optional 

727 Maximum number of images to sample for the NMF fitting. 

728 

729 Returns 

730 ------- 

731 flatfield : np.ndarray 

732 2D array of the shading‐correction (mean scaled to 1). 

733 """ 

734 

735 # 1) Gather and possibly subsample the list of files 

736 shading_images = ut.sorted_files(shading_tiles_dir, ".tif") 

737 n_total = len(shading_images) 

738 if n_total > n_images_limit: 

739 print( 

740 f"Found {n_total} images, sampling {n_images_limit} of them for memory‐savings." 

741 ) 

742 shading_images = np.random.choice( 

743 shading_images, n_images_limit, replace=False 

744 ) 

745 else: 

746 print(f"Found {n_total} images.") 

747 

748 n_images = len(shading_images) 

749 

750 # 2) Peek at the first image to get shape 

751 first_img = iio.imread(shading_images[0]).astype(np.float32) 

752 

753 height, width, _ = first_img.shape 

754 del first_img 

755 

756 # 3) Create a temporary file on disk and memory‐map it 

757 tmp = tempfile.NamedTemporaryFile( 

758 dir=shading_tiles_dir.parent, suffix=".dat", delete=False 

759 ) 

760 tmp_filename = tmp.name 

761 tmp.close() 

762 

763 # A memmap of shape (n_images, height*width) dtype float32 

764 X = np.memmap( 

765 tmp_filename, 

766 dtype=np.float32, 

767 mode="w+", 

768 shape=(n_images, height * width), 

769 ) 

770 

771 # 4) Stream each TIFF into the memmap 

772 for idx, img_path in enumerate( 

773 tqdm(shading_images, desc="Building memmap") 

774 ): 

775 img = iio.imread(img_path).astype(np.float32) 

776 X[idx, :] = img.ravel() 

777 X.flush() 

778 

779 # 5) Fit a rank‐1 NMF 

780 # This will only hold (n_images × 1) W and (1 × n_pixels) H in RAM 

781 model = NMF( 

782 n_components=1, 

783 init="random", 

784 random_state=0, 

785 max_iter=200, 

786 tol=1e-4, 

787 ) 

788 W = model.fit_transform(X) # shape = (n_images, 1) 

789 H = model.components_[0] # shape = (height*width,) 

790 

791 # 6) Tear down the memmap file 

792 del X 

793 os.remove(tmp_filename) 

794 

795 # 7) Reshape H back into a 2D flatfield and normalize its mean to 1 

796 flatfield = H.reshape((height, width)) 

797 m = flatfield.mean() 

798 if m != 0: 

799 flatfield /= m 

800 

801 # 8) Optionally write it out 

802 out_path = ( 

803 shading_tiles_dir.parent 

804 / f"nnmf_shading_correction_{n_images_limit}.tif" 

805 ) 

806 iio.imwrite(out_path, flatfield.astype(np.float32)) 

807 

808 print( 

809 f"Estimated flatfield of size {flatfield.shape} and wrote to {out_path}" 

810 ) 

811 

812 

813# def save_shading_correction(shading_correction_img: np.ndarray, save_location: Path): 

814# """Save the shading correction""" 

815 

816# # make the directory if it doesn't exist 

817# parent_dir = save_location.parent.mkdir(parents=True,exist_ok=True) 

818 

819# # save 

820# imageio.imwrite(save_location,shading_correction_img) 

821 

822 

823@app.command( 

824 cls=CustomCLICommand, 

825 name="estimate_basic_shading_correction", 

826 short_help="Estimate BaSiC (fiji plugin) flatfield", 

827) 

828def estimate_basic_shading_correction( 

829 shading_tiles_dir: Path, fiji_app: Path 

830) -> NDArray: 

831 """To come""" 

832 

833 # write ImageJ macro 

834 shading_macro = shading_tiles_dir.parent.joinpath( 

835 f"{shading_tiles_dir.name}_shading_macro.ijm" 

836 ) 

837 

838 print(f"Estimating basic shading correction: {shading_macro}") 

839 

840 if platform == "win32": 

841 shading_tiles_dir_str = str(shading_tiles_dir).replace(os.sep, "/") 

842 else: 

843 shading_tiles_dir_str = str(shading_tiles_dir) 

844 

845 with open(shading_macro, "w", encoding="utf-8") as f: 

846 # f.write('setBatchMode(true);\n') 

847 f.write(f'File.openSequence("{shading_tiles_dir_str}");\n') 

848 # f.write("imageTitle = getTitle();\n") 

849 f.write( 

850 f'run("BaSiC ", "processing_stack={shading_tiles_dir.name} flat-field=None dark-field=None shading_estimation=[Estimate shading profiles] shading_model=[Estimate flat-field only (ignore dark-field)] setting_regularisationparametes=Manual temporal_drift=Ignore correction_options=[Compute shading only] lambda_flat=5 lambda_dark=0.50");\n' 

851 ) 

852 # f.write('close(imageTitle);\n') 

853 f.write(f'selectImage("Flat-field:{shading_tiles_dir.name}");\n') 

854 f.write( 

855 f'saveAs("Tiff", "{str(shading_tiles_dir.parent.joinpath(f"BaSiC_flatfield_estimate_{shading_tiles_dir.name}.tif")).replace(os.sep, "/")}");\n' 

856 ) 

857 f.write('run("Close All");\n') 

858 f.write('run("Quit");') 

859 # f.write('setBatchMode(false);') 

860 f.close() 

861 

862 # result = subprocess.run([str(fiji_app), "--run", str(shading_macro)], capture_output=True, text=True) 

863 # print("STDOUT:", result.stdout) 

864 # print("STDERR:", result.stderr) 

865 

866 ## Don't add "--headless", to not run in headless 

867 # https://forum.image.sc/t/set-memory-from-command-line-upon-startup-headless/8668/4 

868 run_macro = Popen( 

869 [str(fiji_app), "-macro", str(shading_macro)], 

870 shell=False, 

871 stdout=PIPE, 

872 stderr=PIPE, 

873 ) 

874 

875 # stream_data = run_macro.communicate()[0] 

876 stdout, stderr = run_macro.communicate() 

877 

878 # print("STDOUT:", stdout.decode()) 

879 # print("STDERR:", stderr.decode()) 

880 

881 if "error" in str(stdout).lower(): 

882 raise OSError( 

883 f"Shading estimation failed on current slice, Fiji output:\n{stdout}" 

884 ) 

885 

886 

887@app.command( 

888 cls=CustomCLICommand, 

889 name="reorganize_tiles_by_channel", 

890 short_help="Reorganize tiles by channel keyword", 

891) 

892def reorganize_tiles_by_channel( 

893 tiles_dir: Path, channel_keywords: List[str], move_files: bool = True 

894): 

895 """Reorganize by moving files based on keywords such as Pol, Bright, or Dark.""" 

896 

897 dest_folder = tiles_dir.parent.expanduser() 

898 

899 for file_path in tiles_dir.rglob( 

900 "*" 

901 ): # Use rglob to find all files recursively 

902 if file_path.is_file(): 

903 for keyword in channel_keywords: 

904 if keyword in file_path.name: 

905 keyword_dest_directory = dest_folder / keyword 

906 keyword_dest_directory.mkdir( 

907 parents=True, exist_ok=True 

908 ) # Create directory if it doesn't exist 

909 if move_files: 

910 shutil.move( 

911 file_path, keyword_dest_directory / file_path.name 

912 ) 

913 else: 

914 shutil.copy2( 

915 file_path, keyword_dest_directory / file_path.name 

916 ) 

917 break # Move to the next file after finding the first matching keyword 

918 

919 print("\tTiles reorganized by channels.\n") 

920 

921 # remove origin directory 

922 if move_files: 

923 ut.rmdir(tiles_dir) 

924 

925 

926def combine_rgb_channels( 

927 red_channel_path, green_channel_path, blue_channel_path, output_path 

928): 

929 """_summary_ 

930 

931 Args: 

932 red_channel_path (_type_): _description_ 

933 green_channel_path (_type_): _description_ 

934 blue_channel_path (_type_): _description_ 

935 output_path (_type_): _description_ 

936 """ 

937 # Read the individual channel images 

938 r_channel = imageio.v3.imread(red_channel_path) 

939 g_channel = imageio.v3.imread(green_channel_path) 

940 b_channel = imageio.v3.imread(blue_channel_path) 

941 

942 # Check if all channels have the same shape 

943 if ( 

944 r_channel.shape != g_channel.shape 

945 or r_channel.shape != b_channel.shape 

946 ): 

947 print("Error: All channel images must have the same dimensions.") 

948 return 

949 

950 # Stack the channels along the last dimension to create an RGB image 

951 rgb_image = np.stack((r_channel, g_channel, b_channel), axis=-1) 

952 

953 # Save the combined image as a 32-bit RGB image 

954 imageio.imwrite( 

955 output_path, rgb_image, format="TIFF" 

956 ) # Use TIFF for float support 

957 

958 

959def separate_color_channels(input_dir: Path) -> list[Path]: 

960 """To come""" 

961 

962 print(f"\tReading images in {input_dir}\n") 

963 parent_dir = input_dir.parent 

964 

965 # Create directories for each channel in the parent directory 

966 red_channel_dir = parent_dir / f"{input_dir.name}_channel_1_red" 

967 green_channel_dir = parent_dir / f"{input_dir.name}_channel_2_green" 

968 blue_channel_dir = parent_dir / f"{input_dir.name}_channel_3_blue" 

969 

970 red_channel_dir.mkdir(exist_ok=True) 

971 green_channel_dir.mkdir(exist_ok=True) 

972 blue_channel_dir.mkdir(exist_ok=True) 

973 

974 print( 

975 f"Splitting RGB images into \n\t\t{red_channel_dir}\n\t\t{green_channel_dir}\n\t\t{blue_channel_dir}" 

976 ) 

977 

978 all_color_images_paths = ut.sorted_files(input_dir, ".tif") 

979 

980 for each_color_image_path in tqdm(all_color_images_paths): 

981 # color_image = Image.open(each_color_image_path) 

982 color_image = imageio.v3.imread(each_color_image_path) 

983 

984 # # Check if the image is in RGB mode 

985 # if color_image.mode != 'RGB': 

986 # print(f"The image is in {color_image.mode} mode, not RGB. Please provide an RGB image.") 

987 # ut.rmdir(red_channel_dir) 

988 # ut.rmdir(green_channel_dir) 

989 # ut.rmdir(blue_channel_dir) 

990 # return 

991 

992 # # Split the image into its RGB channels 

993 # r, g, b = color_image.split() 

994 

995 # Check if the image is RGB 

996 if color_image.ndim != 3 or color_image.shape[2] != 3: 

997 print("The image is not in RGB format.") 

998 ut.rmdir(red_channel_dir) 

999 ut.rmdir(green_channel_dir) 

1000 ut.rmdir(blue_channel_dir) 

1001 return 

1002 

1003 # Split the image into its RGB channels 

1004 r_channel = color_image[:, :, 0] # Red channel 

1005 g_channel = color_image[:, :, 1] # Green channel 

1006 b_channel = color_image[:, :, 2] # Blue channel 

1007 

1008 # Save each channel as an image 

1009 imageio.imwrite( 

1010 red_channel_dir / f"{each_color_image_path.stem}_channel1_red.tif", 

1011 r_channel, 

1012 ) 

1013 imageio.imwrite( 

1014 green_channel_dir 

1015 / f"{each_color_image_path.stem}_channel2_green.tif", 

1016 g_channel, 

1017 ) 

1018 imageio.imwrite( 

1019 blue_channel_dir 

1020 / f"{each_color_image_path.stem}_channel3_blue.tif", 

1021 b_channel, 

1022 ) 

1023 

1024 return [red_channel_dir, green_channel_dir, blue_channel_dir]