Skip to content

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
mainfrom
fix/confocal-excitation-wavelength
Open

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

Conversation

@tlambert03

Copy link
Copy Markdown
Owner

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:

  • Modalities are now responsible for creating system PSF = excitation spot × detection PSF. The spot is computed at the excitation wavelengths (several lines 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 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. 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 (0.12 s vs 1.84 s on main).
  • 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.

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.tracks now models that:

sim = Simulation(
    ...,
    channels=[LASER_405, LASER_488, LASER_561, LASER_640],
    tracks=[[0, 2], [1, 3]],  # by index or channel name, or "sequential"/"simultaneous"
)
  • default is "sequential": each channel is excited only by its own light, which is what main did.
  • within a track, every laser excites every fluorophore, so you get realistic bleedthrough.
  • each item is a Track (bare lists are cast), so we have a place to put per-track settings later (pinhole, dwell, averaging, power...).
  • a laser shared by channels in a track is counted once, and channels must agree on its power/dichroic. Point-scanner channels in a track must also share a dwell time.
  • Confocal.shared_pinhole (default True) shares one pinhole within a track.

Lasers and examples. Added LASER_405/445/488/515/561/640 channels 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, power is 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:

  • an off-grid laser line (e.g. 488.5 nm) gave exactly zero excitation (inner join against the 1 nm grid). Spectrum * Spectrum now interpolates onto the union of both grids.
  • the lowest-wavelength emission sample was dropped from PSF weights (widefield is now 0.4-5.5% brighter).
  • detector QE wasn't applied to channels without an emission filter.

The PSF implementation moved to a private microsim._psf module. microsim.psf keeps 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:

  • all confocal output changes (excitation wavelength, pinhole definition, local saturation).
  • filtered_emission_rates() / emission_flux() apply saturation by default (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 warns for channels without a band-limited emission filter, and raises if a track's pinhole detects no light.
  • new fields on Simulation, 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).

Performance

Same speed as main, within noise. Peak memory is up ~0.1-0.2 GB (3-6%):

main this PR
README confocal, cold PSF cache 2.68 s / 3.81 GB ~2.7 s / 4.01 GB
README confocal, warm disk cache 1.10 s 1.05 s
widefield, cold 2.28 s 2.15 s
4 channels × 2 fluors, cold 6.55 s 6.30 s

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.
  • each channel uses its own dichroic, even within a track (a real scanner has one multiband dichroic per track).

tlambert03 and others added 6 commits October 11, 2026 22:21
…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

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.22785% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.96%. Comparing base (1773a2e) to head (5fe0ebb).

Files with missing lines Patch % Lines
src/microsim/psf.py 90.90% 3 Missing ⚠️
src/microsim/schema/optical_config/config.py 90.00% 2 Missing ⚠️
...rc/microsim/schema/optical_config/_fluorescence.py 98.24% 1 Missing ⚠️
src/microsim/schema/simulation.py 98.70% 1 Missing ⚠️
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.
📢 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.

…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

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