Examples: from minimal to full#

A code-first ladder through the forward pipeline. Each rung adds one step to the steps list and shows what it buys you - starting from sharp cells on black and ending at a full, noisy recording. Every step is optional and composable: a step that is absent simply contributes nothing (there is no hidden default-valued version running), so any rung below is a valid recording on its own and an honest control for the rung above it.

This is the static, copy-paste companion to the interactive anatomy notebook (which builds the same chain with live sliders). Every figure here is reproducible from the snippet beside it.

All rungs share one acquisition - a 200 µm field at 1.0 µm/px (8 µm sensor pitch ÷ 8× magnification), 20 s at 20 fps:

from minisim import Acquisition, Optics, ImageSensor

acq = Acquisition(
    fps=20.0,
    duration_s=20.0,
    optics=Optics(magnification=8.0),                  # 8x
    image_sensor=ImageSensor(
        n_px_height=200, n_px_width=200,
        pixel_pitch_um=8.0,                            # 8 / 8x = 1.0 µm/px -> 200 µm FOV
    ),
)

1. Minimal: cells on black#

The smallest spec that produces a movie: place neurons, give them calcium activity, and composite them into pixels. No optics step, so the cells are sharp disks; no sensor, so the movie is clean continuous intensity.

from minisim import Spec, simulate, PlaceNeurons, CellActivity, Composite

rec = simulate(Spec(acquisition=acq, seed=1, steps=[
    PlaceNeurons(),     # where the cells are (3-D volume)
    CellActivity(),     # calcium traces so they blink
    Composite(),        # cells -> the movie
]))

composite and cell_activity are not optional “effects”: without traces, composite has nothing to scale the footprints by and the movie is blank. The recording already ships full ground truth - cell centers, the planted footprints A, and the clean traces C/spikes S:

gt = rec.ground_truth
gt.centers_um      # (n, 3) cell centers as (z, y, x) µm
gt.A_planted       # (n, H, W) sharp footprints
gt.C, gt.S         # (n, frames) calcium traces and spike counts
a movie frame of sharp cells, a depth-colored position scatter, and calcium traces

Left: a frame (cells on black). Middle/right: ground truth - cell positions colored by depth z, and a few calcium traces C.#

2. Add optics: blur and dimming by depth#

The optics step degrades each footprint by its depth: diffraction + defocus (distance from the focal plane) + scatter blur, plus scatter attenuation and the NA² collection loss. Cells away from the focal plane blur out; deep cells also dim.

from minisim import CellOptics

rec = simulate(Spec(acquisition=acq, seed=1, steps=[
    PlaceNeurons(), CellActivity(),
    CellOptics(),       # depth-dependent blur + dimming
    Composite(),
]))

CellOptics has no tunable fields - the blur and attenuation are fully determined by each cell’s depth and the physical Optics/Tissue constants on the acquisition. With focal_depth_in_tissue_um="auto" (the default) the focal plane is resolved here; the per-cell results land in ground truth:

gt = rec.ground_truth
gt.focal_depth_um                  # the resolved focal plane (µm)
gt.A_planted, gt.A_observed        # (n, H, W) sharp vs optically-degraded footprints
gt.observed_sigma_px, gt.depth_um  # (n,) per-cell blur width and depth
gt.in_focus                        # (n,) within the depth of field?
one cell's planted vs observed footprint, and a blur decomposition vs depth

One cell from this recording, before and after optics: its planted footprint A (the sharp truth) and its observed footprint (blurred and dimmed), shown at its own scale to reveal the blur shape. Right: ground truth across the whole population - the per-cell total blur (dots) decomposed into defocus (the “V”, zero at the focal plane) and scatter (the ramp growing with depth), which add in quadrature, while brightness (red) falls with depth.#

3. Add brain motion#

brain_motion rigidly translates the tissue frame per frame and crops the sensor FOV from a margin-padded canvas (sized automatically), so real off-FOV tissue moves into view. This is the motion you would run motion-correction against.

from minisim import BrainMotion

rec = simulate(Spec(acquisition=acq, seed=1, steps=[
    PlaceNeurons(), CellActivity(), CellOptics(), Composite(),
    BrainMotion(),      # rigid (dy, dx) translation per frame
]))

gt = rec.ground_truth
gt.shifts            # (frames, 2) per-frame (dy, dx) displacement in PIXELS
per-frame shift over time and the 2-D motion path

Ground truth: the per-frame (dy, dx) shift over time (left) and the 2-D motion path (right). The default "physical" model is a damped oscillator driven by a locomotion rhythm plus broadband sloshing - the exact displacements are recorded, so recovered shifts can be scored against them.#

4. Add neuropil background#

