feat: Record + Z-Stack acquisition mode#564
Open
hongquanli wants to merge 30 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ×Nttime points and, at each FOV, runs two phases in sequence:T = round(fps × duration)frames at one Z plane, saved as a per-FOV Zarr(T, 1, 1, Y, X).(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 +SaveZarrJobdispatch, backpressure, abort/progress) out ofMultiPointWorker.MultiPointWorkerinherits it unchanged.StreamingCapture(control/core/streaming_capture.py) — the recording primitive: a continuous frame source → bounded queue → writer thread → directZarrWriter. Genericframe_source/frame_router/stop_conditionseam, shaped so a future hardware-sequenced source drops in.RecordZStackWorker/RecordZStackController— own thin t×well×FOV loop reusing the inherited mechanics; recording viaStreamingCapture, z-stack via the existingSaveZarrJobpath (hybrid storage).RecordZStackMultiPointWidget+gui_hcstab wiring (QtRecordZStackControllerbridges the plain controller to Qt signals).AbstractCamera.set_frame_rate(fps)(no-op default; ToupTekPRECISE_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:
liveControllertrigger mode so it doesn't take the wrong (no-illumination) capture branch when live was hardware-triggered.stop_streaming;RecordingWriter.start()error no longer masked by joining an unstarted thread; no out-of-bounds frame write pastT; live view restored after acquisition;frame_count ≥ 1validation._abort_event, acquisition-time GUI lockout (signal_acquisition_started), plus DRY lifts and dead-code removal.Testing
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.test_MultiPointController.pyacquisition tests (timing-bound) are unrelated and fail onmastertoo.blackclean across all changed files. Headless--simulationlaunch withENABLE_RECORDING=Trueconstructs 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-
JobRunnermixin, and replacing the duck-typeddisplay_progress_bar/_emit_plate_layoutstubs with a protocol/hasattr.🤖 Generated with Claude Code