Skip to content

feat: multi-plane recording — N z-planes per FOV (stacks on #564)#579

Open
hongquanli wants to merge 18 commits into
feat/record-zstack-acquisitionfrom
feat/multi-plane-recording
Open

feat: multi-plane recording — N z-planes per FOV (stacks on #564)#579
hongquanli wants to merge 18 commits into
feat/record-zstack-acquisitionfrom
feat/multi-plane-recording

Conversation

@hongquanli

Copy link
Copy Markdown
Contributor

Summary

At each FOV, the recording phase now records a video at N z-planes (parameters: Bottom Z offset, Nz, dz) instead of a single plane — sequential per-plane videos, one Zarr store per plane. Extends the Record + Z-Stack feature (#564); plane j records at z_ref + bottom_z_offset_um + j*dz_um using the same z-reference rule as today (laser AF when enabled+referenced, else Z at FOV arrival).

  • Nz = 1 reproduces today's behavior exactly, including the plane-less filename fov_{k}.ome.zarr and byte-identical metadata.
  • Multi-plane stores are named fov_{k}_z{j}.ome.zarr and carry self-describing _squid attrs: plane_index, plane_z_offset_um, n_planes.
  • Same recording channel, fps, and duration at every plane; illumination stays on across the planes of one FOV; plane 0's Z move happens before illumination turns on (no extra light dose during stage motion).
  • Abort is checked between planes: an abort mid-plane seals that plane (existing StreamingCapture behavior) and skips the rest; state restore + .done marker still run.
  • Camera-mode and stage-Z restore now run in finally, so they also happen on the fail-fast write-error path.
  • UI: the recording group's single "Z-offset" field became Bottom Z / Nz / dz, with dz hidden (not disabled) at Nz = 1, plus a computed summary label (plane span + per-FOV time cost).
  • ZarrAcquisitionConfig gained an optional extra_squid_attrs passthrough (None for single-plane output).

Spec: design-multi-plane-recording-2026-07-03.md (approved 2026-07-03).

Test plan

  • 117 tests passing (core + widget + camera-fps suites), including new tests: plane-offset helper + validation, per-plane path naming, Nz=3 integration (real Zarr write + readback of names/shapes/plane attrs), abort-between-planes, illumination/Z-move ordering, fail-fast restore, extra _squid attrs readback, dz visibility, planes label, validate() wiring.
  • Runtime E2E in the simulated GUI: Nz=3 produced exactly fov_0_z0/z1/z2.ome.zarr, shape (5,1,1,H,W), correct per-plane metadata, dz visible at Nz=3, zero tracebacks; screenshot confirms the new fields row.
  • Hardware test pass (with feat: Record + Z-Stack acquisition mode #564's): illumination-across-planes with real light sources; fail-fast path on real disk. Note: simulation showed intermittent camera under-delivery (4/5 frames, store sealed incomplete with honest counts) on both multi-plane and the pre-existing single-plane path — feat: Record + Z-Stack acquisition mode #564's designed behavior, not a regression here, but worth watching on hardware.

🤖 Generated with Claude Code

hongquanli and others added 14 commits July 4, 2026 03:07
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onfig

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ecordZStackWorker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… at Nz=1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mera/Z on fail-fast path; harden validate-wiring test

Final whole-branch review findings on feat/multi-plane-recording:

- record(): move to the first plane BEFORE turning illumination on
  (pre-branch parity — illumination was previously off during the Z move +
  settle; the multi-plane refactor had regressed this to illuminate during
  every FOV's first Z move, causing extra photobleaching).
- record(): widen the try/finally so the camera-mode + stage-Z restore also
  runs when a plane raises the fail-fast RuntimeError (previously only ran on
  the normal path, leaving the stage at the plane offset on error).
- test_validate_wires_recording_nz_dz: spinbox minimums make invalid Nz/dz
  unreachable, so the test asserted nothing beyond validate() returning None.
  Now monkeypatches _validate_record_zstack_params to capture and assert the
  actual recording_nz/recording_dz_um kwargs it receives.
- Updated record()/_record_one_plane docstrings and comments to describe the
  new control flow.
…ynamic Z-offset caption

Nz moves to the FPS/Duration row (with a divider) and the computed
plane-summary label is removed; the offset caption reads "Z offset"
at Nz=1 and "Bottom Z offset" at Nz>1, on one row with dz.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dz | FPS, Dur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…led; slim spinbox widths

Without Laser AF there is no reference plane to offset from — the field
(and caption) hide, and build_parameters emits 0.0 so a hidden stale
value cannot shift the recording planes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…work

Merges origin/feat/record-zstack-acquisition (18 commits: widget layout
changes + Record/Z-Stack settings reuse via YAML + drag-and-drop) into
feat/multi-plane-recording (#579) so the stacked PR is unblocked.

Conflicts resolved in:
- control/core/record_zstack_controller.py: kept both the multi-plane
  recording_plane_offsets_um() helper and the settings-reuse
  _build_objective_info()/_save_record_zstack_yaml() helpers.
- control/widgets.py: kept HEAD's Nz/dz/bottom-Z recording-plane
  redesign (which superseded the single entry_recording_z_offset
  field) and dropped the now-redundant old Z-offset widget from the
  other side's layout tweaks; updated _apply_yaml_settings() and its
  widgets_to_block list (a non-conflicting hunk that still referenced
  the removed widget/field) to use the new Nz/bottom-Z/dz widgets.
- tests/core/test_record_zstack_worker.py and
  tests/test_record_zstack_widget.py: unioned both branches' new test
  functions (git's diff3 had misaligned several of them because they
  shared boilerplate setup code); updated the handful of assertions
  that referenced the pre-multi-plane recording_z_offset_um field.

Also renamed RecordZStackYAMLData.recording_z_offset_um ->
recording_bottom_z_offset_um + new recording_nz/recording_dz_um
fields (and the corresponding acquisition.yaml recording: keys) in
control/acquisition_yaml_loader.py, since the settings-reuse YAML
schema needs to round-trip the multi-plane fields that superseded the
single z-offset -- this wasn't flagged as a textual conflict but broke
without the update. Updated tests/control/test_acquisition_yaml_loader.py
to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d-trip test

test_full_save_load_round_trip_preserves_settings previously ran with Laser AF
off, which forces recording_bottom_z_offset_um to 0.0 in build_parameters()
and left recording_Nz/recording_dz_um at their defaults -- so the multi-plane
recording fields introduced by this branch were never actually exercised by
the round trip. Enable Laser AF and set Nz/dz/bottom-Z to distinctive values
so the save->parse->apply cycle is verified for these fields too.
…quisition

Restores the plane-count/Z-range/per-FOV-duration summary that a prior UI
change removed (it was an inline label, dropped for being too wide) as a
QMessageBox.question shown right after validate() succeeds and before the
acquisition actually starts, giving the user one last look. Answering No
aborts the start and re-unchecks the button; the dialog is skipped entirely
when the Recording phase is disabled.

The summary's Z offset uses the same effective-value logic as
build_parameters() (0.0 when Laser AF is off) so it can never show a stale
value from the hidden entry_recording_bottom_z field that doesn't match what
actually runs.

Also fixes entry_recording_bottom_z's tooltip, which stayed frozen at
"Bottom plane offset relative to the Z reference" even after Nz dropped to 1
and the caption switched to "Z offset:" -- it now tracks the same multi/
single wording as the label.
…offset moves to its own row

Live-testing feedback: FPS/Duration should come first, and the Bottom-Z
offset field (visible only when Laser AF is on) shouldn't share a row with
Nz/dz/FPS/Dur since it shifts that row's width when it appears/disappears.
…yout

Live-testing feedback: the offset row should line up with Nz, not start
flush-left. Switched the recording-controls block from two independent
QHBoxLayouts to one QGridLayout so the offset shares Nz's columns and
aligns exactly regardless of font/platform metrics.
hongquanli and others added 4 commits July 5, 2026 21:45
Live-testing feedback across several rounds landed on: FPS/Duration always
lead row 1; Nz/dz/offset append to row 1 too as long as at most 2 of them
are visible, but move to their own row 2 (as a group) only when all 3 are
shown at once (Nz>1 AND Laser AF on) -- that's too many fields for one row.

When on row 2, Nz/dz get temporarily widened to match FPS/Dur's widths so
the columns align exactly (both rows share the same left margin, so equal
leading widths are sufficient -- no grid needed). Otherwise they use their
own compact widths sized to the actual label/spinbox content, fixing an
earlier pass that forced the wider shared width unconditionally and left
visible dead space in the common single-row case.

Also: dz now shows 1 decimal instead of 2, and both rows share one uniform
inter-item spacing instead of a larger explicit gap between field groups.
…trigger live can't break recording

Starting a recording while Live ran in SOFTWARE-trigger mode aborted the run on
real hardware. _probe_frame_shape() sized the recording dataset by sending a
software trigger, but Live's last trigger was still outstanding
(get_ready_for_trigger() == False), so send_trigger() was rejected ("trigger too
early"). The probe then fell back to get_resolution() — the raw binned sensor
size (3104x2084) — while the camera delivers cropped 2084x2084 frames, so every
write_frame failed with a dimension-alignment error and the store sealed
incomplete, fail-fasting the whole acquisition.

Probe in CONTINUOUS mode instead — the same mode recording uses — and read one
free-run frame (no send_trigger, so trigger readiness can't block it); the probed
shape is then exactly what recording writes. On a genuine probe failure, abort
with a clear error instead of sizing from get_resolution() and grinding out a
blank, incomplete recording.

Also fix SimulatedCamera.read_camera_frame() to wait for a frame (matching the
real camera / AbstractCamera contract) rather than returning None before the
first CONTINUOUS frame — otherwise the continuous probe gets no frame in sim.

Verified on a real ITR3CMOS26000KMA: the continuous probe returns (2084, 2084)
(matching delivery) vs get_resolution()'s (3104, 2084), and get_ready_for_trigger()
is False right after a software trigger (reproducing the reject). New tests cover
the probe using CONTINUOUS/never triggering and aborting (not get_resolution) on
no frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants