mehta-lab / waveorder

Wave optical models and inverse algorithms for label-agnostic imaging of density & orientation.
BSD 3-Clause "New" or "Revised" License
12 stars 3 forks source link

New Stokes and Mueller module #110

Closed talonchandler closed 1 year ago

talonchandler commented 1 year ago

This PR adds a completely rewritten version of all Stokes- and Mueller-related calculations in waveorder.

This rewrite was motived by the following questions:

Highlight improvements:

What does the new API look like? Here's an example snippet from /hpc/projects/compmicro/projects/infected_cell_imaging/Image_preprocessing/Exp_2023_02_07_A549MemNucl_PolPhase3D/Background_correction_trial/bg-corr-with-mask.py

# Calculate A and A_inv
A = stokes.A_matrix(swing=0.1, scheme="5-State")
A_inv = np.linalg.pinv(A)

# Apply A_inv to background and sample data
S_bg = stokes.mmul(A_inv, cyx_bg_data)
S_sm = stokes.mmul(A_inv, czyx_data)

# Calculate background correction matrix from S_bg
M_inv = stokes.inv_AR_mueller_from_CPL_projection(*S_bg)

# Apply background correction to sample data
bg_corr_S_sm = stokes.mmul(M_inv, S_sm)

# Reconstruct parameters
ret, ori, tra, dop = stokes.inverse_s0123_CPL_after_ADR(*bg_corr_S_sm)

Limitations compared to the current waveorder_reconstructor implementation:

I have not removed the existing implementation in the waveorder_reconstructor class. My current plan is to discuss the technical parts of this reimplementation and compare with the existing implementation here, then later I can complete the refactor by removing the Stokes parts of the waveorder_reconstructor class and updating the recOrder calls.

Note: this PR is to merge into alg-dev, so we have a bit more flexibility in the changes. Temporarily breaking changes/suggestions are okay while we iterate.

Specific feedback requests:

mattersoflight commented 1 year ago

@ziw-liu your comments on a gpu switch, and on documentation+testing practice is very welcome. I wasn't sure if I should use type annotations & numpy-style docstrings? I stayed with only docstrings for now.

Re: GPU implementation, the API between pytorch and numpy seems quite consistent i.e., object.operation runs on CPU if object is a numpy array and on GPU if object is a torch tensor on GPU, e.g. pinv. If we are going to replace the GPU library, I'd first consider pytorch. Can you do a census of numpy methods you use and see if the same methods are available for torch tensor?

@mattersoflight I'm particularly interested in your thoughts on naming. For example, inverse_s0123_CPL_after_ADR doesn't roll off the tongue like the earlier Polarization_recon, but I think it's important to be very specific at this level. Later layers of abstraction can reintroduce more abstract names likes Polarization_recon if we think they're helpful.

If we find that all the assumptions in the forward model related to polarization transfer can be covered by two properties: a) instrument matrix, and b) sample model, we can use a generic name and specify the assumptions via arguments. I'll think more about this.

mattersoflight commented 1 year ago

fft is not relevant for this PR, but for deconvolution operations later. This benchmark reports that pytorch fft works almost as fast as cupy: https://thomasaarholt.github.io/fftspeedtest/fftspeedtest.html. pytorch is accelerated on M1, but cupy will require a nvidia gpu.

ziw-liu commented 1 year ago

Can you do a census of numpy methods you use and see if the same methods are available for torch tensor?

I wouldn't be too concerned about NumPy methods. However SciPy signal processing API may have a much lower coverage. Will have to check in detail.

ziw-liu commented 1 year ago

Using torch is an interesting idea, in that it is 'accelerated' for CPUs too, so in theory the same code can work for CPU and GPU. However in addition to API coverage, lack of optimization/more overhead can be potential issues.

ziw-liu commented 1 year ago

I wasn't sure if I should use type annotations & numpy-style docstrings? I stayed with only docstrings for now.

We don't currently have type checking infra set up, so type hints serves mainly 2 purpose:

  1. Help devs as well as users call the API in code elsewhere, because autocompletion and other in-editor static analysis works better.
  2. Help generate the docstring. I personally use tools that populate the type info in the docstring automatically from docstrings.

I like to write type hints because it helps me code faster (e.g. I get syntax-highlighted and linted types that's copied over so less typos in the docstring type field). But as long as the code is consistent in style and well-documented I think it's all fine.

Soorya19Pradeep commented 1 year ago

@talonchandler, the cell membrane signal from the new orientation image computed with the additional background correction definitely has more contrast and is more continuous signal compared to the earlier version with just measured background correction. Thank you!

mattersoflight commented 1 year ago

@talonchandler

@Soorya19Pradeep asked about the consistency of this convention with the existing waveorder implementation.

Could you clarify which convention we are discussing here: convention for what is called right vs left circularly polarized light, or convention for axes of orientation, or may be both?

mattersoflight commented 1 year ago

@ziw-liu , @talonchandler

Using torch is an interesting idea, in that it is 'accelerated' for CPUs too, so in theory the same code can work for CPU and GPU. My thought was to keep using numpy for CPU, and use torch instead of cupy for GPU. There can be code branches depending on whether you use CPU or GPU, but only when matrices (tensors) are instantiated.

Let's focus on the model (which is making a lot of sense as I read it), naming convention, and numpy implementation in this PR, and start a separate issue to work on GPU acceleration. We should refactor whole codebase (including deconvolution code) if we change the GPU backend.

Soorya19Pradeep commented 1 year ago

@talonchandler

@Soorya19Pradeep asked about the consistency of this convention with the existing waveorder implementation.

Could you clarify which convention we are discussing here: convention for what is called right vs left circularly polarized light, or convention for axes of orientation, or may be both?

@mattersoflight, I am trying to understand how to read the orientation measurement of cell membrane, if it makes physical sense. The value of orientation changes with new implementation and further background correction, so I was curious. This is from orientation information from a cell from earlier version with measured background correction, colorbar range of values (-0.87,+0.87)

image

This is from the new version with just measured background correction, range (+0.87,+2.5). I realized the information here is same, just inverted and offset by 90 degrees.

image

After the extra background correction the range changes and more information is visible, range (0,+3.14).

image
talonchandler commented 1 year ago

@mattersoflight

Could you clarify which convention we are discussing here: convention for what is called right vs left circularly polarized light, or convention for axes of orientation, or may be both?

Good question...I think we should discuss both.

I can think of two paths to take here:

talonchandler commented 1 year ago

Thanks for the comparison @Soorya19Pradeep. Very helpful.

I realized the information here is same, just inverted and offset by 90 degrees.

These are the types of knobs that we might provide in the "decoupling" approach: one checkbox/function for "invert orientation" and one for "rotate by 90 degrees".

mattersoflight commented 1 year ago

Decouple the reconstruction from the orientation convention. We can give ourselves some flexibility in RCP vs. LCP, relative orientations of the camera wrt the polarizers, axis convention etc., then give the recOrder user (and the scripts) enough "knobs" to fix any deviation from convention. For example, we currently have one knob in recOrder that rotates by 90 degrees. To set the knobs, image a known sample (ideally a Kazansky target, but we can point our users to a more available alternative), twiddle the knobs until your colors match your expectations, then keep those knobs as default reconstruction parameters. This is effectively what we're doing now, and I think it's workable.

This is the most general approach for any light path. I think two knobs suffice to register the angular coordinate system in data with the angular coordinate system on stage: one flip and one rotation.

mattersoflight commented 1 year ago

After the extra background correction the range changes and more information is visible, range (0,+3.14).

Thanks, @Soorya19Pradeep for the examples. It is great that you are taking a close look at FOVs.

Seeing the full dynamic range of orientation after correcting background bias is promising! To be sure of the accuracy of the measurement, I suggest finding some patches where you see strong cortical actin bundles. If the background correction in this (arguably challenging) case is accurate, you'd see that orientation is parallel to the actin bundle. Once you have reconstructed retardance and orientation, you can call waveorder.visual.plotVectorField to visualize the orientation.

talonchandler commented 1 year ago

I've just completed the renaming/refactoring. @mattersoflight this is ready for your re-review.

The latest API (at this level) looks like:

# Calculate I2S
I2S = stokes.I2S_matrix(swing, scheme="5-State")

# Calculate Stokes vectors
S_sm = stokes.mmul(I2S, czyx_data)  # Apply I2S to sample data
S_bg_meas = stokes.mmul(I2S, cyx_bg_data)  # Apply I2S to measured background

# Calculate background correction matrix from S_bg
M_bg_inv = stokes.mueller_from_stokes(*S_bg_meas)

# Apply measured background correction to sample data
bg_corr_S_sm = stokes.mmul(M_bg_inv, S_sm)

# Recover ADR parameters
ret, ori, tra, dop = stokes.estimate_ADR_from_stokes(*bg_corr_S_sm)

I've also spent some time characterizing the old (green profiles) vs. new algorithms (white profiles).

Soorya's cells - retardance on y axis - measured bkg correction only Screenshot 2023-03-06 at 5 53 03 PM At most 2% difference in retardance.

Kazansky target - retardance on y axis - measured bkg correction only Screenshot 2023-03-06 at 5 31 36 PM At most 1% difference in retardance.

Kazansky target - orientation on y axis - measured bkg correction only Screenshot 2023-03-06 at 5 35 18 PM At most 2% difference in non-background regions when the different orientation convention is accounted for. This main difference here is from a difference in orientation conventions which we'll be handling with two user-facing switches as discussed above.

Timing Current performance bottleneck is the pre-calculation of mueller_from_stokes from the background stokes vectors, which can be further optimized (I expect factors of 2-4x). For now, here are a couple benchmarks:

1 x 2048 x 2048: old algorithm: 1.0 s new algorithm: 15.4 s

8 x 2048 x 2048: old algorithm: 17.8 s new algorithm: 19.6 s

Example comparison script (generates Kaz target comparison above)

Full example script (click to expand): ``` import numpy as np from waveorder import stokes from recOrder.io.utils import load_bg from recOrder.io.metadata_reader import MetadataReader from recOrder.compute.reconstructions import ( initialize_reconstructor, reconstruct_qlipp_stokes, reconstruct_qlipp_birefringence, ) from iohub.reader import imread from iohub.ngff import open_ome_zarr import napari # Set paths base_path = "/hpc/projects/compmicro/rawdata/hummingbird/Talon/2023_02_08_kaz/" data_subpath = "kaz-raw_recOrderPluginSnap_0/kaz-raw_RawPolDataSnap.zarr" bg_subpath = "BG" cal_subpath = "calibration_metadata.txt" # Read data reader = imread(base_path + data_subpath) T, C, Z, Y, X = reader.shape czyx_data = reader.get_array(position=0)[0, ...] # Read background data cyx_bg_data = load_bg(base_path + bg_subpath, height=Y, width=X) # Read calibration metadata md = MetadataReader(base_path + cal_subpath) def new_bg_correction(czyx_data, cyx_bg_data, swing, scheme): # Calculate I2S I2S = stokes.I2S_matrix(md.Swing, scheme=md.get_calibration_scheme()) # Calculate Stokes vectors S_sm = stokes.mmul(I2S, czyx_data) # Apply I2S to sample data S_bg_meas = stokes.mmul(I2S, cyx_bg_data) # Apply I2S to measured background # Calculate background correction matrix from S_bg M_bg_inv = stokes.mueller_from_stokes(*S_bg_meas) # Apply measured background correction to sample data bg_corr_S_sm = stokes.mmul(M_bg_inv, S_sm) # Recover ADR parameters ret, ori, tra, dop = stokes.estimate_ADR_from_stokes(*bg_corr_S_sm) ret = ret / (2 * np.pi) * 532 return ret, ori, tra, dop def old_bg_correction(czyx_data, cyx_bg_data, swing, scheme): reconstructor_args = { "image_dim": (Y, X), "n_slices": 1, # number of slices in z-stack "wavelength_nm": 532, "swing": swing, "calibration_scheme": scheme, # "4-State" or "5-State" "bg_correction": "global", } reconstructor = initialize_reconstructor( pipeline="birefringence", **reconstructor_args ) # Reconstruct background Stokes bg_stokes = reconstruct_qlipp_stokes(cyx_bg_data, reconstructor) # Reconstruct data Stokes w/ background correction stokes = reconstruct_qlipp_stokes(czyx_data, reconstructor, bg_stokes) birefringence = reconstruct_qlipp_birefringence(stokes, reconstructor) birefringence[0] = ( birefringence[0] / (2 * np.pi) * reconstructor_args["wavelength_nm"] ) return birefringence oldADR = old_bg_correction(czyx_data, cyx_bg_data, md.Swing, md.Calibration_scheme) newADR = new_bg_correction(czyx_data, cyx_bg_data, md.Swing, md.Calibration_scheme) v = napari.Viewer() v.add_image(oldADR[..., 890:1220, 790:1370], name="old") v.add_image(np.stack(newADR)[..., 890:1220, 790:1370], "new") import pdb; pdb.set_trace() ```
talonchandler commented 1 year ago

I neglected to commit one small change: stokes.mueller_from_stokes should be direction=inverse by default since this is the most common usage mode (and the usage mode I showed in the snippet from last night).

mattersoflight commented 1 year ago

thanks for the offline discussion. Looks great to me!