Coverage for src/precon3d/resampler.py: 26%

356 statements  

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

1"""Resample slices""" 

2 

3# Default python modules 

4from enum import Enum 

5from numpy.typing import NDArray 

6from typing import NamedTuple, Final, Tuple, List 

7from pathlib import Path 

8import matplotlib.pyplot as plt 

9from mpl_toolkits.mplot3d import Axes3D 

10 

11# from tqdm import tqdm 

12from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn 

13from datetime import datetime 

14import typer 

15 

16import csv 

17import os 

18import re 

19import sys 

20import shutil 

21import time 

22 

23# 3rd party modules 

24import numpy as np 

25import pandas as pd 

26import matplotlib.pyplot as plt 

27from scipy import optimize 

28from aicspylibczi import CziFile 

29import seaborn as sns 

30 

31# Local scripts 

32import precon3d.czi_info as ci 

33import precon3d.utility as ut 

34import precon3d.factory as gen_types 

35 

36# pylint: disable=wildcard-import 

37from precon3d.custom_types import * 

38from precon3d._my_typer_cli import CustomCLIGroup, CustomCLICommand 

39 

40# CLI 

41app = typer.Typer( 

42 cls=CustomCLIGroup, 

43 no_args_is_help=True, 

44 short_help="Resample slices", 

45) 

46 

47UM_TO_MM: Final[float] = 1.0e-3 

48 

49 

50class ResamplePlotOptions(NamedTuple): 

51 """Displays the resample plot with the specified plot options.""" 

52 

53 display: bool 

54 raw: bool 

55 skip_removed: bool 

56 resampled: bool 

57 fit: bool 

58 legend: bool 

59 # segments_legend: bool = False 

60 

61 

62class ResampleMethod(Enum): 

63 """Defines the available resample methods as an enumeration.""" 

64 

65 SIMPLE = 1 

66 ROBUST = 2 

67 

68 

69# Example using the ResampleMethod enum: 

70# Person = namedtuple('Person', ['name', 'age', 'method']) 

71# Andrew = Person("Andrew", 33, ResampleMethod.ROBUST) 

72 

73 

74def tileregion_support_points(czi_file_path: Path) -> List[SupportPoints]: 

75 """Extract scene support points data from CZI metadata. 

76 

77 Args: 

78 czi_file_path (Path): Path to the `.czi` file. 

79 

80 Returns: 

81 List[SupportPoints]: List of scene support points data. 

82 """ 

83 parent_parent_dir_name = czi_file_path.parent.parent.name 

84 parent_dir_name = czi_file_path.parent.name 

85 file_basename = czi_file_path.stem 

86 

87 czi_fname = f"{parent_parent_dir_name}_{parent_dir_name}_{file_basename}" 

88 

89 # Remove all whitespace from the string 

90 czi_fname = re.sub(r"\s+", "", czi_fname) 

91 

92 # Regular expression to match numerical values 

93 def zero_pad(match): 

94 return match.group().zfill(3) # Pad the matched number with zeros 

95 

96 # Replace all numerical values with zero-padded versions 

97 czi_fname_zeropadded = re.sub(r"\d+", zero_pad, czi_fname) 

98 

99 metadata = CziFile(czi_file_path).meta 

100 

101 all_scene_sp = [] 

102 for each_scene in metadata.findall(".//TileRegion"): 

103 if each_scene.find(".//IsUsedForAcquisition").text == "true": 

104 scene_name = each_scene.attrib["Name"] 

105 scene_z = each_scene.find(".//Z").text 

106 

107 sp_list = [] 

108 for each_sp in each_scene.findall(".//SupportPoints//SupportPoint"): 

109 sp_list.append( 

110 SupportPoint( 

111 x=float(each_sp.find("X").text), 

112 y=float(each_sp.find("Y").text), 

113 z=float(each_sp.find("Z").text), 

114 ) 

115 ) 

116 

117 all_scene_sp.append( 

118 SupportPoints( 

119 z_height=scene_z, 

120 positions=sp_list, 

121 czi_fname=czi_fname_zeropadded, 

122 scene_name=scene_name, 

123 ) 

124 ) 

125 

126 return all_scene_sp 

127 

128 

129def fit_plane(points: list[SupportPoints]) -> tuple[np.ndarray, float]: 

130 """ 

131 Fits a plane to the given support points and calculates the normal vector and tilt angle. 

132 

133 Args: 

134 points (list[tuple[float, float, float]]): List of support points as (x, y, z) tuples. 

135 

136 Returns: 

137 tuple[np.ndarray, float]: A tuple containing the unit normal vector (as a NumPy array) 

138 """ 

139 # Extract coordinates from the support points 

140 X = np.array([p.x for p in points]) 

141 Y = np.array([p.y for p in points]) 

142 Z = np.array([p.z for p in points]) 

143 

144 # Fit a plane using least squares 

145 A = np.c_[X, Y, np.ones(X.shape)] 

146 C, _, _, _ = np.linalg.lstsq(A, Z, rcond=None) 

147 

148 # Plane equation: Z = C[0]*X + C[1]*Y + C[2] 

