_frame_payload auto-contrasts every frame independently: a 1st–99th percentile stretch computed off a strided sample of the downsampled frame, then scaled to 8-bit before JPEG encoding (device_layer.py:967-981).
It is a sensible default for a first look, and wrong for everything after that.
Why it fights the operator
The range moves while you watch. Each frame gets its own lo/hi, so brightness changes because the normalisation shifted, not because the sample did. Judging focus by eye means judging a signal that is being renormalised underneath you. The same is true of the focus score's visual counterpart — an operator sweeping the F-drive is comparing frames that were each scaled differently.
Faint or sparse nuclei are clipped. Bright nuclei on a dark field occupy the top fraction of a percent of the histogram, which is precisely what the 99th-percentile ceiling discards. Ryan at 20:19 on the 2026-08-07 walkthrough, on why two-point calibration was hard: "the embryo is a little small, so we'd probably zoom in on this like in Micro-Manager to make it easier to see nuclei". Zoom is now available and does not help with this — the counts are gone before the browser gets the frame. Likely a contributor to #125.
The sample is strided, not random. flat[::step] over a raster is correlated with image structure — a periodic feature aligned with the stride is over- or under-represented. Cheap and usually harmless, but it means lo/hi can jitter between frames that look identical.
Not fixed by the client-side sliders
The contrast/brightness controls added alongside zoom are CSS filters over the already-stretched 8-bit JPEG. They redistribute what survived; they cannot recover what the stretch clipped.
Done when
The operator can freeze the display range, and a frozen range is visibly frozen. Sketch:
- carry
lo/hi (or percentile pair) as live params, the way exposure and galvo already travel through set_lightsheet_live_params
- an auto/hold control in the Image panel, defaulting to today's behaviour so nothing changes unasked
- when held, keep the last computed range and say so on the frame — a stale range silently applied is its own trap
- consider raising the ceiling above the 99th, or making it settable, for sparse bright objects
Worth measuring before building: capture one raw 16-bit frame with visible nuclei and check what fraction of the range they occupy. If they sit above the 99th percentile, that is the whole bug and the fix is a number.
Found while adding client-side contrast controls; see the feat(ui): contrast and brightness on the camera surfaces commit.
_frame_payloadauto-contrasts every frame independently: a 1st–99th percentile stretch computed off a strided sample of the downsampled frame, then scaled to 8-bit before JPEG encoding (device_layer.py:967-981).It is a sensible default for a first look, and wrong for everything after that.
Why it fights the operator
The range moves while you watch. Each frame gets its own
lo/hi, so brightness changes because the normalisation shifted, not because the sample did. Judging focus by eye means judging a signal that is being renormalised underneath you. The same is true of the focus score's visual counterpart — an operator sweeping the F-drive is comparing frames that were each scaled differently.Faint or sparse nuclei are clipped. Bright nuclei on a dark field occupy the top fraction of a percent of the histogram, which is precisely what the 99th-percentile ceiling discards. Ryan at 20:19 on the 2026-08-07 walkthrough, on why two-point calibration was hard: "the embryo is a little small, so we'd probably zoom in on this like in Micro-Manager to make it easier to see nuclei". Zoom is now available and does not help with this — the counts are gone before the browser gets the frame. Likely a contributor to #125.
The sample is strided, not random.
flat[::step]over a raster is correlated with image structure — a periodic feature aligned with the stride is over- or under-represented. Cheap and usually harmless, but it meanslo/hican jitter between frames that look identical.Not fixed by the client-side sliders
The contrast/brightness controls added alongside zoom are CSS filters over the already-stretched 8-bit JPEG. They redistribute what survived; they cannot recover what the stretch clipped.
Done when
The operator can freeze the display range, and a frozen range is visibly frozen. Sketch:
lo/hi(or percentile pair) as live params, the way exposure and galvo already travel throughset_lightsheet_live_paramsWorth measuring before building: capture one raw 16-bit frame with visible nuclei and check what fraction of the range they occupy. If they sit above the 99th percentile, that is the whole bug and the fix is a number.
Found while adding client-side contrast controls; see the
feat(ui): contrast and brightness on the camera surfacescommit.