Skip to content

fix: compute confocal excitation at the excitation wavelengths; refactor PSF pipeline - #163

Open
tlambert03 wants to merge 2 commits into
mainfrom
fix/confocal-excitation
Open

tlambert03 wants to merge 2 commits into
mainfrom
fix/confocal-excitation

Conversation

@tlambert03

@tlambert03 tlambert03 commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

This is the PR #161 and #162 were prep for. It started as a one-line question: does point-scanning confocal compute its PSF at the excitation or the emission wavelength? Turns out it was the emission wavelength... for both the excitation spot and the detection PSF. Fixing that properly meant pulling apart how modalities, channels and PSFs fit together, so this restructures the simulation → modality → PSF pipeline.

How images are formed now (widefield is just the case where the excitation is uniform):

  • system PSF = excitation spot × detection PSF. The spot is computed at the excitation wavelengths (several spots for multiple lasers/broadband sources, binned by width via the new Settings.excitation_bin_width_nm); the detection PSF is computed at the emission wavelengths and, for confocal, convolved with an anti-aliased pinhole mask (so small pinholes don't snap to whole pixels).
  • brightness is separate from PSF shape. Each channel × fluorophore gets a detected photon rate (photons/s per fluorophore) and the PSFs carry only shape. For confocal, saturation (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.
  • (confocal) pinhole size in Airy units is defined at the emission filter band center, so one channel has one physical pinhole (rather than a different pinhole per emission wavelength bin). Confocal.airy_unit_wavelength_nm overrides that.

Internally: modalities pick an illumination (WidefieldIllumination/FocusedIllumination) and a detection (CameraDetection/PinholeDetection) from a new private _optics.py, and Simulation hands them a ChannelFluorescence per channel × fluorophore (absorption rate and detected fraction by wavelength) instead of an xarray of emission rates.

Coming next: laser channels (LASER_488 etc.) and updated confocal examples/docs, then acquisition tracks (sequential vs. simultaneous channels).

Breaking changes

  • all confocal output changes (excitation wavelength, pinhole definition).
  • filtered_emission_rates() / emission_flux() apply saturation by default for every modality (new saturate= keyword).
  • removed microsim.psf.cached_psf, Modality.psf(), and point_scanning; Modality.render() has a new signature.
  • cached PSF arrays are read-only (make_psf returns a copy).
  • Identity output follows settings.float_dtype (float32 by default).
  • Confocal.pinhole_au must be at least 0.05 AU: a fully closed pinhole isn't allowed (on main, pinhole_au=0 meant a one-pixel pinhole; now it would pass no light at all).
  • confocal warns for channels without a band-limited emission filter.
  • new fields on Confocal and Settings, so JSON saved with this version won't load in older ones.
  • PSF disk cache layout changed (old cached PSFs are just orphaned, not misread).

Speed is the same as main within noise; peak memory is up ~0.1-0.2 GB (3-6%) on the README example.

Known limitations / follow-ups

  • pinhole throughput depends on pixel size. The pinhole mask area is now exact, but the emission PSF is still sampled at the voxel size, so at coarse pixels the light through a small pinhole is off: at 0.5 AU, -12% at dx=0.1 µm and +27% at 0.2 µm (vs 0.02 µm). The fix is probably computing the detection PSF on a finer grid before the pinhole convolution.
  • the confocal PSF radius is a fixed 8 AU (unchanged from main). It doesn't account for pinhole size or depth, so it clips a bit of out-of-focus excitation in thick volumes and some light from large pinholes.

…tor PSF pipeline

The confocal excitation spot was computed at the emission wavelength.  It is now
computed at the excitation wavelengths, each weighted by how strongly the
fluorophore absorbs it, then saturated locally.

To make this possible, the simulation -> modality -> PSF pipeline is
restructured:

- `Simulation._channel_fluorescence()` returns a `ChannelFluorescence` per
  (channel, fluorophore): absorption rate (photons/s by excitation wavelength)
  and detected fraction (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 changing power doesn't recompute PSFs.
- `microsim._psf` builds PSFs from two cached, single-wavelength primitives:
  `excitation_spot` and `detection_psf`.  `make_psf` and `make_confocal_psf`
  keep their signatures.
- The pinhole mask is anti-aliased (each pixel weighted by the fraction of its
  area inside the pinhole), so small pinholes don't snap to whole pixels.
- 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`.
- New `Settings.excitation_bin_width_nm` (default 10).

BREAKING CHANGES:
- all confocal output changes (excitation wavelength, pinhole definition).
- `filtered_emission_rates()` / `emission_flux()` apply saturation by default
  for every modality (new `saturate=` keyword).
- removed `microsim.psf.cached_psf`, `Modality.psf()` and `point_scanning`
  (replaced by `detection_type.sums_over_pixel_area`); `Modality.render()`
  takes per-channel/fluorophore fluorescence instead of emission rates.
- cached PSF arrays are read-only; `make_psf` returns a copy.
- `Identity` output follows `settings.float_dtype` (float32 by default).
- confocal warns for channels without a band-limited emission filter, and
  raises if a channel's pinhole detects no light.
- PSF disk cache layout changed (old cached PSFs are orphaned, not reused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.03922% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.14%. Comparing base (1773a2e) to head (9fc40dc).

Files with missing lines Patch % Lines
src/microsim/psf.py 90.90% 3 Missing ⚠️
src/microsim/schema/optical_config/config.py 85.71% 2 Missing ⚠️
src/microsim/schema/simulation.py 95.23% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #163      +/-   ##
==========================================
+ Coverage   88.26%   89.14%   +0.87%     
==========================================
  Files          49       51       +2     
  Lines        3316     3483     +167     
==========================================
+ Hits         2927     3105     +178     
+ Misses        389      378      -11     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

A (nearly) closed pinhole passes (almost) no light, so pinhole_au=0 gave an
all-zero image.  On main, 0 meant a one-pixel pinhole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant