From 2fca4024a88b7fa16ac71efa9a278909ac578765 Mon Sep 17 00:00:00 2001 From: Talon Chandler Date: Fri, 24 Oct 2025 14:05:14 -0700 Subject: [PATCH 01/12] generate sector pupil --- waveorder/optics.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/waveorder/optics.py b/waveorder/optics.py index 46204517..c5823006 100644 --- a/waveorder/optics.py +++ b/waveorder/optics.py @@ -148,6 +148,64 @@ def generate_pupil(frr, NA, lamb_in): return Pupil +def generate_sector_pupil( + radial_frequencies, + x_frequencies, + y_frequencies, + NA, + wavelength, + start_angle, + end_angle, +): + """ + Generate a sector pupil for a given angular range. + + Parameters + ---------- + radial_frequencies : torch.tensor + radial frequency coordinate in units of inverse length + x_frequencies : torch.tensor + x component of 2D spatial frequency array + y_frequencies : torch.tensor + y component of 2D spatial frequency array + NA : float + numerical aperture of the pupil function (normalized by the refractive index) + wavelength : float + wavelength of the light in units of length + start_angle : float + start angle of sector in degrees (0-360) + end_angle : float + end angle of sector in degrees (0-360) + + Returns + ------- + torch.tensor + sector pupil function + """ + # Start with circular pupil + pupil = torch.zeros(radial_frequencies.shape) + pupil[radial_frequencies < NA / wavelength] = 1 + + # If full aperture (0 to 360), return full pupil + if start_angle == 0 and end_angle == 360: + return pupil + + # Calculate angles in frequency space + # Note: atan2 returns angles in radians from -pi to pi + angles = torch.atan2(y_frequencies, x_frequencies) # radians, -pi to pi + angles_deg = torch.rad2deg(angles) % 360 # convert to degrees, 0 to 360 + + # Create sector mask + if end_angle > start_angle: + # Normal case: sector doesn't wrap around 0 + sector_mask = (angles_deg >= start_angle) & (angles_deg < end_angle) + else: + # Sector wraps around 0 degrees (e.g., 315 to 45) + sector_mask = (angles_deg >= start_angle) | (angles_deg < end_angle) + + return pupil * sector_mask.float() + + def gen_sector_Pupil(fxx, fyy, NA, lamb_in, sector_angle, rotation_angle): """ From 0bcb4d7ab55fa2b82d58caedebcdf89fcf1a727e Mon Sep 17 00:00:00 2001 From: Talon Chandler Date: Fri, 24 Oct 2025 14:05:53 -0700 Subject: [PATCH 02/12] update 2d and 3d phase reconstruction --- waveorder/models/isotropic_thin_3d.py | 166 ++++++++++++++-------- waveorder/models/phase_thick_3d.py | 191 +++++++++++++++----------- 2 files changed, 226 insertions(+), 131 deletions(-) diff --git a/waveorder/models/isotropic_thin_3d.py b/waveorder/models/isotropic_thin_3d.py index 16f253b9..4b89ce9b 100644 --- a/waveorder/models/isotropic_thin_3d.py +++ b/waveorder/models/isotropic_thin_3d.py @@ -44,6 +44,7 @@ def calculate_transfer_function( numerical_aperture_illumination: float, numerical_aperture_detection: float, invert_phase_contrast: bool = False, + illumination_sector_angles: list[tuple[float, float]] = None, ) -> Tuple[Tensor, Tensor]: transverse_nyquist = sampling.transverse_nyquist( wavelength_illumination, @@ -52,45 +53,60 @@ def calculate_transfer_function( ) yx_factor = int(np.ceil(yx_pixel_size / transverse_nyquist)) - ( - absorption_2d_to_3d_transfer_function, - phase_2d_to_3d_transfer_function, - ) = _calculate_wrap_unsafe_transfer_function( + # Handle sector illumination case (or single channel with full aperture) + if illumination_sector_angles is None: + # Single channel with full aperture - wrap as [(0, 360)] + illumination_sector_angles = [(0, 360)] + + absorption_tfs = [] + phase_tfs = [] + + for start_angle, end_angle in illumination_sector_angles: ( - yx_shape[0] * yx_factor, - yx_shape[1] * yx_factor, - ), - yx_pixel_size / yx_factor, - z_position_list, - wavelength_illumination, - index_of_refraction_media, - numerical_aperture_illumination, - numerical_aperture_detection, - invert_phase_contrast=invert_phase_contrast, - ) + absorption_2d_to_3d_transfer_function, + phase_2d_to_3d_transfer_function, + ) = _calculate_wrap_unsafe_transfer_function( + ( + yx_shape[0] * yx_factor, + yx_shape[1] * yx_factor, + ), + yx_pixel_size / yx_factor, + z_position_list, + wavelength_illumination, + index_of_refraction_media, + numerical_aperture_illumination, + numerical_aperture_detection, + invert_phase_contrast=invert_phase_contrast, + sector_angle_start=start_angle, + sector_angle_end=end_angle, + ) - absorption_2d_to_3d_transfer_function_out = torch.zeros( - (len(z_position_list),) + tuple(yx_shape), dtype=torch.complex64 - ) - phase_2d_to_3d_transfer_function_out = torch.zeros( - (len(z_position_list),) + tuple(yx_shape), dtype=torch.complex64 - ) + absorption_2d_to_3d_transfer_function_out = torch.zeros( + (len(z_position_list),) + tuple(yx_shape), dtype=torch.complex64 + ) + phase_2d_to_3d_transfer_function_out = torch.zeros( + (len(z_position_list),) + tuple(yx_shape), dtype=torch.complex64 + ) - for z in range(len(z_position_list)): - absorption_2d_to_3d_transfer_function_out[z] = ( - sampling.nd_fourier_central_cuboid( - absorption_2d_to_3d_transfer_function[z], yx_shape + for z in range(len(z_position_list)): + absorption_2d_to_3d_transfer_function_out[z] = ( + sampling.nd_fourier_central_cuboid( + absorption_2d_to_3d_transfer_function[z], yx_shape + ) ) - ) - phase_2d_to_3d_transfer_function_out[z] = ( - sampling.nd_fourier_central_cuboid( - phase_2d_to_3d_transfer_function[z], yx_shape + phase_2d_to_3d_transfer_function_out[z] = ( + sampling.nd_fourier_central_cuboid( + phase_2d_to_3d_transfer_function[z], yx_shape + ) ) - ) + absorption_tfs.append(absorption_2d_to_3d_transfer_function_out) + phase_tfs.append(phase_2d_to_3d_transfer_function_out) + + # Always return (C, Z, Y, X) arrays, even for single channel return ( - absorption_2d_to_3d_transfer_function_out, - phase_2d_to_3d_transfer_function_out, + torch.stack(absorption_tfs, dim=0), + torch.stack(phase_tfs, dim=0), ) @@ -103,6 +119,8 @@ def _calculate_wrap_unsafe_transfer_function( numerical_aperture_illumination: float, numerical_aperture_detection: float, invert_phase_contrast: bool = False, + sector_angle_start: float = None, + sector_angle_end: float = None, ) -> Tuple[Tensor, Tensor]: if numerical_aperture_illumination >= numerical_aperture_detection: print( @@ -119,11 +137,25 @@ def _calculate_wrap_unsafe_transfer_function( yx_shape, yx_pixel_size ) - illumination_pupil = optics.generate_pupil( - radial_frequencies, - numerical_aperture_illumination, - wavelength_illumination, - ) + # Generate illumination pupil (sector or full aperture) + if sector_angle_start is not None and sector_angle_end is not None: + fyy, fxx = util.generate_frequencies(yx_shape, yx_pixel_size) + illumination_pupil = optics.generate_sector_pupil( + radial_frequencies, + fxx, + fyy, + numerical_aperture_illumination, + wavelength_illumination, + sector_angle_start, + sector_angle_end, + ) + else: + illumination_pupil = optics.generate_pupil( + radial_frequencies, + numerical_aperture_illumination, + wavelength_illumination, + ) + detection_pupil = optics.generate_pupil( radial_frequencies, numerical_aperture_detection, @@ -162,25 +194,30 @@ def calculate_singular_system( phase_2d_to_3d_transfer_function: Tensor, ) -> Tuple[Tensor, Tensor, Tensor]: """Calculates the singular system of the absoprtion and phase transfer - functions. + functions for multi-channel data. - Together, the transfer functions form a (2, Z, Vy, Vx) tensor, where - (2,) is the object-space dimension (abs, phase), (Z,) is the data-space - dimension, and (Vy, Vx) are the spatial frequency dimensions. + Together, the transfer functions form a (2, C, Z, Vy, Vx) tensor, where + (2,) is the object-space dimension (abs, phase), (C,) is the channel dimension, + (Z,) is the data-space dimension, and (Vy, Vx) are the spatial frequency dimensions. - The SVD is computed over the (2, Z) dimensions. + The SVD is computed over the (2, C*Z) dimensions, flattening channels and z together. Parameters ---------- absorption_2d_to_3d_transfer_function : Tensor - ZYX transfer function for absorption + CZYX transfer function for absorption phase_2d_to_3d_transfer_function : Tensor - ZYX transfer function for phase + CZYX transfer function for phase Returns ------- Tuple[Tensor, Tensor, Tensor] + U, S, Vh for the singular system """ + # absorption_2d_to_3d_transfer_function shape: (C, Z, Y, X) + # phase_2d_to_3d_transfer_function shape: (C, Z, Y, X) + + # Stack absorption and phase: (2, C, Z, Y, X) sfYX_transfer_function = torch.stack( ( absorption_2d_to_3d_transfer_function, @@ -188,6 +225,18 @@ def calculate_singular_system( ), dim=0, ) + + # Flatten C and Z dimensions: (2, C*Z, Y, X) + num_channels = sfYX_transfer_function.shape[1] + num_z = sfYX_transfer_function.shape[2] + sfYX_transfer_function = sfYX_transfer_function.reshape( + 2, + num_channels * num_z, + sfYX_transfer_function.shape[3], + sfYX_transfer_function.shape[4], + ) + + # Permute for SVD: (Y, X, 2, C*Z) YXsf_transfer_function = sfYX_transfer_function.permute(2, 3, 0, 1) Up, Sp, Vhp = torch.linalg.svd(YXsf_transfer_function, full_matrices=False) U = Up.permute(2, 3, 0, 1) @@ -289,17 +338,17 @@ def apply_inverse_transfer_function( TV_iterations: int = 10, bg_filter: bool = False, ) -> Tuple[Tensor, Tensor]: - """Reconstructs absorption and phase from zyx_data and a pair of - 3D-to-2D transfer functions named absorption_2d_to_3d_transfer_function and - phase_2d_to_3d_transfer_function, providing options for reconstruction - algorithms. + """Reconstructs absorption and phase from multi-channel zyx_data and the + singular system, combining all illumination channels into single absorption + and phase estimates. Parameters ---------- zyx_data : Tensor - 3D raw data, label-free defocus stack + Multi-channel 3D raw data with shape (C, Z, Y, X). + For single channel (full aperture), C=1. singular_system : Tuple[Tensor, Tensor, Tensor] - singular system of the transfer function bank + singular system of the multi-channel transfer function bank reconstruction_algorithm : Literal["Tikhonov";, "TV";], optional "Tikhonov" or "TV", by default "Tikhonov" "TV" is not implemented. @@ -318,16 +367,25 @@ def apply_inverse_transfer_function( Returns ------- Tuple[Tensor] - yx_absorption (unitless) - yx_phase (radians) + yx_absorption (unitless) with shape (Y, X) + yx_phase (radians) with shape (Y, X) Raises ------ NotImplementedError TV is not implemented """ + # zyx_data shape: (C, Z, Y, X) + num_channels = zyx_data.shape[0] + num_z = zyx_data.shape[1] + + # Flatten C and Z dimensions: (C*Z, Y, X) + czyx_data = zyx_data.reshape( + num_channels * num_z, zyx_data.shape[2], zyx_data.shape[3] + ) + # Normalize - zyx = util.inten_normalization(zyx_data, bg_filter=bg_filter) + czyx = util.inten_normalization(czyx_data, bg_filter=bg_filter) # TODO Consider refactoring with vectorial transfer function SVD if reconstruction_algorithm == "Tikhonov": @@ -338,7 +396,7 @@ def apply_inverse_transfer_function( "sj...,j...,jf...->fs...", U, S_reg, Vh ) - absorption_yx, phase_yx = apply_filter_bank(sfyx_inverse_filter, zyx) + absorption_yx, phase_yx = apply_filter_bank(sfyx_inverse_filter, czyx) # ADMM deconvolution with anisotropic TV regularization elif reconstruction_algorithm == "TV": diff --git a/waveorder/models/phase_thick_3d.py b/waveorder/models/phase_thick_3d.py index 5a2e2547..7d57da24 100644 --- a/waveorder/models/phase_thick_3d.py +++ b/waveorder/models/phase_thick_3d.py @@ -43,7 +43,8 @@ def calculate_transfer_function( numerical_aperture_illumination: float, numerical_aperture_detection: float, invert_phase_contrast: bool = False, -) -> tuple[np.ndarray, np.ndarray]: + illumination_sector_angles: list[tuple[float, float]] = None, +) -> tuple[Tensor, Tensor]: transverse_nyquist = sampling.transverse_nyquist( wavelength_illumination, numerical_aperture_illumination, @@ -58,33 +59,51 @@ def calculate_transfer_function( yx_factor = int(np.ceil(yx_pixel_size / transverse_nyquist)) z_factor = int(np.ceil(z_pixel_size / axial_nyquist)) - ( - real_potential_transfer_function, - imag_potential_transfer_function, - ) = _calculate_wrap_unsafe_transfer_function( + # Handle sector illumination case (or single channel with full aperture) + if illumination_sector_angles is None: + # Single channel with full aperture - wrap as [(0, 360)] + illumination_sector_angles = [(0, 360)] + + real_tfs = [] + imag_tfs = [] + + for start_angle, end_angle in illumination_sector_angles: ( - zyx_shape[0] * z_factor, - zyx_shape[1] * yx_factor, - zyx_shape[2] * yx_factor, - ), - yx_pixel_size / yx_factor, - z_pixel_size / z_factor, - wavelength_illumination, - z_padding, - index_of_refraction_media, - numerical_aperture_illumination, - numerical_aperture_detection, - invert_phase_contrast=invert_phase_contrast, - ) + real_potential_transfer_function, + imag_potential_transfer_function, + ) = _calculate_wrap_unsafe_transfer_function( + ( + zyx_shape[0] * z_factor, + zyx_shape[1] * yx_factor, + zyx_shape[2] * yx_factor, + ), + yx_pixel_size / yx_factor, + z_pixel_size / z_factor, + wavelength_illumination, + z_padding, + index_of_refraction_media, + numerical_aperture_illumination, + numerical_aperture_detection, + invert_phase_contrast=invert_phase_contrast, + sector_angle_start=start_angle, + sector_angle_end=end_angle, + ) + zyx_out_shape = (zyx_shape[0] + 2 * z_padding,) + zyx_shape[1:] + real_tfs.append( + sampling.nd_fourier_central_cuboid( + real_potential_transfer_function, zyx_out_shape + ) + ) + imag_tfs.append( + sampling.nd_fourier_central_cuboid( + imag_potential_transfer_function, zyx_out_shape + ) + ) - zyx_out_shape = (zyx_shape[0] + 2 * z_padding,) + zyx_shape[1:] + # Always return (C, Z, Y, X) array, even for single channel return ( - sampling.nd_fourier_central_cuboid( - real_potential_transfer_function, zyx_out_shape - ), - sampling.nd_fourier_central_cuboid( - imag_potential_transfer_function, zyx_out_shape - ), + torch.stack(real_tfs, dim=0), + torch.stack(imag_tfs, dim=0), ) @@ -98,7 +117,9 @@ def _calculate_wrap_unsafe_transfer_function( numerical_aperture_illumination: float, numerical_aperture_detection: float, invert_phase_contrast: bool = False, -) -> tuple[np.ndarray, np.ndarray]: + sector_angle_start: float = None, + sector_angle_end: float = None, +) -> tuple[Tensor, Tensor]: radial_frequencies = util.generate_radial_frequencies( zyx_shape[1:], yx_pixel_size ) @@ -109,11 +130,25 @@ def _calculate_wrap_unsafe_transfer_function( if invert_phase_contrast: z_position_list = torch.flip(z_position_list, dims=(0,)) - ill_pupil = optics.generate_pupil( - radial_frequencies, - numerical_aperture_illumination, - wavelength_illumination, - ) + # Generate illumination pupil (sector or full aperture) + if sector_angle_start is not None and sector_angle_end is not None: + fyy, fxx = util.generate_frequencies(zyx_shape[1:], yx_pixel_size) + ill_pupil = optics.generate_sector_pupil( + radial_frequencies, + fxx, + fyy, + numerical_aperture_illumination, + wavelength_illumination, + sector_angle_start, + sector_angle_end, + ) + else: + ill_pupil = optics.generate_pupil( + radial_frequencies, + numerical_aperture_illumination, + wavelength_illumination, + ) + det_pupil = optics.generate_pupil( radial_frequencies, numerical_aperture_detection, @@ -150,8 +185,8 @@ def _calculate_wrap_unsafe_transfer_function( def visualize_transfer_function( viewer, - real_potential_transfer_function: np.ndarray, - imag_potential_transfer_function: np.ndarray, + real_potential_transfer_function: Tensor, + imag_potential_transfer_function: Tensor, zyx_scale: tuple[float, float, float], ) -> None: add_transfer_function_to_viewer( @@ -170,11 +205,11 @@ def visualize_transfer_function( def apply_transfer_function( - zyx_object: np.ndarray, - real_potential_transfer_function: np.ndarray, + zyx_object: Tensor, + real_potential_transfer_function: Tensor, z_padding: int, brightness: float, -) -> np.ndarray: +) -> Tensor: # This simplified forward model only handles phase, so it resuses the fluorescence forward model # TODO: extend to absorption return ( @@ -200,19 +235,18 @@ def apply_inverse_transfer_function( TV_rho_strength: float = 1e-3, TV_iterations: int = 10, ) -> Tensor: - """Reconstructs 3D phase from labelfree defocus zyx_data and a pair of - complex 3D transfer functions real_potential_transfer_function and - imag_potential_transfer_function, providing options for reconstruction - algorithms. + """Reconstructs 3D phase from labelfree defocus zyx_data and multi-channel + transfer functions, combining all illumination channels into a single phase estimate. Parameters ---------- zyx_data : Tensor - 3D raw data, label-free defocus stack + Multi-channel 3D raw data with shape (C, Z, Y, X). + For single channel (full aperture), C=1. real_potential_transfer_function : Tensor - Real potential transfer function, see calculate_transfer_function abov + Real potential transfer function with shape (C, Z, Y, X). imaginary_potential_transfer_function : Tensor - Imaginary potential transfer function, see calculate_transfer_function abov + Imaginary potential transfer function with shape (C, Z, Y, X). z_padding : int Padding for axial dimension. Use zero for defocus stacks that extend ~3 PSF widths beyond the sample. Pad by ~3 PSF widths otherwise. @@ -234,47 +268,50 @@ def apply_inverse_transfer_function( Returns ------- Tensor - zyx_phase (radians) + zyx_phase (radians) with shape (Z, Y, X) Raises ------ NotImplementedError TV is not implemented """ - # Handle padding - zyx_padded = util.pad_zyx_along_z(zyx_data, z_padding) - - # Normalize - zyx = util.inten_normalization_3D(zyx_padded) - - # Prepare TF - effective_transfer_function = ( - real_potential_transfer_function - + absorption_ratio * imaginary_potential_transfer_function - ) - - # Reconstruct - if reconstruction_algorithm == "Tikhonov": - inverse_filter = tikhonov_regularized_inverse_filter( - effective_transfer_function, regularization_strength - ) - - # [None]s and [0] are for applying a 1x1 "bank" of filters. - # For further uniformity, consider returning (1, Z, Y, X) - f_real = apply_filter_bank(inverse_filter[None, None], zyx[None])[0] - - elif reconstruction_algorithm == "TV": - raise NotImplementedError - f_real = util.single_variable_admm_tv_deconvolution_3D( - zyx, - effective_transfer_function, - reg_re=regularization_strength, - rho=TV_rho_strength, - itr=TV_iterations, + # Multi-channel reconstruction with sector illumination (or single channel) + # zyx_data shape: (C, Z, Y, X) + # TF shapes: (C, Z, Y, X) + num_channels = zyx_data.shape[0] + reconstructions = [] + + for c in range(num_channels): + # Handle padding + zyx_padded = util.pad_zyx_along_z(zyx_data[c], z_padding) + + # Normalize + zyx = util.inten_normalization_3D(zyx_padded) + + # Prepare TF for this channel + effective_transfer_function = ( + real_potential_transfer_function[c] + + absorption_ratio * imaginary_potential_transfer_function[c] ) - # Unpad - if z_padding != 0: - f_real = f_real[z_padding:-z_padding] + # Reconstruct this channel + if reconstruction_algorithm == "Tikhonov": + inverse_filter = tikhonov_regularized_inverse_filter( + effective_transfer_function, regularization_strength + ) + f_real = apply_filter_bank(inverse_filter[None, None], zyx[None])[ + 0 + ] + elif reconstruction_algorithm == "TV": + raise NotImplementedError + + # Unpad + if z_padding != 0: + f_real = f_real[z_padding:-z_padding] + + reconstructions.append(f_real) + + # Sum all channel reconstructions + f_real = torch.stack(reconstructions, dim=0).sum(dim=0) return f_real From 6f89c1fcd3a2c7b59497c64548882aabfe95cef2 Mon Sep 17 00:00:00 2001 From: Talon Chandler Date: Fri, 24 Oct 2025 14:06:19 -0700 Subject: [PATCH 03/12] settings --- waveorder/cli/settings.py | 70 +++++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/waveorder/cli/settings.py b/waveorder/cli/settings.py index 9ecbf628..d5bcd101 100644 --- a/waveorder/cli/settings.py +++ b/waveorder/cli/settings.py @@ -1,7 +1,7 @@ import os import warnings from pathlib import Path -from typing import List, Literal, Optional, Union +from typing import List, Literal, Optional, Tuple, Union from pydantic.v1 import ( BaseModel, @@ -100,6 +100,7 @@ class PhaseTransferFunctionSettings( ): numerical_aperture_illumination: NonNegativeFloat = 0.5 invert_phase_contrast: bool = False + illumination_sector_angles: Optional[List[Tuple[float, float]]] = None @validator("numerical_aperture_illumination") def na_ill(cls, v, values): @@ -110,6 +111,29 @@ def na_ill(cls, v, values): ) return v + @validator("illumination_sector_angles") + def validate_sector_angles(cls, v): + if v is None: + return v + if len(v) == 0: + raise ValueError( + "illumination_sector_angles must contain at least one sector" + ) + for start, end in v: + if start < 0 or start >= 360: + raise ValueError( + f"Sector start angle {start} must be in range [0, 360)" + ) + if end <= 0 or end > 360: + raise ValueError( + f"Sector end angle {end} must be in range (0, 360]" + ) + if start >= end and not (start > 0 and end == 360): + raise ValueError( + f"Sector start angle {start} must be less than end angle {end}" + ) + return v + class FluorescenceTransferFunctionSettings(FourierTransferFunctionSettings): wavelength_emission: PositiveFloat = 0.507 @@ -171,17 +195,37 @@ def validate_reconstruction_types(cls, values): '"fluorescence" cannot be present alongside "birefringence" or "phase". Please use one configuration file for a "fluorescence" reconstruction and another configuration file for a "birefringence" and/or "phase" reconstructions.' ) num_channel_names = len(values.get("input_channel_names")) - if values.get("birefringence") is None: - if ( - values.get("phase") is None - and values.get("fluorescence") is None - ): - raise ValueError( - "Provide settings for either birefringence, phase, birefringence + phase, or fluorescence." - ) - if num_channel_names != 1: - raise ValueError( - f"{num_channel_names} channels names provided. Please provide a single channel for fluorescence/phase reconstructions." - ) + + # Check for sector illumination in phase reconstruction + phase_settings = values.get("phase") + if phase_settings is not None: + sector_angles = ( + phase_settings.transfer_function.illumination_sector_angles + ) + if sector_angles is not None: + # Multi-channel reconstruction with sector illumination + if len(sector_angles) != num_channel_names: + raise ValueError( + f"Number of illumination_sector_angles ({len(sector_angles)}) must match number of input_channel_names ({num_channel_names})" + ) + else: + # Single channel phase reconstruction without sector illumination + if ( + values.get("birefringence") is None + and num_channel_names != 1 + ): + raise ValueError( + f"{num_channel_names} channels names provided. Please provide a single channel for phase reconstructions without sector illumination." + ) + else: + if values.get("birefringence") is None: + if values.get("fluorescence") is None: + raise ValueError( + "Provide settings for either birefringence, phase, birefringence + phase, or fluorescence." + ) + if num_channel_names != 1: + raise ValueError( + f"{num_channel_names} channels names provided. Please provide a single channel for fluorescence reconstructions." + ) return values From 83f4d406a94b0af7d0c615afcbd19894060462a5 Mon Sep 17 00:00:00 2001 From: Talon Chandler Date: Fri, 24 Oct 2025 14:06:27 -0700 Subject: [PATCH 04/12] sector illumination example --- .../phase_thick_3d_sector_illumination.py | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/examples/models/phase_thick_3d_sector_illumination.py diff --git a/docs/examples/models/phase_thick_3d_sector_illumination.py b/docs/examples/models/phase_thick_3d_sector_illumination.py new file mode 100644 index 00000000..d6a31c09 --- /dev/null +++ b/docs/examples/models/phase_thick_3d_sector_illumination.py @@ -0,0 +1,177 @@ +""" +phase thick 3d with sector illumination +======================================== + +# 3D phase reconstruction with oblique sector illumination +# This example demonstrates multi-channel phase reconstruction where each channel +# corresponds to a different illumination sector angle. +""" + +import napari +import numpy as np +import torch + +from waveorder.models import phase_thick_3d + +# Parameters +# all lengths must use consistent units e.g. um +simulation_arguments = { + "zyx_shape": (100, 256, 256), + "yx_pixel_size": 6.5 / 63, + "z_pixel_size": 0.25, + "index_of_refraction_media": 1.3, +} +phantom_arguments = {"index_of_refraction_sample": 1.50, "sphere_radius": 5} +transfer_function_arguments = { + "z_padding": 0, + "wavelength_illumination": 0.532, + "numerical_aperture_illumination": 0.9, + "numerical_aperture_detection": 1.2, +} + +# Define 9 sector illumination angles +# 8 sectors at 45-degree intervals + 1 full aperture +sector_angle = 45 +illumination_sector_angles = [ + (i * sector_angle, (i + 1) * sector_angle) for i in range(8) +] + [(0, 360)] + +print(f"Using {len(illumination_sector_angles)} illumination sectors") + +# Create a phantom +zyx_phase = phase_thick_3d.generate_test_phantom( + **simulation_arguments, **phantom_arguments +) + +# Calculate multi-channel transfer function (one for each sector) +( + real_potential_transfer_function, + imag_potential_transfer_function, +) = phase_thick_3d.calculate_transfer_function( + **simulation_arguments, + **transfer_function_arguments, + illumination_sector_angles=illumination_sector_angles, +) + +print( + f"Transfer function shape: {real_potential_transfer_function.shape}" +) # Should be (C, Z, Y, X) + +# Display complete multi-channel transfer function +viewer = napari.Viewer() +zyx_scale = np.array( + [ + simulation_arguments["z_pixel_size"], + simulation_arguments["yx_pixel_size"], + simulation_arguments["yx_pixel_size"], + ] +) + +# Add full CZYX transfer function (imaginary part) as single 4D layer +# Match the visualization style from add_transfer_function_to_viewer +czyx_shape = imag_potential_transfer_function.shape +voxel_scale = np.array( + [ + czyx_shape[1] * zyx_scale[0], # Z extent + czyx_shape[2] * zyx_scale[1], # Y extent + czyx_shape[3] * zyx_scale[2], # X extent + ] +) +lim = 0.5 * torch.max(torch.abs(imag_potential_transfer_function)).item() + +viewer.add_image( + torch.fft.ifftshift( + torch.imag(imag_potential_transfer_function), dim=(-3, -2, -1) + ) + .cpu() + .numpy(), + name="Imag pot. TF (CZYX)", + colormap="bwr", + contrast_limits=(-lim, lim), + scale=(1,) + tuple(1 / voxel_scale), # No scaling on C dimension +) + +# Set up XZ view with C and Y as sliders +viewer.dims.order = [0, 2, 1, 3] # (C, Y, Z, X) for XZ display +viewer.dims.current_step = ( + 0, + czyx_shape[1] // 2, + czyx_shape[2] // 2, + czyx_shape[3] // 2, +) + +input( + "Showing CZYX OTF in XZ view (use C and Y sliders). Press to continue..." +) +viewer.layers.select_all() +viewer.layers.remove_selected() + +# Simulate multi-channel data (one channel per sector) +# In practice, these would come from your microscope as separate acquisitions +zyx_data_multi_channel = [] +for c in range(len(illumination_sector_angles)): + zyx_data_channel = phase_thick_3d.apply_transfer_function( + zyx_phase, + real_potential_transfer_function[c], + transfer_function_arguments["z_padding"], + brightness=1e3, + ) + zyx_data_multi_channel.append(zyx_data_channel) + +# Stack into (C, Z, Y, X) tensor +zyx_data_multi_channel = torch.stack(zyx_data_multi_channel, dim=0) +print(f"Multi-channel data shape: {zyx_data_multi_channel.shape}") + +# Reconstruct phase from all channels combined +zyx_recon = phase_thick_3d.apply_inverse_transfer_function( + zyx_data_multi_channel, + real_potential_transfer_function, + imag_potential_transfer_function, + transfer_function_arguments["z_padding"], +) + +# Display +viewer.add_image(zyx_phase.numpy(), name="Phantom", scale=zyx_scale) +viewer.add_image( + zyx_data_multi_channel.numpy(), + name="Data (CZYX)", + scale=zyx_scale, +) +viewer.add_image(zyx_recon.numpy(), name="Reconstruction", scale=zyx_scale) + +# Show comparison with single channel (full aperture) for reference +print("\nComparing with single-channel (full aperture) reconstruction...") +( + real_tf_single, + imag_tf_single, +) = phase_thick_3d.calculate_transfer_function( + **simulation_arguments, + **transfer_function_arguments, + illumination_sector_angles=None, # Full aperture +) +zyx_data_single = phase_thick_3d.apply_transfer_function( + zyx_phase, + real_tf_single[0], # Single channel + transfer_function_arguments["z_padding"], + brightness=1e3, +) +zyx_recon_single = phase_thick_3d.apply_inverse_transfer_function( + zyx_data_single[None, ...], # Add channel dimension + real_tf_single, + imag_tf_single, + transfer_function_arguments["z_padding"], +) +viewer.add_image( + zyx_recon_single.numpy(), + name="Reconstruction (single channel)", + scale=zyx_scale, +) + +print( + f"\nReconstruction error (multi-channel): {torch.mean(torch.abs(zyx_recon - zyx_phase)).item():.6f}" +) +print( + f"Reconstruction error (single channel): {torch.mean(torch.abs(zyx_recon_single - zyx_phase)).item():.6f}" +) + +input("\nShowing phantom, data, and reconstructions. Press to quit...") From f2b0fd4bef15a59a7ef342c487501f63520f7b56 Mon Sep 17 00:00:00 2001 From: Talon Chandler Date: Tue, 28 Oct 2025 13:14:49 -0700 Subject: [PATCH 05/12] use modular arithmetic to enable negative sector angles --- waveorder/cli/settings.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/waveorder/cli/settings.py b/waveorder/cli/settings.py index d5bcd101..b34ba3a3 100644 --- a/waveorder/cli/settings.py +++ b/waveorder/cli/settings.py @@ -119,20 +119,17 @@ def validate_sector_angles(cls, v): raise ValueError( "illumination_sector_angles must contain at least one sector" ) + normalized = [] for start, end in v: - if start < 0 or start >= 360: - raise ValueError( - f"Sector start angle {start} must be in range [0, 360)" - ) - if end <= 0 or end > 360: - raise ValueError( - f"Sector end angle {end} must be in range (0, 360]" - ) - if start >= end and not (start > 0 and end == 360): + if start >= end: raise ValueError( f"Sector start angle {start} must be less than end angle {end}" ) - return v + # Normalize angles to [0, 360) using modulo 360 + normalized_start = start % 360 + normalized_end = end % 360 + normalized.append((normalized_start, normalized_end)) + return normalized class FluorescenceTransferFunctionSettings(FourierTransferFunctionSettings): From bc3bd12f634ea946999c62df2b4adcd2da1fb6c9 Mon Sep 17 00:00:00 2001 From: Talon Chandler Date: Tue, 28 Oct 2025 13:15:11 -0700 Subject: [PATCH 06/12] axis-aligned sectors for example --- .../models/phase_thick_3d_sector_illumination.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/examples/models/phase_thick_3d_sector_illumination.py b/docs/examples/models/phase_thick_3d_sector_illumination.py index d6a31c09..60b85f39 100644 --- a/docs/examples/models/phase_thick_3d_sector_illumination.py +++ b/docs/examples/models/phase_thick_3d_sector_illumination.py @@ -32,11 +32,16 @@ # Define 9 sector illumination angles # 8 sectors at 45-degree intervals + 1 full aperture sector_angle = 45 +sector_angle_offset = -22.5 illumination_sector_angles = [ - (i * sector_angle, (i + 1) * sector_angle) for i in range(8) + ( + (i * sector_angle + sector_angle_offset) % 360, + ((i + 1) * sector_angle + sector_angle_offset) % 360, + ) + for i in range(8) ] + [(0, 360)] -print(f"Using {len(illumination_sector_angles)} illumination sectors") +print(f"Using {illumination_sector_angles} illumination sectors") # Create a phantom zyx_phase = phase_thick_3d.generate_test_phantom( From 9562f7d8f3c108684af53ee9849f73eef1c90fd8 Mon Sep 17 00:00:00 2001 From: Talon Chandler Date: Tue, 28 Oct 2025 13:45:05 -0700 Subject: [PATCH 07/12] fix dimension mismatch for tfs --- waveorder/cli/compute_transfer_function.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/waveorder/cli/compute_transfer_function.py b/waveorder/cli/compute_transfer_function.py index 4374b19c..9eecbebf 100644 --- a/waveorder/cli/compute_transfer_function.py +++ b/waveorder/cli/compute_transfer_function.py @@ -211,14 +211,12 @@ def generate_and_save_phase_transfer_function( # Save dataset.create_image( "real_potential_transfer_function", - real_potential_transfer_function.cpu().numpy()[None, None, ...], + real_potential_transfer_function.cpu().numpy()[None, ...], chunks=(1, 1, 1, zyx_shape[1], zyx_shape[2]), ) dataset.create_image( "imaginary_potential_transfer_function", - imaginary_potential_transfer_function.cpu().numpy()[ - None, None, ... - ], + imaginary_potential_transfer_function.cpu().numpy()[None, ...], chunks=(1, 1, 1, zyx_shape[1], zyx_shape[2]), ) @@ -367,14 +365,15 @@ def compute_transfer_function_cli( print("Found z_focus_offset:", z_focus_offset) # Prepare output dataset - num_channels = ( + num_input_channel = len(settings.input_channel_names) + num_output_channels = ( 2 if settings.reconstruction_dimension == 2 else 1 ) # space for SVD output_dataset = open_ome_zarr( output_dirpath, layout="fov", mode="w", - channel_names=num_channels * ["None"], + channel_names=num_input_channel * num_output_channels * ["None"], ) # Pass settings to appropriate calculate_transfer_function and save From 9c775d76025249060700c918e72088a3707c33a5 Mon Sep 17 00:00:00 2001 From: Talon Chandler Date: Tue, 28 Oct 2025 15:11:49 -0700 Subject: [PATCH 08/12] sector wrapping bugfix --- waveorder/cli/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/waveorder/cli/settings.py b/waveorder/cli/settings.py index b34ba3a3..1d3b9e94 100644 --- a/waveorder/cli/settings.py +++ b/waveorder/cli/settings.py @@ -126,8 +126,9 @@ def validate_sector_angles(cls, v): f"Sector start angle {start} must be less than end angle {end}" ) # Normalize angles to [0, 360) using modulo 360 + # Special case: preserve 360 for full aperture (don't reduce to 0) normalized_start = start % 360 - normalized_end = end % 360 + normalized_end = end % 360 if end % 360 != 0 else 360 normalized.append((normalized_start, normalized_end)) return normalized From 866a6e6ab76073d630d8cd7365f5dcad5da3964a Mon Sep 17 00:00:00 2001 From: Talon Chandler Date: Tue, 28 Oct 2025 15:12:16 -0700 Subject: [PATCH 09/12] consistent dims --- waveorder/cli/apply_inverse_models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/waveorder/cli/apply_inverse_models.py b/waveorder/cli/apply_inverse_models.py index 995c2e5d..b02596f2 100644 --- a/waveorder/cli/apply_inverse_models.py +++ b/waveorder/cli/apply_inverse_models.py @@ -92,19 +92,19 @@ def phase( # [phase only, 3] elif recon_dim == 3: - # Load transfer functions + # Load transfer functions (keep channel dimension) real_potential_transfer_function = torch.tensor( - transfer_function_dataset["real_potential_transfer_function"][0, 0] + transfer_function_dataset["real_potential_transfer_function"][0] ) imaginary_potential_transfer_function = torch.tensor( transfer_function_dataset["imaginary_potential_transfer_function"][ - 0, 0 + 0 ] ) - # Apply + # Apply (pass full CZYX data) output = phase_thick_3d.apply_inverse_transfer_function( - czyx_data[0], + czyx_data, real_potential_transfer_function, imaginary_potential_transfer_function, z_padding=settings_phase.transfer_function.z_padding, From ee1e688c56e39ece8124a30d2d52dd40d5582fcb Mon Sep 17 00:00:00 2001 From: Talon Chandler Date: Tue, 28 Oct 2025 15:13:04 -0700 Subject: [PATCH 10/12] print trasnfer function progress --- waveorder/models/phase_thick_3d.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/waveorder/models/phase_thick_3d.py b/waveorder/models/phase_thick_3d.py index 7d57da24..78e48d7a 100644 --- a/waveorder/models/phase_thick_3d.py +++ b/waveorder/models/phase_thick_3d.py @@ -67,7 +67,10 @@ def calculate_transfer_function( real_tfs = [] imag_tfs = [] - for start_angle, end_angle in illumination_sector_angles: + for i, (start_angle, end_angle) in enumerate(illumination_sector_angles): + print( + f"Calculating transfer function {i+1}/{len(illumination_sector_angles)} for sector [{start_angle:.1f}, {end_angle:.1f}] degrees" + ) ( real_potential_transfer_function, imag_potential_transfer_function, @@ -282,6 +285,7 @@ def apply_inverse_transfer_function( reconstructions = [] for c in range(num_channels): + print(f"Reconstructing channel {c+1}/{num_channels}") # Handle padding zyx_padded = util.pad_zyx_along_z(zyx_data[c], z_padding) From 68a691ea0dc27e20346dbd83591347c6edb0bfef Mon Sep 17 00:00:00 2001 From: talonchandler Date: Mon, 26 Jan 2026 15:51:18 -0800 Subject: [PATCH 11/12] adapt to CZYX input across phase reconstructions --- .../examples/configs/birefringence-and-phase.yml | 1 + docs/examples/configs/phase.yml | 1 + docs/examples/models/phase_thick_3d.py | 16 ++++++++-------- tests/models/test_isotropic_thin_3d.py | 5 +++-- tests/models/test_phase_thick_3d.py | 15 ++++++++------- waveorder/cli/compute_transfer_function.py | 1 + waveorder/cli/settings.py | 13 +++++++------ 7 files changed, 29 insertions(+), 23 deletions(-) diff --git a/docs/examples/configs/birefringence-and-phase.yml b/docs/examples/configs/birefringence-and-phase.yml index 1f92c609..c5f9c5fe 100644 --- a/docs/examples/configs/birefringence-and-phase.yml +++ b/docs/examples/configs/birefringence-and-phase.yml @@ -25,6 +25,7 @@ phase: numerical_aperture_detection: 1.2 numerical_aperture_illumination: 0.5 invert_phase_contrast: false + illumination_sector_angles: null apply_inverse: reconstruction_algorithm: Tikhonov regularization_strength: 0.001 diff --git a/docs/examples/configs/phase.yml b/docs/examples/configs/phase.yml index 0c1b287c..9b46e439 100644 --- a/docs/examples/configs/phase.yml +++ b/docs/examples/configs/phase.yml @@ -13,6 +13,7 @@ phase: numerical_aperture_detection: 1.2 numerical_aperture_illumination: 0.5 invert_phase_contrast: false + illumination_sector_angles: null apply_inverse: reconstruction_algorithm: Tikhonov regularization_strength: 0.001 diff --git a/docs/examples/models/phase_thick_3d.py b/docs/examples/models/phase_thick_3d.py index b7e0e45c..0cf8b6f7 100644 --- a/docs/examples/models/phase_thick_3d.py +++ b/docs/examples/models/phase_thick_3d.py @@ -34,7 +34,7 @@ **simulation_arguments, **phantom_arguments ) -# Calculate transfer function +# Calculate transfer function (returns shape (C, Z, Y, X)) ( real_potential_transfer_function, imag_potential_transfer_function, @@ -42,7 +42,7 @@ **simulation_arguments, **transfer_function_arguments ) -# Display transfer function +# Display transfer function (extract single channel for visualization) viewer = napari.Viewer() zyx_scale = np.array( [ @@ -53,25 +53,25 @@ ) phase_thick_3d.visualize_transfer_function( viewer, - real_potential_transfer_function, - imag_potential_transfer_function, + real_potential_transfer_function[0], + imag_potential_transfer_function[0], zyx_scale, ) input("Showing OTFs. Press to continue...") viewer.layers.select_all() viewer.layers.remove_selected() -# Simulate +# Simulate (extract single channel for forward model) zyx_data = phase_thick_3d.apply_transfer_function( zyx_phase, - real_potential_transfer_function, + real_potential_transfer_function[0], transfer_function_arguments["z_padding"], brightness=1e3, ) -# Reconstruct +# Reconstruct (wrap data in channel dimension for inverse) zyx_recon = phase_thick_3d.apply_inverse_transfer_function( - zyx_data, + zyx_data[None], # Add channel dimension: (Z, Y, X) -> (1, Z, Y, X) real_potential_transfer_function, imag_potential_transfer_function, transfer_function_arguments["z_padding"], diff --git a/tests/models/test_isotropic_thin_3d.py b/tests/models/test_isotropic_thin_3d.py index 9bd49bf7..3d85b9a7 100644 --- a/tests/models/test_isotropic_thin_3d.py +++ b/tests/models/test_isotropic_thin_3d.py @@ -17,5 +17,6 @@ def test_calculate_transfer_function(invert_phase_contrast): invert_phase_contrast=invert_phase_contrast, ) - assert Hu.shape == (3, 100, 101) - assert Hp.shape == (3, 100, 101) + # Transfer functions now have shape (C, Z, Y, X) where C is number of channels + assert Hu.shape == (1, 3, 100, 101) + assert Hp.shape == (1, 3, 100, 101) diff --git a/tests/models/test_phase_thick_3d.py b/tests/models/test_phase_thick_3d.py index fa1ca2f9..411c4135 100644 --- a/tests/models/test_phase_thick_3d.py +++ b/tests/models/test_phase_thick_3d.py @@ -19,8 +19,9 @@ def test_calculate_transfer_function(invert_phase_contrast): invert_phase_contrast=invert_phase_contrast, ) - assert H_re.shape == (20 + 2 * z_padding, 100, 101) - assert H_im.shape == (20 + 2 * z_padding, 100, 101) + # Transfer functions now have shape (C, Z, Y, X) where C is number of channels + assert H_re.shape == (1, 20 + 2 * z_padding, 100, 101) + assert H_im.shape == (1, 20 + 2 * z_padding, 100, 101) # Helper function for testing reconstruction invariances @@ -58,7 +59,7 @@ def simulate_phase_recon( **simulation_arguments, **phantom_arguments ) - # Calculate transfer function + # Calculate transfer function (returns shape (C, Z, Y, X)) ( real_potential_transfer_function, imag_potential_transfer_function, @@ -66,17 +67,17 @@ def simulate_phase_recon( **simulation_arguments, **transfer_function_arguments ) - # Simulate + # Simulate (extract single channel for forward model) zyx_data = phase_thick_3d.apply_transfer_function( zyx_phase, - real_potential_transfer_function, + real_potential_transfer_function[0], transfer_function_arguments["z_padding"], brightness=1000, ) - # Reconstruct + # Reconstruct (wrap data in channel dimension for inverse) zyx_recon = phase_thick_3d.apply_inverse_transfer_function( - zyx_data, + zyx_data[None], # Add channel dimension: (Z, Y, X) -> (1, Z, Y, X) real_potential_transfer_function, imag_potential_transfer_function, transfer_function_arguments["z_padding"], diff --git a/waveorder/cli/compute_transfer_function.py b/waveorder/cli/compute_transfer_function.py index ba271ec5..05c83ae6 100644 --- a/waveorder/cli/compute_transfer_function.py +++ b/waveorder/cli/compute_transfer_function.py @@ -69,6 +69,7 @@ def generate_and_save_vector_birefringence_transfer_function( ) phase_settings_dict = settings.phase.transfer_function.model_dump() phase_settings_dict.pop("z_focus_offset") # not used in 3D + phase_settings_dict.pop("illumination_sector_angles") # not used in vector birefringence sfZYX_transfer_function, _, singular_system = ( inplane_oriented_thick_pol3d_vector.calculate_transfer_function( diff --git a/waveorder/cli/settings.py b/waveorder/cli/settings.py index 77e06d93..fb65247e 100644 --- a/waveorder/cli/settings.py +++ b/waveorder/cli/settings.py @@ -115,7 +115,8 @@ def validate_numerical_aperture_illumination(self): ) return self - @validator("illumination_sector_angles") + @field_validator("illumination_sector_angles") + @classmethod def validate_sector_angles(cls, v): if v is None: return v @@ -195,10 +196,10 @@ def validate_reconstruction_types(self): raise ValueError( '"fluorescence" cannot be present alongside "birefringence" or "phase". Please use one configuration file for a "fluorescence" reconstruction and another configuration file for a "birefringence" and/or "phase" reconstructions.' ) - num_channel_names = len(values.get("input_channel_names")) + num_channel_names = len(self.input_channel_names) # Check for sector illumination in phase reconstruction - phase_settings = values.get("phase") + phase_settings = self.phase if phase_settings is not None: sector_angles = ( phase_settings.transfer_function.illumination_sector_angles @@ -212,15 +213,15 @@ def validate_reconstruction_types(self): else: # Single channel phase reconstruction without sector illumination if ( - values.get("birefringence") is None + self.birefringence is None and num_channel_names != 1 ): raise ValueError( f"{num_channel_names} channels names provided. Please provide a single channel for phase reconstructions without sector illumination." ) else: - if values.get("birefringence") is None: - if values.get("fluorescence") is None: + if self.birefringence is None: + if self.fluorescence is None: raise ValueError( "Provide settings for either birefringence, phase, birefringence + phase, or fluorescence." ) From bb8431568c4a35e0a4e1ddb77c01c1d725ec6951 Mon Sep 17 00:00:00 2001 From: talonchandler Date: Mon, 26 Jan 2026 15:54:43 -0800 Subject: [PATCH 12/12] formatting --- waveorder/cli/compute_transfer_function.py | 4 +++- waveorder/cli/settings.py | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/waveorder/cli/compute_transfer_function.py b/waveorder/cli/compute_transfer_function.py index 05c83ae6..13bf6dc7 100644 --- a/waveorder/cli/compute_transfer_function.py +++ b/waveorder/cli/compute_transfer_function.py @@ -69,7 +69,9 @@ def generate_and_save_vector_birefringence_transfer_function( ) phase_settings_dict = settings.phase.transfer_function.model_dump() phase_settings_dict.pop("z_focus_offset") # not used in 3D - phase_settings_dict.pop("illumination_sector_angles") # not used in vector birefringence + phase_settings_dict.pop( + "illumination_sector_angles" + ) # not used in vector birefringence sfZYX_transfer_function, _, singular_system = ( inplane_oriented_thick_pol3d_vector.calculate_transfer_function( diff --git a/waveorder/cli/settings.py b/waveorder/cli/settings.py index fb65247e..a7fc7216 100644 --- a/waveorder/cli/settings.py +++ b/waveorder/cli/settings.py @@ -212,10 +212,7 @@ def validate_reconstruction_types(self): ) else: # Single channel phase reconstruction without sector illumination - if ( - self.birefringence is None - and num_channel_names != 1 - ): + if self.birefringence is None and num_channel_names != 1: raise ValueError( f"{num_channel_names} channels names provided. Please provide a single channel for phase reconstructions without sector illumination." )