neuropil adds the diffuse haze from the surrounding dendritic/axonal felt: a smooth spatial field modulated by a biologically-driven temporal envelope (the local population’s lagged calcium plus an independent slow drift).

from minisim import Neuropil

rec = simulate(Spec(acquisition=acq, seed=1, steps=[
    PlaceNeurons(), CellActivity(), CellOptics(), Composite(),
    Neuropil(),         # additive diffuse background
]))

gt = rec.ground_truth
gt.neuropil_spatial      # (n_comp, H, W) smooth spatial components
gt.neuropil_temporal     # (n_comp, frames) per-component envelopes
gt.neuropil_population   # (frames,) the population activity driver
a frame with neuropil haze, the neuropil spatial field, and its temporal envelopes

Left: a frame - cells (dimmed by optics) sitting in the diffuse haze. Middle/right: ground truth from this recording - the neuropil’s smooth spatial field (sum of its components) and the per-component temporal envelopes with the population driver (black). The haze tracks population activity rather than blinking, so it is contamination demixing must separate from the real traces.#

5. Add the static optical fields#

Three scope-fixed fields, all smooth and static (they do not move with the brain): illumination_profile (excitation brighter at center), vignette (collection light loss toward the corners), and leakage (an additive stray-light glow). Each is recorded to ground truth as an (H, W) field.

from minisim import IlluminationProfile, Vignette, Leakage

rec = simulate(Spec(acquisition=acq, seed=1, steps=[
    PlaceNeurons(), CellActivity(), CellOptics(), Composite(), Neuropil(),
    IlluminationProfile(),   # excitation falloff (multiplicative)
    Vignette(),              # collection falloff (multiplicative)
    Leakage(),               # stray-light baseline (additive)
]))

gt = rec.ground_truth
gt.illumination, gt.vignette, gt.leakage    # (H, W) static fields

These three are exactly the smooth, static background that minian’s “glow removal” estimates and strips (the multiplicative falloffs and the additive leakage), because the cells are sharp and moving while the fields are not.

illumination times vignette field, the leakage glow, and a frame with both applied

Left/middle: the combined illumination × vignette field and the additive leakage glow. Right: a frame with the fields applied - bright center, dim corners, plus the central haze.#

6. Full recording: add the sensor#

sensor is the last step and the only one that produces integer counts: it turns the clean intensity into raw 8-bit ADC counts via Poisson shot noise, Gaussian read noise, gain, and quantization. This is where SNR becomes real (it emerges from the photon budget against the noise floor, never set by hand) and where the detectable flag and the auto-focus yield go live.

from minisim import Bleaching, Sensor

rec = simulate(Spec(acquisition=acq, seed=1, steps=[
    PlaceNeurons(), CellActivity(),
    Bleaching(),             # cell-domain: slow activity-driven photobleaching
    CellOptics(), Composite(), Neuropil(),
    BrainMotion(),
    IlluminationProfile(), Vignette(), Leakage(),
    Sensor(),                # photons -> noisy integer counts
]))

gt = rec.ground_truth
gt.detectable        # (n,) cells whose transient clears the sensor noise floor
rec.observed         # (frames, H, W) raw 8-bit counts

(bleaching is cell-domain, so it sits before composite with the other per-cell steps. Its fade acts over minutes, so it is negligible in a 20 s clip - included here for completeness.)

noise-free expected counts vs realized noisy counts, and the ADC count histogram

Left: the noise-free expected counts (the intensity the sensor sees, digitized without the random draws). Middle: the same frame with the sensor’s shot + read noise - identical scene, only the noise differs. Right: the count histogram; the spike at 255 is saturation clipping at the 8-bit ceiling.#

Both panels come from the same run (until="leakage" for the left): with a sensor present, the optics "auto" focus switches to the yield-maximizing plane, so a separate sensorless run would focus on a different plane and show a different scene - the sensor is what makes the focus decision go live.

Writing a video, and two gotchas#

Any rung can be written straight to a grayscale video (needs the notebook extra, pip install "minisim[notebook]"):

rec.write_video("recording.avi", vmax=float(rec.observed.max()))
  • Without a sensor step you must pass vmax. A sensorless movie is continuous intensity with no ADC range, so there is no natural white point; write_video/simulate_video raise unless you give one. With a sensor, vmax defaults to the full ADC range automatically.

  • Mind motion vs FOV. A spec warns (SpecWarning) if the motion extent exceeds ~5% of the FOV. At this 200 µm field the default ~10 µm motion is right at 5% (fine); on a smaller FOV, either lower BrainMotion(motion_amplitude_um=...) or widen the field with a coarser pixel.

For the same progression with live sliders and the physics narrated stage by stage, see the anatomy notebook.