Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
fddfb3d
feat(camera): add best-effort set_frame_rate hint to AbstractCamera
hongquanli Jun 21, 2026
1bb5e69
feat(camera): SimulatedCamera honors set_frame_rate in continuous stream
hongquanli Jun 21, 2026
1a81c53
feat(camera): ToupcamCamera.set_frame_rate via PRECISE_FRAMERATE
hongquanli Jun 21, 2026
c0fb841
refactor: extract MultiPointWorkerBase (no behavior change)
hongquanli Jun 21, 2026
4b8cc1f
refactor: extract create_experiment_dir helper into acquisition_setup…
hongquanli Jun 21, 2026
9df259a
feat(streaming): add CountStop + RecordingRouter (pure)
hongquanli Jun 21, 2026
deae236
feat(streaming): RecordingWriter (bounded queue + thread -> ZarrWriter)
hongquanli Jun 21, 2026
83fa68a
fix(streaming): drain thread solely owns ZarrWriter lifecycle (no abo…
hongquanli Jun 21, 2026
3be2db4
feat(streaming): ContinuousFrameSource + StreamingCapture orchestrator
hongquanli Jun 21, 2026
5d78773
fix(streaming): move CameraAcquisitionMode import to top (no real cyc…
hongquanli Jun 21, 2026
aac4518
feat(record-zstack): params + pure planning helpers
hongquanli Jun 21, 2026
e0eb6bd
test(record-zstack): cover validation paths + document epsilon
hongquanli Jun 21, 2026
691369d
feat(record-zstack): RecordZStackWorker (record + zstack per FOV)
hongquanli Jun 21, 2026
fb93711
feat(record-zstack): RecordZStackController + ZarrWriter daemon-threa…
hongquanli Jun 21, 2026
3d2a62c
feat(record-zstack): RecordZStackMultiPointWidget + validation
hongquanli Jun 21, 2026
d66885c
fix(record-zstack): correct laser-AF reference check + dedup zstack r…
hongquanli Jun 21, 2026
ae5af71
feat(record-zstack): inline channel editors, Copy-from-Live, computed…
hongquanli Jun 21, 2026
ba30f7d
test(record-zstack): tighten invalid-range plane-label assertion + do…
hongquanli Jun 21, 2026
8431755
feat(record-zstack): Start/Stop handoff to RecordZStackController
hongquanli Jun 21, 2026
e644787
feat(record-zstack): add Record + Z-Stack tab to gui_hcs (ENABLE_RECO…
hongquanli Jun 21, 2026
3014893
feat(record-zstack): widget finished/progress slots for gui_hcs wiring
hongquanli Jun 21, 2026
25131f4
fix(record-zstack): add recordZStackWidget None sentinel (gui_hcs)
hongquanli Jun 21, 2026
27694cb
fix(record-zstack): illumination, trigger mode, streaming & live-view…
hongquanli Jun 21, 2026
da939bf
fix: harden streaming_capture (fps order, start error path, OOB gatin…
hongquanli Jun 21, 2026
2e9cf57
fix(record-zstack): batch-3 correctness, threading, and simplificatio…
hongquanli Jun 21, 2026
103bcd9
refactor: DRY batch 4 — lift _wait_for_outstanding_callback_images to…
hongquanli Jun 21, 2026
82d8166
fix: Wire signal_acquisition_started for Record+Z-Stack UI lockout (f…
hongquanli Jun 21, 2026
d4dc9d5
fix: emit signal_acquisition_started(True) before spawning worker thread
hongquanli Jun 21, 2026
df68321
refactor: Remove setter shims and duplicate attrs from RecordZStackCo…
hongquanli Jun 22, 2026
2aacdb6
style(record-zstack): compact widget layout to match WellplateMultiPoint
hongquanli Jun 22, 2026
407f1d5
feat(record-zstack): wire FOV grid and compact the acquisition widget
hongquanli Jun 28, 2026
3120d01
fix(record-zstack): release RecordingWriter if drain thread fails to …
hongquanli Jun 28, 2026
14ddb18
fix(record-zstack): clear stale FOV regions before re-geometrying + a…
hongquanli Jun 29, 2026
9744776
test(record-zstack): skip tensorstore-dependent tests when it's absen…
hongquanli Jun 30, 2026
9b5be65
fix(record-zstack): add emit_selected_channels stub + keep well selec…
hongquanli Jul 4, 2026
3f0ccc9
fix(record-zstack): critical review fixes — data loss, deadlock, abor…
hongquanli Jul 4, 2026
80b4c85
fix(record-zstack): medium review fixes — byte cap, per-FOV z, dt pac…
hongquanli Jul 4, 2026
b745fa8
fix(record-zstack): round-2 review fixes on the fix commits
hongquanli Jul 4, 2026
9a24c53
fix(record-zstack): data-integrity cluster from completed round-2 ver…
hongquanli Jul 4, 2026
ebea578
fix(record-zstack): GUI + cleanup findings from completed round-2 ver…
hongquanli Jul 4, 2026
d59ff5d
merge master: port watchdog hooks into MultiPointWorkerBase
hongquanli Jul 4, 2026
3a78ace
feat(record-zstack): XY/Time tabbed row, Select Wells mode, channel s…
hongquanli Jul 5, 2026
82a4676
feat(record-zstack): add xy_mode/scan_size_mm/overlap_percent to acqu…
hongquanli Jul 5, 2026
09f6dd2
feat(record-zstack): add _build_objective_info helper for acquisition…
hongquanli Jul 5, 2026
d1fd8f4
feat(record-zstack): write full acquisition.yaml snapshot alongside a…
hongquanli Jul 5, 2026
6fea97f
feat(record-zstack): parse record_zstack acquisition.yaml into Record…
hongquanli Jul 5, 2026
d4198b1
refactor(widgets): generalize AcquisitionYAMLDropMixin for a 3rd widg…
hongquanli Jul 5, 2026
3ff2211
fix(record-zstack): match mismatch-dialog display name to actual tab …
hongquanli Jul 5, 2026
c0a246d
refactor(widgets): extract well-region YAML apply logic into shared f…
hongquanli Jul 5, 2026
9c1ae70
feat(record-zstack): wire RecordZStackMultiPointWidget into Acquisiti…
hongquanli Jul 5, 2026
d156951
fix: RecordZStackMultiPointWidget._apply_yaml_settings channel/time bugs
hongquanli Jul 5, 2026
e6d85c7
fix: refresh Time tab styling in RecordZStackMultiPointWidget YAML load
hongquanli Jul 5, 2026
7ed0907
feat(record-zstack): add Save Settings/Load Settings buttons
hongquanli Jul 5, 2026
573d369
fix(tests): exercise real save/load chains in record_zstack settings …
hongquanli Jul 5, 2026
2ec6cf6
test(record-zstack): add full save/load round-trip regression test
hongquanli Jul 5, 2026
8f8f2b6
test: Add assertions for recording_channel and z-stack channel numeri…
hongquanli Jul 5, 2026
5385735
fix(record-zstack): resync xy_controls_frame visibility after YAML load
hongquanli Jul 5, 2026
ae60e1e
fix(record-zstack): catch _apply_yaml_settings failures in the shared…
hongquanli Jul 5, 2026
0397d47
fix(record-zstack): let real save-YAML write failures reach the caller
hongquanli Jul 5, 2026
a585eb3
feat(record-zstack): cap Hamamatsu recording frame rate via DCAM INTE…
hongquanli Jul 5, 2026
01c07aa
fix(record-zstack): honor set_frame_rate never-raises contract + revi…
hongquanli Jul 5, 2026
b8ad45b
Merge remote-tracking branch 'origin/master' into feat/record-zstack-…
hongquanli Jul 6, 2026
4621d9d
refactor: drop dead no-op stubs after master's duck-typing removal (#…
hongquanli Jul 6, 2026
f098cce
Merge branch 'feat/record-zstack-acquisition' into feat/hamamatsu-rec…
hongquanli Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion software/control/acquisition_yaml_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,77 @@ class AcquisitionYAMLData:
flexible_positions: Optional[List[Dict]] = None # [{name, center_mm}, ...]


@dataclass
class RecordZStackYAMLData:
"""Parsed record/z-stack acquisition YAML data structure."""

widget_type: str # "record_zstack"
xy_mode: str = "Select Wells"

objective_name: Optional[str] = None
camera_binning: Optional[Tuple[int, int]] = None

nt: int = 1
delta_t_s: float = 0.0

laser_af: bool = False

recording_enabled: bool = False
recording_channel: Optional[Dict] = None
fps: float = 10.0
duration_s: float = 1.0
recording_z_offset_um: float = 0.0

zstack_enabled: bool = False
zstack_channels: List[Dict] = field(default_factory=list)
z_min_um: float = -3.0
z_max_um: float = 3.0
z_step_um: float = 1.0

scan_size_mm: Optional[float] = None
overlap_percent: float = 10.0
wellplate_regions: Optional[List[Dict]] = None


def _parse_camera_binning(obj: dict) -> Optional[Tuple[int, int]]:
binning = obj.get("camera_binning")
if binning and isinstance(binning, list) and len(binning) == 2:
return tuple(binning)
return None


def _parse_record_zstack_yaml_data(data: dict, acq: dict) -> RecordZStackYAMLData:
obj = data.get("objective", {})
time_series = data.get("time_series", {})
autofocus = data.get("autofocus", {})
recording = data.get("recording", {})
z_stack = data.get("z_stack", {})
wellplate_scan = data.get("wellplate_scan", {})

return RecordZStackYAMLData(
widget_type="record_zstack",
xy_mode=acq.get("xy_mode", "Select Wells"),
objective_name=obj.get("name"),
camera_binning=_parse_camera_binning(obj),
nt=time_series.get("nt", 1),
delta_t_s=time_series.get("delta_t_s", 0.0),
laser_af=autofocus.get("laser_af", False),
recording_enabled=recording.get("enabled", False),
recording_channel=recording.get("channel"),
fps=recording.get("fps", 10.0),
duration_s=recording.get("duration_s", 1.0),
recording_z_offset_um=recording.get("z_offset_um", 0.0),
zstack_enabled=z_stack.get("enabled", False),
zstack_channels=z_stack.get("channels", []),
z_min_um=z_stack.get("z_min_um", -3.0),
z_max_um=z_stack.get("z_max_um", 3.0),
z_step_um=z_stack.get("z_step_um", 1.0),
scan_size_mm=wellplate_scan.get("scan_size_mm"),
overlap_percent=wellplate_scan.get("overlap_percent", 10.0),
wellplate_regions=wellplate_scan.get("regions"),
)


def parse_acquisition_yaml(file_path: str) -> AcquisitionYAMLData:
"""Parse acquisition YAML file and return structured data.

Expand Down Expand Up @@ -82,11 +153,14 @@ def parse_acquisition_yaml(file_path: str) -> AcquisitionYAMLData:
flexible_scan = data.get("flexible_scan", {})

# Validate widget_type
VALID_WIDGET_TYPES = ("wellplate", "flexible")
VALID_WIDGET_TYPES = ("wellplate", "flexible", "record_zstack")
widget_type = acq.get("widget_type", "wellplate")
if widget_type not in VALID_WIDGET_TYPES:
raise ValueError(f"Invalid widget_type '{widget_type}'. Must be one of: {VALID_WIDGET_TYPES}")

if widget_type == "record_zstack":
return _parse_record_zstack_yaml_data(data, acq)

# Parse camera binning
binning = obj.get("camera_binning")
if binning and isinstance(binning, list) and len(binning) == 2:
Expand Down
55 changes: 55 additions & 0 deletions software/control/camera_hamamatsu.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,61 @@ def get_strobe_time(self) -> float:

return (line_interval_s + trigger_delay_s) * 1000.0

def set_frame_rate(self, fps: float) -> float:
"""Cap the internal free-run rate (DCAM INTERNALFRAMERATE) for recording.

In CONTINUOUS (internal-trigger) mode the sensor otherwise free-runs at its
exposure/readout-limited maximum, forcing the read thread to process frames
the recording pipeline only downsamples away. Setting INTERNALFRAMERATE caps
delivery near the requested rate. Exposure is unaffected: the frame period is
constrained to be >= exposure + readout, so a too-fast request is clamped by
the camera (reflected in the read-back) while a slower request just adds
inter-frame dead time.

Returns the rate the camera accepted, or the exposure-limited max on any
failure, so callers can still size/pace against a real number (base contract).
"""
try:
fallback = 1000.0 / self.get_total_frame_time()
except Exception:
# get_strobe_time() raises on a DCAM read error; honor the "always
# returns a usable rate" contract by assuming the requested rate (the
# caller then paces/sizes against it, same as if we'd never capped).
self._log.exception("could not read frame timing; assuming the requested rate.")
fallback = float(fps) if fps and fps > 0 else 0.0
if fps is None or fps <= 0:
return fallback
try:
with self._pause_streaming():
# Fast readout FIRST: high frame rates need it, and the sensor's
# default may be the low-noise (slow) mode whose INTERNALFRAMERATE
# ceiling is far below `fps`. It also raises the valuemax we read
# next, so the clamp isn't pinned to the slow-mode limit. Best-
# effort — log if it doesn't take, but still try to set the rate.
# NOTE: this leaves the camera in fast readout after a recording
# (not restored); acceptable for throughput-oriented recording, see
# PR notes / follow-up to make readout speed configurable.
if not self._set_prop(DCAM_IDPROP.READOUTSPEED, DCAMPROP.READOUTSPEED.FASTEST):
self._log.warning("Could not set fast readout speed; achievable fps may be limited.")

attr = self._camera.prop_getattr(DCAM_IDPROP.INTERNALFRAMERATE)
if isinstance(attr, bool):
self._log.warning(
"INTERNALFRAMERATE not available on this camera; leaving the internal frame rate uncapped."
)
return fallback
target = max(attr.valuemin, min(float(fps), attr.valuemax))
if not self._set_prop(DCAM_IDPROP.INTERNALFRAMERATE, target):
return fallback
achieved = self._camera.prop_getvalue(DCAM_IDPROP.INTERNALFRAMERATE)
if isinstance(achieved, bool):
return fallback
self._log.info(f"set_frame_rate({fps}) set INTERNALFRAMERATE -> {achieved:.3f} fps")
return achieved
except Exception:
self._log.exception("set_frame_rate failed; falling back to exposure-limited max.")
return fallback

def set_frame_format(self, frame_format: CameraFrameFormat):
if frame_format != CameraFrameFormat.RAW:
raise ValueError("Only the RAW frame format is supported by this camera.")
Expand Down
46 changes: 46 additions & 0 deletions software/control/camera_toupcam.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import threading
import control.toupcam as toupcam
import control.toupcam_exceptions
from control.toupcam_exceptions import hresult_checker

log = squid.logging.get_logger(__name__)
Expand Down Expand Up @@ -50,6 +51,21 @@ def get_sn_by_model(camera_model: ToupcamCameraModel):
return None # return None if no device with the specified model_name is connected


def clamp_precise_framerate_tenths(fps: float, min_tenths: int, max_tenths: int) -> int:
"""Clamp fps (in frames per second) to the allowed range in tenths.

Args:
fps: Desired frame rate in frames per second
min_tenths: Minimum allowed value in tenths (0.1 fps units)
max_tenths: Maximum allowed value in tenths (0.1 fps units)

Returns:
Clamped value in tenths of fps
"""
tenths = int(round(fps * 10.0))
return max(min_tenths, min(max_tenths, tenths))


class ToupcamCamera(AbstractCamera):
TOUPCAM_OPTION_RAW_RAW_VAL = 1
TOUPCAM_OPTION_RAW_RGB_VAL = 0
Expand Down Expand Up @@ -556,6 +572,36 @@ def get_exposure_limits(self) -> Tuple[float, float]:
(min_exposure, max_exposure, default_exposure) = self._camera.get_ExpTimeRange()
return min_exposure / 1000.0, max_exposure / 1000.0 # us -> ms

def set_frame_rate(self, fps: float) -> float:
"""Set the frame rate via PRECISE_FRAMERATE option.

_calculate_strobe_info (~:128-140) drives PRECISE_FRAMERATE to MAX on mode
switch; set_frame_rate must be called **after** entering CONTINUOUS to take
effect, and recording restores nothing (next acquisition resets exposure → MAX again).

Args:
fps: Desired frame rate in frames per second. If None or <= 0, returns
current achievable frame rate without changing settings.

Returns:
The achievable frame rate in frames per second, or current rate if not changed.
"""
if fps is None or fps <= 0:
return 1000.0 / self.get_total_frame_time()
try:
max_tenths = self._camera.get_Option(toupcam.TOUPCAM_OPTION_MAX_PRECISE_FRAMERATE)
min_tenths = self._camera.get_Option(toupcam.TOUPCAM_OPTION_MIN_PRECISE_FRAMERATE)
except toupcam.HRESULTException as ex:
self._log.warning(f"precise-framerate range read failed: {control.toupcam_exceptions.explain(ex)}")
return 1000.0 / self.get_total_frame_time()
tenths = clamp_precise_framerate_tenths(fps, min_tenths, max_tenths)
try:
self._camera.put_Option(toupcam.TOUPCAM_OPTION_PRECISE_FRAMERATE, tenths)
except toupcam.HRESULTException as ex:
self._log.warning(f"set precise-framerate failed: {control.toupcam_exceptions.explain(ex)}")
return 1000.0 / self.get_total_frame_time()
return tenths / 10.0

@staticmethod
def _user_gain_to_toupcam(user_gain):
"""
Expand Down
59 changes: 59 additions & 0 deletions software/control/core/acquisition_setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Shared acquisition setup helpers.

Free functions used by MultiPointController (and future controllers such as
RecordZStackController) to set up experiment directories without duplicating
logic across controller classes.
"""

import os
from datetime import datetime
from typing import Optional, Tuple

from control import utils


def compute_pixel_size_um(objective_store, camera) -> Optional[float]:
"""Compute the physical pixel size in µm from objective and camera metadata.

Returns the product of the objective's pixel-size factor and the camera's
binned pixel size in µm, or None if either value is unavailable or an
exception is raised.

Args:
objective_store: ObjectiveStore (or compatible object) with
``get_pixel_size_factor() -> Optional[float]``.
camera: AbstractCamera (or compatible) with
``get_pixel_size_binned_um() -> Optional[float]``.

Returns:
Pixel size in µm, or None.
"""
try:
pixel_factor = objective_store.get_pixel_size_factor()
sensor_pixel_um = camera.get_pixel_size_binned_um()
if pixel_factor is not None and sensor_pixel_um is not None:
return float(pixel_factor) * float(sensor_pixel_um)
return None
except Exception:
return None


def create_experiment_dir(base_path: str, experiment_id: str) -> Tuple[str, str]:
"""Resolve a unique experiment ID and create its output directory.

Appends a timestamp to *experiment_id* (spaces replaced with underscores)
to guarantee uniqueness, then creates the directory tree under *base_path*.

Args:
base_path: Root directory for all experiments.
experiment_id: Human-readable experiment name supplied by the user.

Returns:
A ``(resolved_id, dir_path)`` tuple where *resolved_id* is the
timestamped identifier and *dir_path* is the absolute path of the
newly created directory.
"""
resolved_id = experiment_id.replace(" ", "_") + "_" + datetime.now().strftime("%Y-%m-%d_%H-%M-%S.%f")
dir_path = os.path.join(base_path, resolved_id)
utils.ensure_directory_exists(dir_path)
return resolved_id, dir_path
9 changes: 3 additions & 6 deletions software/control/core/multi_point_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import tempfile
import time
import yaml
from datetime import datetime
from enum import Enum
from threading import Thread
from typing import Optional, Tuple, Any
Expand All @@ -17,6 +16,7 @@
from control import utils, utils_acquisition
import control._def
from control.core.auto_focus_controller import AutoFocusController
from control.core.acquisition_setup import create_experiment_dir
from control.core.multi_point_utils import MultiPointControllerFunctions, ScanPositionInformation, AcquisitionParameters
from control.core.scan_coordinates import ScanCoordinates
from control.core.laser_auto_focus_controller import LaserAutofocusController
Expand Down Expand Up @@ -438,12 +438,9 @@ def set_overlap_percent(self, overlap_percent: float):
self.overlap_percent = overlap_percent

def start_new_experiment(self, experiment_ID): # @@@ to do: change name to prepare_folder_for_new_experiment
# generate unique experiment ID
self.experiment_ID = experiment_ID.replace(" ", "_") + "_" + datetime.now().strftime("%Y-%m-%d_%H-%M-%S.%f")
# generate unique experiment ID and create its output directory
self.experiment_ID, experiment_dir = create_experiment_dir(self.base_path, experiment_ID)
self.recording_start_time = time.time()
# create a new folder
experiment_dir = os.path.join(self.base_path, self.experiment_ID)
utils.ensure_directory_exists(experiment_dir)
# Save acquisition configuration via ConfigRepository
self.liveController.microscope.config_repo.save_acquisition_output(
output_dir=experiment_dir,
Expand Down
Loading
Loading