149 normal_vector = np.array([C[0], C[1], -1]) 

150 normal_vector_magnitude = np.linalg.norm(normal_vector) 

151 normal_vector_unit = normal_vector / normal_vector_magnitude 

152 

153 # Round the vector to remove extra whitespace and ensure clean formatting 

154 normal_vector_unit = np.round(normal_vector_unit, decimals=6) 

155 

156 # Calculate the tilt angle from the z-axis 

157 z_axis = np.array([0, 0, -1]) 

158 cos_theta = np.dot(normal_vector_unit, z_axis) 

159 tilt_angle = np.arccos(cos_theta) * (180 / np.pi) # Convert to degrees 

160 

161 # Return the unit normal vector and the tilt angle 

162 return normal_vector_unit, float(tilt_angle) 

163 

164 

165def fit_plane_and_plot(points: list[SupportPoints]) -> None: 

166 """ 

167 Fits a plane to the given support points, plots the plane, normal vector, and calculates the tilt angle from the z-axis. 

168 

169 Args: 

170 points (list[SupportPoint]): List of support points with x, y, z coordinates. 

171 

172 Returns: 

173 None 

174 """ 

175 # Extract coordinates from the support points 

176 X = np.array([p.x for p in points]) 

177 Y = np.array([p.y for p in points]) 

178 Z = np.array([p.z for p in points]) 

179 

180 # Fit a plane using least squares 

181 A = np.c_[X, Y, np.ones(X.shape)] 

182 C, _, _, _ = np.linalg.lstsq(A, Z, rcond=None) 

183 

184 # Plane equation: Z = C[0]*X + C[1]*Y + C[2] 

185 normal_vector = np.array([C[0], C[1], -1]) 

186 normal_vector_magnitude = np.linalg.norm(normal_vector) 

187 normal_vector_unit = normal_vector / normal_vector_magnitude 

188 

189 # Calculate the tilt angle from the z-axis 

190 z_axis = np.array([0, 0, -1]) 

191 cos_theta = np.dot(normal_vector_unit, z_axis) 

192 tilt_angle = np.arccos(cos_theta) * (180 / np.pi) # Convert to degrees 

193 

194 # Create a grid to plot the plane 

195 x_range = np.linspace(X.min(), X.max(), 10) 

196 y_range = np.linspace(Y.min(), Y.max(), 10) 

197 X_grid, Y_grid = np.meshgrid(x_range, y_range) 

198 Z_grid = C[0] * X_grid + C[1] * Y_grid + C[2] 

199 

200 # Plotting 

201 fig = plt.figure(figsize=(10, 8)) 

202 ax = fig.add_subplot(111, projection="3d") 

203 

204 # Plot the original points 

205 ax.scatter(X, Y, Z, color="r", label="Support Points") 

206 

207 # Plot the fitted plane 

208 ax.plot_surface( 

209 X_grid, Y_grid, Z_grid, alpha=0.25, color="b", rstride=100, cstride=100 

210 ) 

211 

212 # Plot the normal vector 

213 origin = np.mean(X), np.mean(Y), np.mean(Z) 

214 ax.quiver( 

215 *origin, 

216 *normal_vector_unit, 

217 length=20, 

218 color="k", 

219 label="Normal Vector", 

220 ) 

221 

222 ax.quiver(*origin, 1, 0, 0, length=100, color="r", label="X") 

223 ax.quiver(*origin, 0, 1, 0, length=100, color="g", label="Y") 

224 ax.quiver(*origin, 0, 0, 1, length=100, color="b", label="Z") 

225 

226 # Set labels 

227 ax.set_xlabel("X") 

228 ax.set_ylabel("Y") 

229 ax.set_zlabel("Z") 

230 ax.set_title("Fitted Plane and Normal Vector") 

231 ax.legend() 

232 

233 ax.set_box_aspect((1, 1, 0.1)) 

234 # ax.auto_scale_xyz([0, 500], [0, 500], [0, 0.15]) 

235 

236 # Display the equation of the plane and tilt angle 

237 print(f"Plane equation: Z = {C[0]:.4f}*X + {C[1]:.4f}*Y + {C[2]:.4f}") 

238 print(f"Magnitude of tilt from Z-axis: {tilt_angle:.4f} degrees") 

239 

240 plt.show() 

241 

242 

243@app.command( 

244 cls=CustomCLICommand, 

245 name="main", 

246 short_help="save support point information", 

247) 

248def save_czi_tileregion_support_points(configfile: Path): 

249 """ 

250 Extracts support points from CZI files in the input directory and saves them to a CSV file. 

251 

252 Parameters 

253 ---------- 

254 params : Path to config file with general attrs 

255 """ 

256 

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

258 if not isinstance(configfile, Path): 

259 configfile = Path(configfile) 

260 

261 config_dict = ut.read_config(configfile) 

262 params = gen_types.create_resampler_parameters(config_dict) 

263 

264 # Validate file extension 

265 if params.file_extension != ".czi": 

266 raise ValueError("At this time, only .czi file extension is supported") 

267 

268 # Input and output directories 

269 czi_input_directory = params.input_directory 

270 output_directory = params.output_directory 

