Skip to content

feat: Record + Z-Stack acquisition mode#564

Open
hongquanli wants to merge 30 commits into
masterfrom
feat/record-zstack-acquisition
Open

feat: Record + Z-Stack acquisition mode#564
hongquanli wants to merge 30 commits into
masterfrom
feat/record-zstack-acquisition

Conversation

@hongquanli

Copy link
Copy Markdown
Contributor

Summary

Adds a new Record + Z-Stack acquisition mode (its own tab, next to Wellplate Multipoint, gated by ENABLE_RECORDING). It walks selected wells × a per-well FOV grid × Nt time points and, at each FOV, runs two phases in sequence:

  1. Recording — a continuous single-channel video: T = round(fps × duration) frames at one Z plane, saved as a per-FOV Zarr (T, 1, 1, Y, X).
  2. Z-stack — a multi-channel stack over a Z range, saved as OME-Zarr (Nt, C, NZ, Y, X) per FOV.

Both phases are positioned in Z by offsets relative to a reference plane (laser reflection AF when enabled+referenced, otherwise the current Z). Each phase is independently enable-able.

Architecture

  • MultiPointWorkerBase — behavior-preserving refactor lifting the shared capture mechanics (channel apply, single-frame capture, frame callback + SaveZarrJob dispatch, backpressure, abort/progress) out of MultiPointWorker. MultiPointWorker inherits it unchanged.
  • StreamingCapture (control/core/streaming_capture.py) — the recording primitive: a continuous frame source → bounded queue → writer thread → direct ZarrWriter. Generic frame_source/frame_router/stop_condition seam, shaped so a future hardware-sequenced source drops in.
  • RecordZStackWorker / RecordZStackController — own thin t×well×FOV loop reusing the inherited mechanics; recording via StreamingCapture, z-stack via the existing SaveZarrJob path (hybrid storage).
  • RecordZStackMultiPointWidget + gui_hcs tab wiring (QtRecordZStackController bridges the plain controller to Qt signals).
  • Camera frame-rate hint — best-effort AbstractCamera.set_frame_rate(fps) (no-op default; ToupTek PRECISE_FRAMERATE; simulated camera honors it), with software downsampling as the portable safety net.

Review & hardening

The branch was put through a high-effort multi-angle code review, which caught real hardware-path bugs the simulation tests couldn't surface. All were fixed and re-reviewed:

  • Critical: recording now energizes illumination (was dark); z-stack now sets liveController trigger mode so it doesn't take the wrong (no-illumination) capture branch when live was hardware-triggered.
  • Important: correct well-count method; lazy well-selector resolution (survives plate-format change); fps hint applied after the mode switch; z-stack stop_streaming; RecordingWriter.start() error no longer masked by joining an unstarted thread; no out-of-bounds frame write past T; live view restored after acquisition; frame_count ≥ 1 validation.
  • Hardening: non-blocking hot-path enqueue (drop-on-full + log), abort marks the recording incomplete (not sealed), loud partial-capture warning, _abort_event, acquisition-time GUI lockout (signal_acquisition_started), plus DRY lifts and dead-code removal.

Testing

  • New tests: camera fps, StreamingCapture (router/writer/orchestrator + error/OOB/abort/partial paths), worker simulation smoke (2 wells × 2 FOV × 2 t → recording (T,1,1,Y,X) + z-stack (Nt,C,NZ,Y,X) Zarr verified), widget (validation, Copy-from-Live, computed labels, Start/Stop handoff, signal lockout) — 63 passing.
  • Existing multipoint regression green (the base-class refactor is behavior-preserving). Pre-existing flaky test_MultiPointController.py acquisition tests (timing-bound) are unrelated and fail on master too.
  • black clean across all changed files. Headless --simulation launch with ENABLE_RECORDING=True constructs the tab + controller with no tracebacks.

Follow-ups (deferred, maintainability-only)

Two DRY cleanups that re-touch shared multipoint code are deferred to a focused follow-up PR: a shared pre-warmed-JobRunner mixin, and replacing the duck-typed display_progress_bar/_emit_plate_layout stubs with a protocol/hasattr.

