Repository navigation
fix: confocal excitation at the excitation wavelength; redesign PSF pipeline, add acquisition tracks and laser channels - #160
Open
tlambert03 wants to merge 7 commits into
Open
tlambert03 wants to merge 7 commits into
tlambert03 wants to merge 7 commits into
Conversation
…tor PSF pipeline The confocal excitation spot was computed at each emission wavelength. It is now computed at the excitation wavelengths, each weighted by how strongly the fluorophore absorbs it, then saturated. To make this possible without digging channel/fluorophore objects back out of xarray coordinates, the simulation -> modality -> PSF pipeline is restructured: - `Simulation.channel_fluorescence()` returns a `ChannelFluorescence` per (channel, fluorophore): absorption (photons/s by excitation wavelength) and detection (photons detected per absorbed photon, by emission wavelength), always unsaturated. - Modalities combine an illumination (`WidefieldIllumination`, `FocusedIllumination`) and a detection (`CameraDetection`, `PinholeDetection`) into a system PSF (excitation spot x detection PSF), kept separate from the scalar brightness so cached OTFs don't depend on power. - `psf.py` builds PSFs from two cached, single-wavelength primitives: `excitation_spot` and `detection_psf` (disk cache "v4"). `make_psf` and `make_confocal_psf` keep their signatures; `cached_psf` is removed. - The confocal pinhole has one physical size per channel: `pinhole_au` is in Airy units at the center of the emission filter band, or at the new `Confocal.airy_unit_wavelength_nm`. - `point_scanning` is replaced by `detection_type.sums_over_pixel_area`. - `filtered_emission_rates` and `emission_flux` mean the same thing for every modality (rate at the reference intensity) and take `saturate=True`. Also fixes: - lasers between sampled wavelengths (e.g. 488.5 nm) were not absorbed at all: filters and cross sections are now interpolated at the light's wavelengths - spectral binning no longer loses photons (brightness comes from the full spectrum; `include_lowest=True`) - a spectral detector QE was ignored for channels without an emission filter New setting: `Settings.excitation_bin_width_nm` (default 10). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pinhole mask was a binary disk (pixel open if its center is inside), so a pinhole a few pixels across snapped to whole pixels: e.g. 0.25 AU at 0.03 um pixels opened 9 px instead of 11.3 px (-21%), and throughput changed in steps as `pinhole_au` or the pixel size varied. Each pixel is now weighted by the fraction of its area inside the pinhole (supersampled), so the open area matches the pinhole and varies smoothly. 1 AU results change by ~0.2%. Bumps the PSF disk cache version to "v5". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes: - confocal no longer fails on a channel that detects nothing (e.g. a longpass channel with no matching fluorophore): the pinhole is only sized once some fluorophore is detected, and the channel is dark - a light source sampled at a single wavelength no longer loses its wavelength axis (crashed even in widefield) - `OpticalConfig.emission_band_center` integrates over wavelength (unbiased on non-uniform grids), and treats a spectrum covering only its passband as band-limited; only bands wider than 150 nm that run off the end of the data (e.g. longpass) fall back to the center of the detected light, now with a `UserWarning` (shown once per message) instead of a log on every render - `Confocal.airy_unit_wavelength_nm` must be > 0 - cached PSFs are read-only (numpy), and `make_psf` returns a copy, so modifying a returned PSF can't corrupt the cache; the pinhole radius is passed by keyword and wavelengths are normalized to float, so identical PSFs share cache entries - PSF wavelengths are rounded to 0.5 nm (< 0.1% PSF width), so fluorophores with nearly the same centroid share PSFs Renames (for clarity): - `ChannelFluorescence.absorption` -> `absorption_spectrum`, `.absorption_rate` -> `total_absorption_rate`, `.detection` -> `detected_fraction` - `microsim.psf.saturate` -> `apply_saturation` `ChannelFluorescence` is now exported from `microsim.schema`. Public API changes in this PR (beyond those already described): - `Modality.render(truth, em_rates, ...)` is now `render(truth, fluorescence, ...)` (custom modalities overriding `render` must be updated) - the `.psf()` methods of `Widefield` / `Confocal` are removed (use `microsim.psf.make_psf`) - `Spectrum * Spectrum` interpolates both onto the union of their wavelengths (previously: outer join, zero-filled between samples) - `OpticalConfig.filtered_emission_rate` returns values on the fluorophore's emission wavelengths, and applies a spectral detector QE even without an emission filter - absorption changes for any light sampled off the fluorophore's wavelength grid (not only lasers), since spectra are now interpolated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Most point scanners detect the channels they acquire together through a single pinhole. `Confocal.shared_pinhole` (default True) sizes one pinhole for the channels sharing it, with 1 AU at the average of their emission filter band centers (or at `airy_unit_wavelength_nm`, if set). `shared_pinhole=False` gives each channel its own pinhole, matched to its emission band. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tracks (sequential vs. simultaneous acquisition): - New `Simulation.tracks`: "sequential" (default, each channel excited only by its own light sources, as before), "simultaneous", or a list of `Track`s (bare lists of channel indices or names are cast, e.g. [[0, 2], [1, 3]]). All light sources of a track excite every fluorophore, so fluorophores can bleed into every channel of their track. - A light source used by several channels of a track is counted once; those channels must agree on its power and excitation filters (ValueError otherwise, or if they share only some light sources). - Point scanners: channels in a track must share one pixel dwell time. - `Confocal.shared_pinhole` now shares a pinhole within a track (so with the default sequential tracks, each channel has its own pinhole). - A rejected `Simulation` field assignment no longer leaves the new value set. Library and examples: - `LASER_405/445/488/515/561/640` channels (laser, dichroic, emission bandpass). - Confocal examples, notebooks and docs use lasers, EGFP/EYFP, HyD detectors and realistic powers/dwell times; README example uses a 0.6 AU pinhole. Fixes: - `pinhole_au` of 0 (or pinholes much smaller than a pixel) no longer tries to allocate hundreds of GB for the anti-aliased pinhole mask. - Corrected uW equivalents of peak irradiance in comments (1e5 W/cm^2 is ~50 uW at NA 1.4). - Fewer temporary PSF-sized arrays when building confocal PSFs; track absorption is computed once per track. Internals: - PSF implementation moved to private `microsim._psf`; `microsim.psf` keeps `make_psf`, `make_confocal_psf` and the previously public vectorial functions. - `ChannelFluorescence` (now not exported) `absorption_spectrum` field renamed `absorption_rate`; `Simulation.channel_fluorescence()` is now private. BREAKING CHANGES (vs. main, for this whole branch): - Confocal PSF uses the excitation wavelengths for the excitation spot (was the emission wavelength): all confocal output changes. - Confocal `pinhole_au` is one physical pinhole per channel/track, in Airy units at the emission filter band center (track average if shared); new `Confocal.airy_unit_wavelength_nm` and `Confocal.shared_pinhole`. - `filtered_emission_rates()` and `emission_flux()` apply saturation by default (also for confocal); new `saturate` keyword. - Removed `microsim.psf.cached_psf`, `Modality.psf()` and `point_scanning`; `Modality.render()` takes per-channel/fluorophore fluorescence instead of emission rates. - `make_psf`/`make_confocal_psf` and cached PSF arrays: cached arrays are read-only; `make_psf` returns a copy. - Widefield images are 0.4-5.5% brighter (the lowest-wavelength emission sample is no longer dropped; brightness uses the full spectrum). - Detector QE now applies to channels without an emission filter. - Laser lines between wavelength grid points now excite (were silently 0); `Spectrum * Spectrum` interpolates onto the union of both wavelength grids (was zero-filled outer join); absorption is interpolated onto the light's wavelengths. - `filtered_emission_rates` spans the full fluorophore emission range (zeros outside the filters). - `Identity` output dtype follows `settings.float_dtype` (float32 by default). - Confocal warns for channels without a band-limited emission filter, and raises if a pinhole track detects no light. - New `Settings.excitation_bin_width_nm`; `spectral_bin_threshold_percentage` also applies to excitation binning. - PSF disk cache layout changed (old cached PSFs are orphaned, not reused). - New fields mean JSON saved by this version won't load in older versions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #160 +/- ##
==========================================
+ Coverage 88.26% 89.96% +1.70%
==========================================
Files 49 51 +2
Lines 3316 3569 +253
==========================================
+ Hits 2927 3211 +284
+ Misses 389 358 -31 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…on-wavelength # Conflicts: # src/microsim/_psf.py # src/microsim/psf.py # src/microsim/schema/modality/_simple_psf.py # tests/test_photometry.py
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This started as fix to make point scanning confocal use the excitation wavelength for the PSF instead of emission. Fixing that properly meant pulling apart how modalities, channels and PSFs fit together, so this ended up being a redesign of the simulation → modality → PSF pipeline.
The model now:
Settings.excitation_bin_width_nm); the detection PSF is computed at the emission wavelengths and convolved with an anti-aliased pinhole mask (so small pinholes don't snap to whole pixels).E / (1 + sE)) is applied locally across the spot, so it's strongest at the center. A nice side effect: changing laser power no longer recomputes PSFs (0.12 s vs 1.84 s on main).Confocal.airy_unit_wavelength_nmoverrides that.Tracks (sequential vs. simultaneous acquisition). The previous model assumed sequential channel acquisition, (with broadband light sources that still can result in bleedthrough). On a real point scanner you often choose which channels are acquired together.
Simulation.tracksnow models that:"sequential": each channel is excited only by its own light, which is what main did.Track(bare lists are cast), so we have a place to put per-track settings later (pinhole, dwell, averaging, power...).Confocal.shared_pinhole(default True) shares one pinhole within a track.Lasers and examples. Added
LASER_405/445/488/515/561/640channels to the library (laser + dichroic + emission bandpass), and went through all the confocal examples/notebooks/docs so they use laser lines, real fluorophores, HyD detectors and realistic powers and dwell times, rather than broadband filter cubes with the mock fluorophore. For reference,poweris the peak irradiance at the focus: 1e5 W/cm² is ~50 µW at NA 1.4. The README example now uses a 0.6 AU pinhole (0.2 AU was very photon-starved).Also fixed along the way:
Spectrum * Spectrumnow interpolates onto the union of both grids.The PSF implementation moved to a private
microsim._psfmodule.microsim.psfkeeps the same public functions as on main (make_psf,make_confocal_psf,vectorial_*...). I'd like to move the rest of the accidentally-public stuff there in a future PR.Breaking changes
There are a lot here... the next release v0.0.13 will absolutely be breaking. but it's always been part of the contract here for this early dev project:
filtered_emission_rates()/emission_flux()apply saturation by default (newsaturate=keyword).microsim.psf.cached_psf,Modality.psf(), andpoint_scanning;Modality.render()has a new signature.make_psfreturns a copy).Identityoutput followssettings.float_dtype(float32 by default).Simulation,ConfocalandSettings, so JSON saved with this version won't load in older ones.Performance
Same speed as main, within noise. Peak memory is up ~0.1-0.2 GB (3-6%):
Known limitations / follow-ups