271 output_directory.mkdir(parents=True, exist_ok=True) 

272 

273 # Sort the CZI files in the specified input directory 

274 sorted_czi_files = ut.sorted_files( 

275 directory=czi_input_directory, file_extension=params.file_extension 

276 ) 

277 

278 # Prepare the output file path 

279 output_file_name = re.sub( 

280 r"\s+", 

281 "", 

282 f"{czi_input_directory.parent.parent.name}_{czi_input_directory.parent.name}_{czi_input_directory.name}_support_points.csv", 

283 ) 

284 output_file_path = output_directory / output_file_name 

285 

286 print(f"Found {len(sorted_czi_files)} CZI files in {czi_input_directory}\n") 

287 print(f"Support point information will be saved in {output_file_path}\n") 

288 print(f'{" Reading the files ":*^28}') 

289 

290 # Progress bar setup 

291 total_files = len(sorted_czi_files) 

292 with Progress( 

293 TextColumn("[progress.description]{task.description}"), 

294 BarColumn(), 

295 TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), 

296 TextColumn("[bold blue]{task.completed}/{task.total}"), 

297 ) as progress: 

298 task = progress.add_task("Reading...", total=total_files) 

299 

300 # Open the CSV file for writing 

301 with open(output_file_path, mode="w", newline="") as csv_file: 

302 csv_writer = csv.writer(csv_file) 

303 

304 # Write the header row to the CSV file 

305 csv_writer.writerow( 

306 [ 

307 "File Name", 

308 "Scene", 

309 "z height from metadata", 

310 "Support Point #", 

311 "Support Point X", 

312 "Support Point Y", 

313 "Support Point Z", 

314 "normal vector", 

315 "angle from z", 

316 ] 

317 ) 

318 

319 # Process each CZI file 

320 for each_czi_path in sorted_czi_files: 

321 try: 

322 # Extract support points from the current CZI file 

323 tr_sp = tileregion_support_points(each_czi_path) 

324 

325 # Write support points to the CSV file 

326 for scene_sp in tr_sp: 

327 normal_vec, angle_from_z = fit_plane(scene_sp.positions) 

328 for idx, each_sp in enumerate(scene_sp.positions, start=1): 

329 csv_writer.writerow( 

330 [ 

331 scene_sp.czi_fname, 

332 scene_sp.scene_name, 

333 scene_sp.z_height, 

334 idx, 

335 each_sp.x, 

336 each_sp.y, 

337 each_sp.z, 

338 normal_vec, 

339 angle_from_z, 

340 ] 

341 ) 

342 

343 except RuntimeError as error: 

344 # Print an error message if the file cannot be processed 

345 print(f"{each_czi_path} has an error: {error}") 

346 continue 

347 finally: 

348 progress.update(task, advance=1) 

349 

350 print(f"Support points successfully saved to {output_file_path}") 

351 

352 

353def plot_support_points(csv_file: Path): 

354 """ 

355 Reads support point data from a CSV file and plots the z-positions of the support points 

356 for each slice using Seaborn. 

357 

358 Parameters 

359 ---------- 

360 csv_file : str 

361 Path to the CSV file containing support point data. 

362 

363 Returns 

364 ------- 

365 None 

366 """ 

367 # Read the CSV file into a Pandas DataFrame 

368 df = pd.read_csv(csv_file) 

369 

370 # Ensure the data is sorted by slice and support point number 

371 df = df.sort_values(by=["File Name", "Support Point #"]) 

372 

373 # Plot the z-positions for each slice 

374 plt.figure(figsize=(12, 6)) 

375 sns.lineplot( 

376 data=df, 

377 x="File Name", # Slice (x-axis) 

378 y="Support Point Z", # Z position (y-axis) 

379 hue="Support Point #", # Support Point # (overlaid lines) 

380 marker="o", 

381 ) 

382 

383 # Customize the plot 

384 plt.title("Z Positions of Support Points Across Slices", fontsize=16) 

385 plt.xlabel("Slice (File Name)", fontsize=14) 

386 plt.ylabel("Z Position", fontsize=14) 

387 plt.xticks(rotation=45, fontsize=10) 

388 plt.legend(title="Support Point #", fontsize=10) 

389 plt.grid(True, linestyle="--", alpha=0.5) 

390 

391 # Show the plot 

392 plt.tight_layout() 

393 plt.show() 

394 

395 

396def plot_support_points_and_metadata(csv_file: Path): 

397 """ 

398 Reads support point data from a CSV file and creates multiple plots: 

399 1. Z positions of support points across slices. 

400 2. Z height from metadata across slices. 

401 3. Angle from z-axis across slices. 

402 4. Polar plot visualizing normal vectors looking down the z-axis. 

403 

404 Parameters 

405 ---------- 

406 csv_file : str 

407 Path to the CSV file containing support point data. 

408 

409 Returns 

410 ------- 

411 None 

412 """ 

413 # Read the CSV file into a Pandas DataFrame 

414 df = pd.read_csv(csv_file) 

415 

416 # Ensure the data is sorted by slice and support point number 

417 df = df.sort_values(by=["File Name", "Support Point #"]) 

418 

419 # Preprocess the normal vector column to fix missing commas 

420 def parse_normal_vector(vec: str): 

421 """ 

422 Parses a string representation of a normal vector into a NumPy array, 

423 ensuring no leading commas and replacing internal whitespace with commas. 

424 """ 

425 

426 # Remove leading and trailing whitespace 

427 vec = vec.strip() 

428 

429 # Use regular expressions to replace internal whitespace with commas 

430 vec = re.sub(r"\s+", ",", vec) # Replace all consecutive whitespace with commas 

431 

432 if vec.startswith("[,"): 

433 vec = "[" + vec[2:] 

434 

435 return np.array(eval(vec)) # Convert string to NumPy array 

436 

437 df["normal vector"] = df["normal vector"].apply(parse_normal_vector) 

438 

439 # Create subplots 

440 fig, axes = plt.subplots( 

441 2, 2, figsize=(14, 10), gridspec_kw={"width_ratios": [2, 1]} 

442 ) 

443 fig.suptitle(f"{csv_file.stem}\nSupport Points Analysis", fontsize=16) 

444 

445 def reduce_filename_length_vectorized(filenames: pd.Series) -> pd.Series: 

446 """ 

447 Reduces filename length by removing repeated substrings between underscores 

448 using vectorized operations. 

449 

450 Parameters 

451 ---------- 

452 filenames : pd.Series 

453 Pandas Series containing filenames. 

454 

455 Returns 

456 ------- 

457 pd.Series 

458 Pandas Series with reduced filenames. 

459 """ 

460 # Extract all substrings between underscores 

461 substrings = filenames.str.extractall(r"_(.*?)_")[0] 

462 

463 # Find common substrings across all filenames 

464 common_substrings = substrings.value_counts()[ 

465 substrings.value_counts() == len(filenames) 

466 ].index 

467 

468 # Remove common substrings from filenames using vectorized string replacement 

469 reduced_filenames = filenames 

470 for common in common_substrings: 

471 reduced_filenames = reduced_filenames.str.replace( 

472 f"_{common}_", "_", regex=False 

473 ) 

474 

475 return reduced_filenames 

476 

477 # Reduce filename length 

478 df["Reduced File Name"] = reduce_filename_length_vectorized(df["File Name"]) 

479 

480 # Adjust x-ticks spacing 

481 x_ticks = df["Reduced File Name"].unique() 

482 

483 # Plot 1: Z positions of support points across slices 

484 sns.lineplot( 

485 data=df, 

486 x="Reduced File Name", 

487 y="Support Point Z", 

488 hue="Support Point #", 

489 marker=".", 

490 ax=axes[0, 0], 

491 ) 

492 axes[0, 0].set_title("Z Positions of Support Points Across Slices", fontsize=14) 

493 axes[0, 0].set_xlabel("Slice (File Name)", fontsize=12) 

494 axes[0, 0].set_ylabel("Z Position", fontsize=12) 

495 axes[0, 0].tick_params(axis="x", rotation=45) 

496 axes[0, 0].grid(True, linestyle="--", alpha=0.5) 

497 

498 # Adjust x-ticks spacing 

499 axes[0, 0].set_xticks(x_ticks[::50]) # Show every second slice for better spacing 

500 

501 # Plot 2: Z height from metadata across slices 

502 sns.lineplot( 

503 data=df, 

504 x="Reduced File Name", 

505 y="z height from metadata", 

506 markers=False, 

507 ax=axes[0, 1], 

508 ) 

509 axes[0, 1].scatter( 

510 df["Reduced File Name"][::10], # Select every 2nd point for x-axis 

511 df["z height from metadata"][::10], # Select every 2nd point for y-axis 

512 color="blue", 

513 alpha=0.7, 

514 s=10, 

515 ) 

516 axes[0, 1].set_title("Z Height from Metadata Across Slices", fontsize=14) 

517 axes[0, 1].set_xlabel("Slice (File Name)", fontsize=12) 

518 axes[0, 1].set_ylabel("Z Height", fontsize=12) 

519 axes[0, 1].tick_params(axis="x", rotation=45) 

520 axes[0, 1].grid(True, linestyle="--", alpha=0.5) 

521 

522 # Adjust x-ticks spacing 

523 axes[0, 1].set_xticks(x_ticks[::50]) # Show every second slice for better spacing 

524 

525 # Plot 3: Angle from z-axis across slices 

526 sns.lineplot( 

527 data=df, 

528 x="Reduced File Name", 

529 y="angle from z", 

530 marker=".", 

531 ax=axes[1, 0], 

532 ) 

533 axes[1, 0].set_title("Angle from Z-Axis Across Slices", fontsize=14) 

534 axes[1, 0].set_xlabel("Slice (File Name)", fontsize=12) 

535 axes[1, 0].set_ylabel("Angle (Degrees)", fontsize=12) 

536 axes[1, 0].tick_params(axis="x", rotation=45) 

537 axes[1, 0].grid(True, linestyle="--", alpha=0.5) 

538 

539 # Adjust x-ticks spacing 

540 axes[1, 0].set_xticks(x_ticks[::50]) # Show every second slice for better spacing 