🤖 Generated with Claude Code

hongquanli and others added 30 commits June 21, 2026 01:55
Implements set_frame_rate override on SimulatedCamera to cap the frame rate
in continuous acquisition mode. When set_frame_rate is called, the streaming
thread's frame cadence gate now honors both exposure time and the target
frame period, using whichever is larger. The method returns the effective
achievable FPS clamped by the total frame time (exposure + strobe).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Lift shared acquisition mechanics out of MultiPointWorker into a new
MultiPointWorkerBase so a future RecordZStackWorker sibling can reuse them:
camera/stage/channel-apply, single-frame capture (acquire_camera_image),
the camera frame callback (_image_callback) with CaptureInfo handoff +
job creation/dispatch + backpressure gating + ready-for-next-trigger event,
job-result summarize/finish, move_to_z_level, _select_config, _sleep,
_frame_wait_timeout_s, wait_till_operation_is_completed, update_use_piezo.

MultiPointWorker now subclasses MultiPointWorkerBase, calls super().__init__()
for the shared handles/state, then builds the real job runners / backpressure
controller and sets all MultiPoint-specific state (NZ/deltaZ/z_range/
z_stacking_config/use_piezo/selected_configurations/scan coords/time-point/
AF + per-channel-offset). Orchestration (run, run_single_time_point,
run_coordinate_acquisition, acquire_at_position) and the z-stack/offset/AF
helpers stay in MultiPointWorker unchanged. _emit_plate_layout gets a base
no-op stub overridden by MultiPointWorker's real implementation so the lifted
frame callback stays self-contained.

Method bodies moved verbatim; net MultiPointWorker state is identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
….py (no behavior change)

Move the experiment-ID timestamping + directory creation logic from
MultiPointController.start_new_experiment into a free function
create_experiment_dir(base_path, experiment_id) -> (resolved_id, dir_path)
in control/core/acquisition_setup.py so a future RecordZStackController
can reuse it without duplicating code.

Pre-warmed JobRunner methods left in MultiPointController — they mutate
instance state and do not factor out cleanly as free functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rt/write race)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Appends ContinuousFrameSource (wraps AbstractCamera into start/stop source)
and StreamingCapture (orchestrates source, router, stop-condition, writer)
to streaming_capture.py.  run() accepts an optional timeout parameter so
Task D can bound wall-clock wait without hanging on a stalled camera.
Adds fake-source test confirming 5-frame emit + downsampling + finalize.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…le); document stop/finalize assumption

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add RecordZStackAcquisitionParameters dataclass and pure helper functions
frame_count, zstack_plane_count, and zstack_offsets_um for z-stack and
recording acquisition planning. These helpers handle frame counting,
z-plane calculation with epsilon tolerance, and offset list generation.
Includes full test coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address code review: add ValueError tests for invalid z-stack inputs
(z_max<z_min, step<=0) and add a comment explaining the floor epsilon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add RecordZStackWorker(MultiPointWorkerBase) orchestrating per-FOV
record + z-stack acquisition:
- record() streams a continuous high-fps capture to a per-FOV recording
  .ome.zarr via the C3 StreamingCapture primitive
- zstack() reuses the inherited triggered-capture + SaveZarrJob dispatch
  path (builds its own JobRunner + BackpressureController like
  MultiPointWorker), managing camera streaming/callback lifecycle locally
- establish_reference() uses laser AF when available, falls back to
  current stage Z
- run() loops time points -> regions -> FOVs, abort- and dt-aware

Smoke test (simulated microscope, 2 wells x 2 FOV x 2 t, both phases)
verifies recording dataset count/shape and z-stack per-FOV zarr shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d fix

- Add RecordZStackController to record_zstack_controller.py: builds params
  via setters, pre-warms a JobRunner subprocess (mirrors MultiPointController),
  resolves a timestamped experiment dir via create_experiment_dir, constructs
  RecordZStackWorker, and spawns it on a daemon thread.  Exposes request_abort(),
  join(), close(), and per-param setters for the widget.
- Fix ZarrWriter._get_loop(): after calling get_event_loop() check that the
  returned loop is not already closed; if it is, fall through to new_event_loop()
  so recordings on daemon threads across multiple time-points don't raise
  'Event loop is closed'.
- Add test_record_zstack_controller_smoke: controller-driven test covering
  Nt=2 with dt_s=0.1, both recording and z-stack phases, shape assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements Task E1: widget skeleton (Option-A single-column layout with
Output, Wells & FOV, Time-lapse/Focus, Recording phase, Z-Stack phase,
Start groups), pure _validate_record_zstack_params() helper, validate(),
build_parameters(), and _add_zstack_channel_row().

18 tests pass (13 pure-helper, 5 widget via qtbot).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ow table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… labels

- Recording group: add exposure/gain/illumination spinboxes and a
  '⟳ Live' button that copies currentConfiguration into the recording
  channel dropdown + inline editors
- Z-stack table expanded to 5 columns (channel, exposure, gain,
  illumination, actions); each row has per-channel spinboxes, a
  '⟳' copy-from-live button, and a '✕' remove button
- '+ Add channel' combo+button below the z-stack table
- Added _remove_zstack_channel_row(), _set_zstack_row_values(),
  _get_zstack_row_values(), _copy_recording_from_live(),
  _copy_zstack_row_from_live(), _on_zstack_add_channel_clicked()
- build_parameters() now reads inline editor values for both the
  recording channel and each z-stack row; uses model_copy(deep=True)
  to avoid mutating source channel objects
- 7 new qtbot tests added (26 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cstring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…RDING)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Completes E4: gui_hcs.py:1148 connects acquisition_finished ->
recordZStackWidget.acquisition_is_finished; these slots were authored
with E4 but omitted from its commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the recordZStackController sentinel; avoids a latent AttributeError
if recordZStackWidget is referenced when ENABLE_RECORDING is False.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… lifecycle

CRITICAL 1: recording captured dark frames — turn illumination on around
cap.run() (off in finally), since set_microscope_mode only energizes
illumination when live and the CONTINUOUS stream does not gate it.

CRITICAL 2: z-stack used the wrong acquire_camera_image branch — set
liveController.set_trigger_mode(SOFTWARE) (capturing/restoring the previous
mode) so camera mode and liveController.trigger_mode agree.

IMPORTANT 6: zstack() now calls camera.stop_streaming() in the finally,
mirroring ContinuousFrameSource.stop(), so the next FOV's recording starts
on a stopped camera.

IMPORTANT 9a: hoist stop-live to run() start (once, capturing was_live) and
restart live once in run()'s finally; removed per-FOV stop-live from record().

MEDIUM: precompute zstack offsets and frame shape once in __init__.
SIMPLIFICATION: resolve recording_channel to a local in record().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g, non-blocking enqueue, abort sealing, partial warning)

- ContinuousFrameSource.start: set CONTINUOUS mode before set_frame_rate so the
  toupcam PRECISE_FRAMERATE hint survives the mode switch (IMPORTANT 5).
- RecordingWriter: track _started, set only after thread.start(); finalize/abort
  skip the join when not started so a failing initialize() propagates its real
  error instead of a 'cannot join thread' crash (IMPORTANT 7).
- StreamingCapture._on_frame: re-check stop condition before routing so no frame
  is enqueued at/past t-index T into a (T,...)-shaped dataset (IMPORTANT 8).
- RecordingWriter.enqueue: put_nowait + drop-on-full (no 0.5s block on the camera
  hot thread); default queue maxsize 64 -> 256 (MEDIUM).
- StreamingCapture: track _aborted; run() finally calls writer.abort() on abort
  (seals zarr incomplete) else finalize() (MEDIUM).
- StreamingCapture.run: WARNING when emitted < expected (CountStop.expected());
  trailing zarr planes are blank fill (MEDIUM).
- Document that the post-stop _emitted read is safe (MINOR).
- Tests: start-error path, OOB gating, abort->writer.abort, complete->finalize,
  partial warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n fixes

- IMPORTANT 3+4: fix _get_selected_well_count to resolve lazily via
  scanCoordinates.get_selected_wells() instead of a stale cached
  well_selection_widget; handles None (glass-slide → count=1) and
  empty dict (no wells selected → count=0 → validation rejects)
- IMPORTANT 9b: reject recording configs where frame_count(fps,
  duration_s) < 1 in _validate_record_zstack_params with clear message
- MEDIUM: replace _abort_requested bool with threading.Event
  (_abort_event); set/clear/is_set are thread-safe; abort_requested_fn
  passed to worker uses lambda: self._abort_event.is_set()
- REUSE: add run_acquisition(params) direct-params path; toggle_acquisition
  now calls run_acquisition(self.build_parameters()) — no 15-setter fan-out;
  setters kept as shims for test_record_zstack_worker.py compatibility
- SIMPLIFICATION: remove unreachable None guard in
  _on_zstack_add_channel_clicked; log warnings in _get_zstack_row_values
  instead of silently substituting defaults
- Tests: 39 pass (8 new); cover frame_count=0, glass-slide/None well
  count, lazy well-selector resolution, and abort Event lifecycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… base, extract compute_pixel_size_um, drop redundant _job_runners re-assignments, fix misleading __class__ comment

- REUSE#1: move _wait_for_outstanding_callback_images verbatim to
  MultiPointWorkerBase; remove subclass overrides from MultiPointWorker
  and RecordZStackWorker (both inherit the shared implementation)
- REUSE#2: add compute_pixel_size_um(objective_store, camera) to
  acquisition_setup.py; call from both worker __init__s
- REUSE#5: deferred — ZarrWriterInfo constructions differ materially
  (is_hcs, use_6d_fov) between MultiPointWorker and RecordZStackWorker
- SIMP#1: drop redundant self._job_runners = [] in MultiPointWorker
  and self._job_runners/_backpressure/_abort_on_failed_job/
  _first_job_dispatched in RecordZStackWorker (base __init__ sets them)
- B1: correct misleading comment — __class__ in a method body always
  refers to the defining class, not the runtime type; type(self) is
  what produces per-subclass logger names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ix-batch5)

- Add signal_acquisition_started(bool) to RecordZStackMultiPointWidget, emitting
  True on valid start and False in acquisition_is_finished(), mirroring the pattern
  used by WellplateMultiPointWidget and FlexibleMultiPointWidget.
- Connect the signal to gui_hcs.toggleAcquisitionStart inside the ENABLE_RECORDING
  guard so other tabs, click-to-move, and live-scan-grid are locked during acquisition.
- Update toggle_acquisition docstring to reflect build_parameters() call path.
- Add comment in record_zstack_controller explaining the abort_event clear→start window.
- Add dropped_count property to RecordingWriter and a summary WARNING log in
  StreamingCapture.run so slow-disk dropped frames are diagnosable without grepping.
- Add 5 new tests covering the signal and dropped-count behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review found a race: toggle_acquisition emitted True AFTER run_acquisition()
returned, but run_acquisition spawns a worker thread that on a fast/one-frame
acquisition could finish (acquisition_is_finished -> emit False) before the GUI
thread reached the emit(True) line. The GUI would then process False then True,
permanently locking all tabs.

Fix mirrors WellplateMultiPointWidget (which emits True via
_set_ui_acquisition_running before run_acquisition):
- Emit True before calling run_acquisition.
- Wrap run_acquisition in try/except; on failure un-check the button and emit
  False to unlock the UI (the worker never started, so acquisition_finished
  will not fire).
- Document the emit(False) paths in acquisition_is_finished docstring.
- Add 2 tests: emit-ordering (True before run) and run-raises (True then False).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ntroller

The widget already uses the clean run_acquisition(params) path, so the
15 one-line setter methods, the corresponding duplicate per-field instance
attributes, and the legacy params=None fallback are all dead code. Remove
them and update the controller test (test_record_zstack_controller_smoke)
to build RecordZStackAcquisitionParameters directly and pass it to
run_acquisition(params), matching how the widget works.

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

1 participant