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
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 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
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
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.
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.)
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
sensorstep you must passvmax. A sensorless movie is continuous intensity with no ADC range, so there is no natural white point;write_video/simulate_videoraise unless you give one. With asensor,vmaxdefaults 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 lowerBrainMotion(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.