541 

542 # Plot 4: Polar plot of normal vectors 

543 axes[1, 1] = plt.subplot(2, 2, 4, polar=True) 

544 # for _, row in df.iterrows(): 

545 # normal_vec = row["normal vector"] 

546 # angle = np.arctan2(normal_vec[1], normal_vec[0]) # Angle in radians 

547 # magnitude = np.linalg.norm(normal_vec[:2]) # Magnitude in the XY plane 

548 # axes[1, 1].scatter(angle, magnitude, color='r', s=5) 

549 # Extract normal vectors as a NumPy 

550 

551 # Randomly sample 100 rows (or fewer if the DataFrame has less than 100 rows) 

552 sampled_df = df.sample(n=min(100, len(df)), random_state=42) 

553 

554 # Extract normal vectors as a NumPy array 

555 normal_vectors = np.array(sampled_df["normal vector"].tolist()) 

556 

557 # Calculate angles and magnitudes using vectorized operations 

558 angles = np.arctan2(normal_vectors[:, 1], normal_vectors[:, 0]) # Angles in radians 

559 magnitudes = np.linalg.norm( 

560 normal_vectors[:, :2], axis=1 

561 ) # Magnitudes in the XY plane 

562 

563 axes[1, 1].scatter(angles, magnitudes, color="r", s=5) 

564 axes[1, 1].spines["polar"].set_visible(False) # This is key for polar plots 

565 axes[1, 1].set_title( 

566 "Polar Plot of Normal Vectors (Looking Down Z-Axis)", fontsize=14 

567 ) 

568 axes[1, 1].set_theta_zero_location("N") # North points up 

569 axes[1, 1].set_theta_direction(-1) # Clockwise direction 

570 # axes[1, 1].axis('off') 

571 

572 # axes[1, 1].legend(loc="upper right", fontsize=8) 

573 

574 # Adjust layout and show the plots 

575 plt.tight_layout(rect=[0, 0, 1, 0.95]) 

576 plt.show() 

577 

578 

579def save_support_point_info(params: GeneralAttrs) -> Path: 

580 """ 

581 Save support point information extracted from CZI files. 

582 

583 This function reads CZI files from the specified input directory, extracts support points, 

584 and saves the information into a CSV file in the output directory. Currently, only files 

585 with the '.czi' extension are supported. 

586 

587 Parameters 

588 ---------- 

589 params : ut.GeneralAttrs 

590 An object containing the following attributes: 

591 - file_extension (str): The file extension to filter files (must be '.czi'). 

592 - input_directory (Path): The directory containing the input CZI files. 

593 - output_directory (Path): The directory where the output CSV file will be saved. 

594 

595 Returns 

596 ------- 

597 Path 

598 The path to the CSV file containing the support point information. 

599 

600 Raises 

601 ------ 

602 ValueError 

603 If the file extension is not '.czi'. 

604 

605 Notes 

606 ----- 

607 - The output CSV file will be named 'support_points.csv'. 

608 - If an error occurs while processing a CZI file, a message will be printed, and the 

609 function will continue with the next file. 

610 - The output directory will be created if it does not exist. 

611 """ 

612 

613 ## Tile reading, CZI only 

614 file_extension = params.file_extension 

615 if file_extension != ".czi": 

616 raise ValueError("At this time, only .czi file extension is supported") 

617 

618 czi_input_directory = params.input_directory 

619 # Sort the CZI files in the specified input directory 

620 sorted_czi_files = ut.sorted_files( 

621 directory=czi_input_directory, file_extension=file_extension 

622 ) 

623 

624 output_directory = params.output_directory 

625 # Create the output directory if it doesn't exist 

626 output_directory.mkdir(parents=True, exist_ok=True) 

627 

628 output_file_name = "support_points.csv" 

629 file_path_czi_sp_out = output_directory.joinpath(output_file_name) 

630 file_path_czi_info = output_directory.joinpath("czi_info.csv") 

631 

632 print(f"Found {len(sorted_czi_files)} czi files in {czi_input_directory}\n") 

633 print(f"Support point information will be saved in {file_path_czi_sp_out}\n") 

634 

635 print(f'{" Reading the files ":*^28}') 

636 

637 total_files = len(sorted_czi_files) 

638 

639 with Progress( 

640 TextColumn("[progress.description]{task.description}"), 

641 BarColumn(), 

642 TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), 

643 TextColumn("[bold blue]{task.completed}/{task.total}"), 

644 ) as progress: 

645 task = progress.add_task("Reading...", total=total_files) 

646 

647 for each_czi_path in sorted_czi_files: 

648 try: 

649 # Extract support points from the current CZI file 

650 all_scene_sp = ci.scene_support_points(each_czi_path) 

651 

652 # check if empty 

653 if all_scene_sp: # case one, there are tile regions and tile arrays 

654 if all_scene_sp[0].z_heights.size == 0: 

655 all_scene_sp = ci.scene_support_points_global(each_czi_path) 

656 else: # case two, empty, only tile arrays 

657 all_scene_sp = ci.scene_support_points_global(each_czi_path) 

658 except RuntimeError as error: 

659 # Print an error message if the file cannot be processed 

660 # This scenario usually occurs if the czi is corrupted 

661 print(f"{each_czi_path} has an error: {error}") 

662 continue 

663 finally: 

664 progress.update(task, advance=1) 

665 # Dump the support point data to a CSV file 

666 dump_czi_support_point_info(all_scene_sp, file_path_czi_sp_out) 

667 dump_file_info_to_csv(each_czi_path, file_path_czi_info) 

668 # Uncomment the following lines to dump scene-specific support point data 

669 # for each_scene_sp in all_scene_sp: 

670 # # Determine the file name based on the scene name 

671 # file_path = output_directory / f"{each_scene_sp.scene}_raw_sp_info.csv" 

672 # dump_scene_support_point_info(each_scene_sp, file_path) 

673 

674 return file_path_czi_sp_out 

675 

676 

677def dump_czi_support_point_info( 

678 all_sp_info: list[SceneSupportPoints], csv_out_file: Path 

679): 

680 """to come""" 

681 czi_fname = all_sp_info[0].czi_fname 

682 

683 all_z_heights = np.array([]) 

684 for each_scene_sp in all_sp_info: 

685 # Ensure each_scene_sp.z_heights is a NumPy array 

686 each_scene_sp_z_height = np.array(each_scene_sp.z_heights) 

687 # Concatenate the current z_heights with the accumulated ones 

688 all_z_heights = np.concatenate((all_z_heights, each_scene_sp_z_height)) 

689 

690 # compute slice average 

691 average_z_height = np.mean(all_z_heights) 

692 

693 # Check if the file exists and is empty to decide on writing the header 

694 file_exists = csv_out_file.exists() 

695 

696 with open(csv_out_file, "a", newline="", encoding="utf-8") as csvfile: 

697 sp_writer = csv.writer(csvfile) 

698 

699 # If the file didn't exist or was empty, write the header 

700 if not file_exists or csv_out_file.stat().st_size == 0: 

701 sp_writer.writerow( 

702 [ 

703 "File name", 

704 "Average height (um)", 

705 "All heights (um)", 

706 ] 

707 ) 

708 

709 # Write the data row 

710 sp_writer.writerow([czi_fname, average_z_height, all_z_heights]) 

711 

712 

713def dump_file_info_to_csv(file_path: Path, csv_file: Path): 

714 # Ensure the file_path is a Path object 

715 # file_path = Path(file_path) 

716 

717 # Check if the file exists 

718 if not file_path.exists(): 

719 print(f"The file {file_path} does not exist.") 

720 return 

721 

722 # Extracting information 

723 parent1 = file_path.parent.name 

724 parent2 = file_path.parent.parent.name if file_path.parent.parent else "" 

725 file_name = file_path.name 

726 file_size = file_path.stat().st_size 

727 # creation_time = datetime.fromtimestamp(file_path.stat().st_ctime).strftime('%Y-%m-%d %H:%M:%S') 

728 creation_time = ci.acquisition_date_and_time(file_path) 

729 

730 # Data to write 

731 data = [parent2, parent1, file_name, file_size, creation_time] 

732 

733 # Check if the file exists and is empty to decide on writing the header 

734 file_exists = csv_file.exists() 

735 # Writing to CSV 

736 with open(csv_file, mode="a", newline="", encoding="utf-8") as file: 

737 writer = csv.writer(file) 

738 

739 # If the file didn't exist or was empty, write the header 

740 if not file_exists or csv_file.stat().st_size == 0: 

741 writer.writerow( 

742 [ 

743 "Parent Dir2", 

744 "Parent Dir1", 

745 "Filename", 

746 "File size", 

747 "Creation time", 

748 ] 

749 ) 

750 writer.writerow(data) 

751 

752 # print(f"Data for {file_name} has been written to {csv_file}") 

753 

754 

755def dump_scene_support_point_info(sp_info: SceneSupportPoints, csv_out_file: Path): 

756 """Dump scene support point information to a CSV file. 

757 

758 Parameters 

759 ---------- 

760 sp_info : SceneSupportPoints 

761 An object containing information about the scene, including the filename, 

762 scene name, and z heights of support points. 

763 csv_out_file : Path 

764 The path to the CSV file where the information will be written. 

765 

766 Notes 

767 ----- 

768 If the specified CSV file does not exist or is empty, a header row will be 

769 written. Each subsequent call will append a new row with the scene information. 

770 """ 

771 czi_fname = sp_info.czi_fname 

772 scene_name = sp_info.scene 

773 if len(sp_info.z_heights) == 0: # empty 

774 average_z_height = sp_info.z 

775 else: 

776 average_z_height = np.mean(sp_info.z_heights) 

777 # breakpoint() 

778 

779 # Check if the file exists and is empty to decide on writing the header 

780 file_exists = csv_out_file.exists() 

781 

782 with open(csv_out_file, "a", newline="", encoding="utf-8") as csvfile: 

783 sp_writer = csv.writer(csvfile) 

784 

785 # If the file didn't exist or was empty, write the header 

786 if not file_exists or csv_out_file.stat().st_size == 0: 

787 sp_writer.writerow( 

788 [ 

789 "File name", 

790 "Scene name", 

791 "Average height (um)", 

792 "All heights (um)", 

793 # "All Support Points (x,y,z)", 

794 ] 

795 ) 

796 

797 # Write the data row 

798 sp_writer.writerow([czi_fname, scene_name, average_z_height, sp_info.z_heights]) 

799 

800 

801def raw_z_heights(csv_file_path: Path, pol_images: bool) -> np.array: 

802 """Returns the z height position from the csv file. 

803 

804 Args: 

805 csv_file_path: The fully pathed csv file. 

806 

807 Returns: 

808 The z-height positions. 

809 """ 

810 

811 df = pd.read_csv(csv_file_path) 

812 

813 # Check if the "Average height (um)" column exists 

814 if "Average height (um)" in df.columns: 

815 height_data = df.get("Average height (um)") 

816 else: 

817 # If not found, get unique z heights for each unique filename 

818 unique_heights = df[["File Name", "z height from metadata"]].drop_duplicates() 

819 height_data = unique_heights["z height from metadata"] 

820 

821 height_arr = height_data.to_numpy() 

822 

823 if pol_images: 

824 return height_arr[1::13] 

825 

826 return height_arr 

827 

828 

829def process_scene_resample( 

830 raw_z_csv: Path, 

831 target_thickness: float, # units of micron 

832 plot_opts: ResamplePlotOptions, 

833 pol_images: bool, 

834 skip_slice_idx: np.array = np.array(0), 

835) -> bool: 

836 """Resamples an individual scene for a dataset to provide 3D slices with 

837 nearly uniform slice thickness. 

838 

839 Assumes we are working with directories of images, not HDF files to store the origin data 

840 

841 Resample the slice images to be near uniform thickness 

842 

843 Args: 

844 raw_z_csv: The csv file containing the raw z values. 

845 

846 Returns: 

847 True if the function succeeded, False otherwise. 

848 """ 

849 

850 # 1 

851 

852 processed = False # by default, the fuction has not yet been processed 

853 

854 all_slice_z = raw_z_heights(raw_z_csv, pol_images) 

855 

856 # all_slice_czi_paths = ut.sorted_files(Path(czi_input_dir), "czi") 

857 # all_slice_tif_paths = io.sorted_files(scene_input_dir, "tif") 

858 

859 # check the file indices match properly, same sizes 

860 # assert len(all_slice_czi_paths) == len( 

861 # all_slice_tif_paths 

862 # ), "number of czi files used to calculate z heights do not match the number of image slices attempting to resample." 

863 

864 # IGNORE FOR NOW 

865 # assert all_slice_z.shape[0] == len( 

866 # all_slice_czi_paths 

867 # ), "number of z heights in the csv do not match the number of czi files used to calculate z heights." 

868 

869 # assert all_slice_z.shape[0] == len( 

870 # all_slice_tif_paths 

871 # ), "number of z heights in the csv do not match the number of image slices attempting to resample." 

872 

873 # 2 return new slice indices 

874 resampled_idx = scene_resample_simple(all_slice_z, target_thickness, skip_slice_idx) 

875 

876 # plot and verify depending on plot options 

877 

878 # for each channel in the scene, place files into new directory 

879 # (could be with mv or cp depending on how much we trust ourselves) 

880 

881 if plot_opts.display: 

882 fig, ax = plt.subplots(figsize=(6, 5), dpi=150) 

883 ax.set_ylabel("Z Position (mm)", fontweight="bold") 

884 ax.set_xlabel("Slice Number", labelpad=5, fontweight="bold") 

885 

886 if plot_opts.raw: 

887 plt.scatter( 

888 np.arange(0, len(all_slice_z)), 

889 all_slice_z * UM_TO_MM, 

890 s=3, 

891 label="All Slices", 

892 color="k", 

893 ) 

894 if plot_opts.skip_removed: 

895 skip_remove_z = np.delete(all_slice_z, skip_slice_idx) 

896 plt.scatter( 

897 np.arange(0, len(skip_remove_z)), 

898 skip_remove_z * UM_TO_MM, 

899 s=3, 

900 label="Skipped Slice Removed", 

901 color="orange", 

902 alpha=0.5, 

903 ) 

904 if plot_opts.resampled: 

905 plt.scatter( 

906 np.arange(0, len(resampled_idx)), 

907 all_slice_z[resampled_idx] * UM_TO_MM, 

908 s=3, 

909 label="Resampled Height", 

910 color="gray", 

911 ) 

912 if plot_opts.fit: 

913 q = fit_linear_segments( 

914 x_values=np.arange(0, len(resampled_idx)), 

915 y_values=all_slice_z[resampled_idx] * UM_TO_MM, 

916 segment_count=1, 

917 ) 

918 qx = np.array([q[0][1], q[0][0]]) 

919 qy = np.array([q[1][1], q[1][0]]) 

920 resampled_slope = (q[1][1] - q[1][0]) / (q[0][1] - q[0][0]) 

921 

922 plt.plot( 

923 qx, 

924 qy, 

925 label=f"Overall {np.around(resampled_slope, 4)*(1/(UM_TO_MM))} um/slice", 

926 color="k", 

927 linestyle="--", 

928 ) 

929 title = f"Resampling for target thickness of\n{target_thickness} micron" 

930 if plot_opts.legend: 

931 plt.legend() 

932 plt.grid() 

933 ax.set_axisbelow(True) 

934 plt.title(title, fontweight="bold") 

935 plt.tight_layout() 

936 plt.show() 

937 

938 if not ut.yes_no("\tContinue with resampling?"): 

939 print("Exiting data processor...") 

940 sys.exit(0) 

941 

942 # new_slice_idx = 0 

943 # for slice_idx in resampled_idx: 

944 # file_dest = os.path.join(scene_output_dir, f"Slice_{new_slice_idx:>04}") 

945 

946 # # COPY FILES 

947 # shutil.copyfile(all_slice_tif_paths[slice_idx], file_dest) 

948 

949 # new_slice_idx += 1 

950 

951 processed = True # overwrite, if we get to this point, the functiion succeede 

952 

953 return processed 

954 

955 

956def scene_resample_simple( 

957 z_heights: NDArray, # Array of original z heights 

958 target_thickness: float, # Desired thickness of the slices in microns 

959 skip_slice_idx: NDArray = np.array([], dtype=int), # Indices of slices to skip 

960) -> NDArray: # Indices of the resampled heights in the original z_heights array 

961 """Resamples an individual scene for a dataset to provide 3D slices with nearly uniform slice thickness. 

962 

963 Args: 

964 z_heights (np.ndarray): Array of original z heights. 

965 target_thickness (float): Desired thickness of the slices in microns. 

966 skip_slice_idx (np.ndarray, optional): Array of indices to skip during resampling. Defaults to an empty array. 

967 

968 Returns: 

969 np.ndarray: Indices of the resampled heights in the original z_heights array. 

970 """ 

971 

972 # Ensure skip_slice_idx is a 1D array 

973 skip_slice_idx = np.asarray(skip_slice_idx).flatten() 

974 

975 # Remove skipped slice indices from z_heights 

976 if skip_slice_idx.size > 0: 

977 sel_z_heights = np.delete(z_heights, skip_slice_idx) 

978 else: 

979 sel_z_heights = z_heights 

980 

981 # Generate target heights based on the original z_heights range 

982 target_heights = np.arange( 

983 sel_z_heights[0], sel_z_heights[-1] + target_thickness, target_thickness 

984 ) 

985 

986 # Resample heights based on the target heights 

987 resampled_heights = [] 

988 for height in target_heights: 

989 diff = np.abs(sel_z_heights - height) 

990 index = diff.argmin() 

991 resampled_heights.append(sel_z_heights[index]) 

992 

993 # Convert the result to a NumPy array 

994 resampled_heights = np.asarray(resampled_heights) 

995 

996 # Get the indices of the resampled heights in the original z_heights 

997 resampled_heights_indices = np.array( 

998 [np.argwhere(z_heights == val)[0][0] for val in resampled_heights] 

999 ) 

1000 

1001 return resampled_heights_indices 

1002 

1003 

1004def fit_linear_segments( 

1005 x_values: NDArray[np.float64], 

1006 y_values: NDArray[np.float64], 

1007 segment_count: int, 

1008) -> Tuple[NDArray[np.float64], NDArray[np.float64]]: 

1009 """ 

1010 Fit linear segments to a set of points. 

1011 """ 

1012 if segment_count >= len(x_values): 

1013 raise ValueError( 

1014 "The number of segments must be less than the number of data points." 

1015 ) 

1016 

1017 # Calculate the range of x values 

1018 x_min = x_values.min() 

1019 x_max = x_values.max() 

1020 

1021 # Improved initial guess for segment boundaries 

1022 # Distribute segments evenly across the x range 

1023 segment_boundaries_x = np.linspace(x_min, x_max, segment_count + 1) 

1024 

1025 # For y-values at segment boundaries, interpolate based on the existing points 

1026 segment_boundaries_y = np.interp(segment_boundaries_x, x_values, y_values) 

1027 

1028 def error_function(params): 

1029 """ 

1030 Calculate the mean squared error between the model and the actual y-values. 

1031 """ 

1032 # Reconstruct the y-values at segment boundaries from params 

1033 y_values_at_boundaries = params 

1034 # Use fixed x segment boundaries and interpolated y-values for error calculation 

1035 y_predicted = np.interp(x_values, segment_boundaries_x, y_values_at_boundaries) 

1036 return np.mean((y_values - y_predicted) ** 2) 

1037 

1038 # Optimize only the y-values at segment boundaries 

1039 initial_guess = segment_boundaries_y 

1040 result = optimize.minimize(error_function, x0=initial_guess, method="Nelder-Mead") 

1041 

1042 # Use the optimized y-values with the original x segment boundaries 

1043 optimized_y = result.x 

1044 

1045 return segment_boundaries_x, optimized_y 

1046 

1047 

1048@app.callback() 

1049def callback(): 

1050 """ 

1051 precon3d.resampler tries to evenly space out the slices collected based on the microscope focus height. 

1052 """