Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
115 commits
Select commit Hold shift + click to select a range
4d94bac
feat(camera): add best-effort set_frame_rate hint to AbstractCamera
hongquanli Jun 21, 2026
e6249e1
feat(camera): SimulatedCamera honors set_frame_rate in continuous stream
hongquanli Jun 21, 2026
f6ed2b4
feat(camera): ToupcamCamera.set_frame_rate via PRECISE_FRAMERATE
hongquanli Jun 21, 2026
dc9b9f6
refactor: extract MultiPointWorkerBase (no behavior change)
hongquanli Jun 21, 2026
120ebfd
refactor: extract create_experiment_dir helper into acquisition_setup…
hongquanli Jun 21, 2026
ed97d39
feat(streaming): add CountStop + RecordingRouter (pure)
hongquanli Jun 21, 2026
7c39d53
feat(streaming): RecordingWriter (bounded queue + thread -> ZarrWriter)
hongquanli Jun 21, 2026
62e90fb
fix(streaming): drain thread solely owns ZarrWriter lifecycle (no abo…
hongquanli Jun 21, 2026
fedf4c3
feat(streaming): ContinuousFrameSource + StreamingCapture orchestrator
hongquanli Jun 21, 2026
40ba174
fix(streaming): move CameraAcquisitionMode import to top (no real cyc…
hongquanli Jun 21, 2026
fe12f26
feat(record-zstack): params + pure planning helpers
hongquanli Jun 21, 2026
8846ea2
test(record-zstack): cover validation paths + document epsilon
hongquanli Jun 21, 2026
b77c237
feat(record-zstack): RecordZStackWorker (record + zstack per FOV)
hongquanli Jun 21, 2026
02e669c
feat(record-zstack): RecordZStackController + ZarrWriter daemon-threa…
hongquanli Jun 21, 2026
4007b27
feat(record-zstack): RecordZStackMultiPointWidget + validation
hongquanli Jun 21, 2026
ada2a0f
fix(record-zstack): correct laser-AF reference check + dedup zstack r…
hongquanli Jun 21, 2026
2a04afa
feat(record-zstack): inline channel editors, Copy-from-Live, computed…
hongquanli Jun 21, 2026
cc5d894
test(record-zstack): tighten invalid-range plane-label assertion + do…
hongquanli Jun 21, 2026
a4b2bbb
feat(record-zstack): Start/Stop handoff to RecordZStackController
hongquanli Jun 21, 2026
fe5f641
feat(record-zstack): add Record + Z-Stack tab to gui_hcs (ENABLE_RECO…
hongquanli Jun 21, 2026
80cc428
feat(record-zstack): widget finished/progress slots for gui_hcs wiring
hongquanli Jun 21, 2026
8eb51af
fix(record-zstack): add recordZStackWidget None sentinel (gui_hcs)
hongquanli Jun 21, 2026
366bf19
fix(record-zstack): illumination, trigger mode, streaming & live-view…
hongquanli Jun 21, 2026
13b3943
fix: harden streaming_capture (fps order, start error path, OOB gatin…
hongquanli Jun 21, 2026
e940571
fix(record-zstack): batch-3 correctness, threading, and simplificatio…
hongquanli Jun 21, 2026
a7b3980
refactor: DRY batch 4 — lift _wait_for_outstanding_callback_images to…
hongquanli Jun 21, 2026
43772ff
fix: Wire signal_acquisition_started for Record+Z-Stack UI lockout (f…
hongquanli Jun 21, 2026
44a67b7
fix: emit signal_acquisition_started(True) before spawning worker thread
hongquanli Jun 21, 2026
ce2dbd7
refactor: Remove setter shims and duplicate attrs from RecordZStackCo…
hongquanli Jun 22, 2026
1bad106
style(record-zstack): compact widget layout to match WellplateMultiPoint
hongquanli Jun 22, 2026
576e679
feat(record-zstack): wire FOV grid and compact the acquisition widget
hongquanli Jun 28, 2026
f8b9298
fix(record-zstack): release RecordingWriter if drain thread fails to …
hongquanli Jun 28, 2026
af08d88
fix(record-zstack): clear stale FOV regions before re-geometrying + a…
hongquanli Jun 29, 2026
0e0cda3
test(record-zstack): skip tensorstore-dependent tests when it's absen…
hongquanli Jun 30, 2026
160c306
fix(record-zstack): add emit_selected_channels stub + keep well selec…
hongquanli Jul 4, 2026
1ec3b92
fix(record-zstack): critical review fixes — data loss, deadlock, abor…
hongquanli Jul 4, 2026
1921e15
fix(record-zstack): medium review fixes — byte cap, per-FOV z, dt pac…
hongquanli Jul 4, 2026
af08841
fix(record-zstack): round-2 review fixes on the fix commits
hongquanli Jul 4, 2026
da61d26
fix(record-zstack): data-integrity cluster from completed round-2 ver…
hongquanli Jul 4, 2026
068483a
fix(record-zstack): GUI + cleanup findings from completed round-2 ver…
hongquanli Jul 4, 2026
1f6058d
feat(multi-plane): params + pure plane-offset/validation helpers
hongquanli Jul 4, 2026
982d542
feat(multi-plane): extra _squid attrs passthrough on ZarrAcquisitionC…
hongquanli Jul 4, 2026
a8735fa
feat(multi-plane): per-plane recording loop, paths, and metadata in R…
hongquanli Jul 4, 2026
c9a5d9f
feat(multi-plane): Bottom Z / Nz / dz recording fields with dz hidden…
hongquanli Jul 4, 2026
c0c2a3e
fix(multi-plane): move plane-0 Z move before illumination; restore ca…
hongquanli Jul 4, 2026
10e31aa
refactor(multi-plane): compact recording UI — Nz leads FPS/Dur row, d…
hongquanli Jul 4, 2026
aa50083
refactor(multi-plane): single-row recording controls — Nz, Z offset, …
hongquanli Jul 4, 2026
0f88316
refactor(multi-plane): integer FPS/Dur spinboxes, drop fps suffix
hongquanli Jul 4, 2026
2674ec6
feat(multi-plane): show recording Z offset only when Laser AF is enab…
hongquanli Jul 4, 2026
d7dd1ef
feat(record-zstack): XY/Time tabbed row, Select Wells mode, channel s…
hongquanli Jul 5, 2026
b452150
feat(record-zstack): add xy_mode/scan_size_mm/overlap_percent to acqu…
hongquanli Jul 5, 2026
7605d1b
feat(record-zstack): add _build_objective_info helper for acquisition…
hongquanli Jul 5, 2026
8dda233
feat(record-zstack): write full acquisition.yaml snapshot alongside a…
hongquanli Jul 5, 2026
e0db6d6
feat(record-zstack): parse record_zstack acquisition.yaml into Record…
hongquanli Jul 5, 2026
42be5fd
refactor(widgets): generalize AcquisitionYAMLDropMixin for a 3rd widg…
hongquanli Jul 5, 2026
c175190
fix(record-zstack): match mismatch-dialog display name to actual tab …
hongquanli Jul 5, 2026
86d7397
refactor(widgets): extract well-region YAML apply logic into shared f…
hongquanli Jul 5, 2026
8c506b9
feat(record-zstack): wire RecordZStackMultiPointWidget into Acquisiti…
hongquanli Jul 5, 2026
eb906e0
fix: RecordZStackMultiPointWidget._apply_yaml_settings channel/time bugs
hongquanli Jul 5, 2026
e57ce42
fix: refresh Time tab styling in RecordZStackMultiPointWidget YAML load
hongquanli Jul 5, 2026
80d00b2
feat(record-zstack): add Save Settings/Load Settings buttons
hongquanli Jul 5, 2026
616b5c7
fix(tests): exercise real save/load chains in record_zstack settings …
hongquanli Jul 5, 2026
a4219f9
test(record-zstack): add full save/load round-trip regression test
hongquanli Jul 5, 2026
3447bfd
test: Add assertions for recording_channel and z-stack channel numeri…
hongquanli Jul 5, 2026
e2bce04
fix(record-zstack): resync xy_controls_frame visibility after YAML load
hongquanli Jul 5, 2026
c2e4072
fix(record-zstack): catch _apply_yaml_settings failures in the shared…
hongquanli Jul 5, 2026
4bfa53d
fix(record-zstack): let real save-YAML write failures reach the caller
hongquanli Jul 5, 2026
434db70
test(record-zstack): cover Nz/dz/bottom-Z fields in the settings roun…
hongquanli Jul 5, 2026
ee384f7
feat(multi-plane): confirm recording plane summary before starting ac…
hongquanli Jul 5, 2026
ec86a7d
refactor(multi-plane): FPS/Duration lead the recording row; Bottom-Z …
hongquanli Jul 5, 2026
79f2fe3
refactor(multi-plane): align Bottom-Z offset row under Nz via QGridLa…
hongquanli Jul 5, 2026
93105f0
refactor: drop dead no-op stubs after master's duck-typing removal (#…
hongquanli Jul 6, 2026
cbd64da
refactor(multi-plane): dynamic recording-row reflow with aligned columns
hongquanli Jul 6, 2026
26d224f
fix(record-zstack): size continuous recording to the camera's true fr…
Alpaca233 Jul 6, 2026
7b15a17
refactor(record-zstack): drop dead fallback in _continuous_max_framerate
Alpaca233 Jul 6, 2026
75f043a
feat(record-zstack): Recording phase checked by default; collapse unc…
hongquanli Jul 6, 2026
9c7515c
style(record-zstack): replace QGroupBox sections with plain QFrame + …
hongquanli Jul 6, 2026
7edab21
style(record-zstack): flatten Start Acquisition box, hide Save/Load S…
hongquanli Jul 6, 2026
59fb917
fix(record-zstack): resync Recording/Z-Stack section visibility after…
hongquanli Jul 6, 2026
e0a7c57
fix(record-zstack): remove empty space below recording channel table row
hongquanli Jul 6, 2026
93f8e4b
fix(record-zstack): un-bold channel table headers; copy-from-live use…
hongquanli Jul 6, 2026
c990055
fix(record-zstack): copy-from-live no longer resets on lookup miss; D…
hongquanli Jul 6, 2026
2e7e2ba
fix(record-zstack): probe frame shape in CONTINUOUS mode so software-…
Alpaca233 Jul 6, 2026
ee031ea
feat(record-zstack): gray out Start Acquisition when neither phase is…
hongquanli Jul 19, 2026
4fd6558
style(record-zstack): drop the raised-panel frame on the outer widget
hongquanli Jul 19, 2026
d9e19e0
fix(record-zstack): match tab background — QScrollArea was painting i…
hongquanli Jul 20, 2026
26c0c25
rebase: restore merge resolutions lost when flattening branch merges
Alpaca233 Jul 27, 2026
598866a
refactor: dedupe shared acquisition machinery, hoist hot-path work (r…
Alpaca233 Jul 27, 2026
d5db54e
fix: make record-zstack smoke tests machine-independent; inject zarr_…
Alpaca233 Jul 27, 2026
15e4881
refactor: remove the Simple Recording widget
Alpaca233 Jul 28, 2026
e26c76d
fix(camera): apply the toupcam PRECISE_FRAMERATE hint while the strea…
hongquanli Sep 8, 2026
ddccdb4
perf(recording): pipeline TensorStore writes; route camera-paced reco…
hongquanli Sep 8, 2026
13b5a8a
fix(camera): toupcam precise-framerate range tracks the mode; hint re…
hongquanli Sep 8, 2026
551723c
feat(record-zstack): throttled display tap in StreamingCapture
hongquanli Jul 12, 2026
2ddcad3
feat(record-zstack): thread display callable through controller and w…
hongquanli Jul 12, 2026
b914c21
feat(record-zstack): live preview signal + display wiring at RECORD_Z…
hongquanli Jul 12, 2026
7a81bea
docs: fix contradictory preview-fps comment and stale hot-path docstr…
hongquanli Jul 12, 2026
db4e1fc
fix(record-zstack-live-preview): update tests for recording_z_offset_…
hongquanli Jul 27, 2026
f8ca0aa
fix(recording): stamp the measured t-axis spacing when a paced source…
hongquanli Sep 8, 2026
5bad2d6
fix(recording): no corrupted first frame with a hardware ROI (toupcam…
hongquanli Sep 8, 2026
a8afa9e
feat(record-zstack): warn before starting when the camera cannot reac…
hongquanli Sep 8, 2026
90cc309
feat(record-zstack): resolve the recording fps before the acquisition…
hongquanli Sep 8, 2026
752b71d
rebase: restore merge resolutions lost when flattening the master merge
Alpaca233 Sep 29, 2026
0262f9f
refactor(record-zstack): remove duplication and probing left by the r…
Alpaca233 Sep 29, 2026
d4e2ee4
fix(record-zstack): a collapsed Recording section stays collapsed whe…
Alpaca233 Sep 29, 2026
3442c90
fix(record-zstack): every acquisition of a session keeps its z-stack
Alpaca233 Oct 11, 2026
588503b
test(record-zstack): give the simulated test camera its own CameraConfig
Alpaca233 Oct 11, 2026
ec84e67
refactor(record-zstack): simplify the in-process z-stack saving commit
Alpaca233 Oct 11, 2026
6c36b1a
fix(record-zstack): address the Copilot review on the stacked branch
Alpaca233 Oct 11, 2026
54eb11c
refactor(record-zstack): simplify the Copilot-review fixes
Alpaca233 Oct 11, 2026
eb441dd
fix(camera): a software-trigger switch on a stopped ToupTek stream ke…
maragall Oct 12, 2026
ee706e1
Revert "fix(camera): a software-trigger switch on a stopped ToupTek s…
maragall Oct 12, 2026
d5df796
fix(record-zstack): switch to software trigger at the start of the ru…
maragall Oct 12, 2026
a970211
docs(record-zstack): state the scope, side effect and limits of the r…
maragall Oct 12, 2026
7e92627
fix(record-zstack): a 0 ms channel exposure can no longer be entered …
maragall Oct 12, 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
6 changes: 6 additions & 0 deletions software/control/_def.py
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,12 @@ class SOFTWARE_POS_LIMIT:
ENABLE_WELLPLATE_MULTIPOINT = True
ENABLE_RECORDING = False

# Throttled live preview during Record + Z-Stack acquisitions: frames/s shown in
# the live display during the recording phase. When preview is enabled, z-stack
# planes display unthrottled (like multipoint). 0 disables the preview for both
# phases.
RECORD_ZSTACK_DISPLAY_FPS = 3

RESUME_LIVE_AFTER_ACQUISITION = True

# When enabled, each multipoint acquisition will write a second log file scoped to that acquisition at:
Expand Down
91 changes: 84 additions & 7 deletions software/control/acquisition_yaml_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,83 @@ class AcquisitionYAMLData:
skip_saving: bool = False


@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
effective_fps: Optional[float] = None # informational: the rate the saved run was made at
duration_s: float = 1.0
recording_bottom_z_offset_um: float = 0.0
recording_nz: int = 1
recording_dz_um: float = 1.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),
effective_fps=recording.get("effective_fps"),
duration_s=recording.get("duration_s", 1.0),
recording_bottom_z_offset_um=recording.get("bottom_z_offset_um", 0.0),
recording_nz=recording.get("nz", 1),
recording_dz_um=recording.get("dz_um", 1.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 a saved acquisition.yaml - or a saved acquisition folder containing one.

Expand All @@ -83,7 +160,8 @@ def parse_acquisition_yaml(file_path: str) -> AcquisitionYAMLData:
parsed = parse_acquisition_dict(data, source=file_path)

csv_path = os.path.join(folder or os.path.dirname(file_path), "coordinates.csv")
regions = parsed.wellplate_regions if parsed.widget_type == "wellplate" else parsed.flexible_positions
# record_zstack YAMLs carry wellplate-style regions and no flexible_positions.
regions = parsed.flexible_positions if parsed.widget_type == "flexible" else parsed.wellplate_regions
if regions and any(not r.get("fovs") for r in regions) and os.path.isfile(csv_path):
try:
by_name = {r["name"]: r["fovs"] for r in read_coordinates_csv(csv_path)}
Expand Down Expand Up @@ -114,17 +192,16 @@ def parse_acquisition_dict(data: dict, source: str = "<dict>") -> AcquisitionYAM
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:
camera_binning = tuple(binning)
else:
camera_binning = None
camera_binning = _parse_camera_binning(obj)

# Determine overlap_percent from the appropriate section
if wellplate_scan:
Expand Down
175 changes: 175 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 @@ -61,7 +62,33 @@ 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):
# Last successfully read (min, max) PRECISE_FRAMERATE range in tenths of fps; see
# _refresh_precise_framerate_range. Class-level default so it exists before __init__ runs.
_precise_framerate_range_tenths: Optional[Tuple[int, int]] = None
# (width, height, bytes/pixel) the cached range was read for; the range depends on
# resolution and bit depth, so a mode change invalidates it (see _update_internal_settings).
_precise_framerate_mode_key: Optional[Tuple[int, int, int]] = None
# time.time() of the last PRECISE_FRAMERATE write made while the stream was running.
# Such a write corrupts the frame integrating at that moment when a hardware ROI is
# set; ContinuousFrameSource discards frames delivered within a period of it.
frame_rate_hint_live_write_ts: Optional[float] = None

TOUPCAM_OPTION_RAW_RAW_VAL = 1
TOUPCAM_OPTION_RAW_RGB_VAL = 0
PIXEL_SIZE_UM = 3.76
Expand Down Expand Up @@ -331,6 +358,18 @@ def _start_raw_camera_stream(self):
self._raw_camera_stream_started = False
self._log.exception("failed to start camera, hr=0x{:x}".format(ex.hr))
raise ex
# The PRECISE_FRAMERATE range is only readable while the stream runs; grab
# it now so set_frame_rate() calls made while stopped can clamp against
# the current mode's range (see _refresh_precise_framerate_range). Only in
# CONTINUOUS mode: the option paces free-run only, and a trigger-mode
# stream (the z-stack phase) must not overwrite the cached free-run range.
# Outside the try above so nothing here can flip _raw_camera_stream_started
# while the SDK stream is actually running.
try:
if self.get_acquisition_mode() == CameraAcquisitionMode.CONTINUOUS:
self._refresh_precise_framerate_range()
except Exception:
self._log.debug("precise-framerate range refresh skipped after stream start", exc_info=True)

def _on_frame_callback(self):
"""
Expand Down Expand Up @@ -456,6 +495,14 @@ def _update_internal_settings(self, send_exposure=True):
with self._raw_frame_callback_lock:
self._internal_read_buffer = bytes(buffer_size)

# A resolution / bit-depth change moves the PRECISE_FRAMERATE range (28 fps at
# 2x2 16-bit vs 63 fps at 3x3 on the ITR3CMOS26000KMA): drop the cached range so a
# stopped-stream set_frame_rate() cannot clamp the new mode to the old maximum.
mode_key = (int(width), int(height), int(pixel_size))
if mode_key != self._precise_framerate_mode_key:
self._precise_framerate_mode_key = mode_key
self._precise_framerate_range_tenths = None

image_exposure_time_ms = self.get_exposure_time()
camera_exposure_time_ms = self._calculate_camera_exposure_time(image_exposure_time_ms)
self._strobe_info = ToupcamCamera._calculate_strobe_info(
Expand Down Expand Up @@ -588,6 +635,134 @@ 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 _continuous_max_framerate(self, exposure_time_ms: Optional[float] = None) -> float:
"""Highest frame rate the sensor can sustain in CONTINUOUS (free-run) mode, in fps, at the
given exposure (None: the current one).

In continuous mode the exposure pipelines with sensor readout, so the frame period
is max(readout, exposure). This deliberately does NOT use get_total_frame_time()
(== readout + trigger_delay + exposure), which is the *sequential* software/hardware-
trigger period and under-reports the free-run rate by ~2x. strobe_time_us is the
pure, exposure-independent readout period. The triggered-mode timing getters
(get_strobe_time/get_total_frame_time/_calculate_strobe_info) are left untouched.
"""
exposure = self.get_exposure_time() if exposure_time_ms is None else float(exposure_time_ms)
readout_ms = self._strobe_info.strobe_time_us / 1000.0
return 1000.0 / max(readout_ms, exposure)

def _refresh_precise_framerate_range(self) -> Optional[Tuple[int, int]]:
"""Return (min, max) PRECISE_FRAMERATE in tenths of fps, refreshing the cache when possible.

The range query is only answered while the pull-mode stream is running; while the
camera is stopped it fails with E_UNEXPECTED (verified on an ITR3CMOS26000KMA), so a
failed read falls back to the last successful one. Returns None when the option has
never been readable (model without PRECISE_FRAMERATE support).
"""
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)
mode_key = self._current_mode_key()
except Exception as ex:
# HRESULTException while stopped is the normal case; anything else
# (ROI / pixel-format lookups) must not escape either — a stale or
# missing cache only costs the hardware pacing, never the stream.
self._log.debug(f"precise-framerate range read failed (stream stopped?): {ex}")
return self._precise_framerate_range_tenths
self._precise_framerate_range_tenths = (int(min_tenths), int(max_tenths))
self._precise_framerate_mode_key = mode_key
return self._precise_framerate_range_tenths

def _current_mode_key(self) -> Tuple[int, int, int]:
"""(width, height, bytes/pixel) — what the PRECISE_FRAMERATE range depends on."""
_, _, width, height = self._camera.get_Roi()
return int(width), int(height), int(self._get_pixel_size_in_bytes())

def get_max_frame_rate(self, exposure_time_ms: Optional[float] = None) -> float:
"""Highest free-run rate for the current binning / ROI / bit depth at the given exposure.

Pure query (no SDK writes): the readout-limited estimate of _continuous_max_framerate
at that exposure, bounded by the cached PRECISE_FRAMERATE maximum when the cache is for
the current mode. Within ~1.5% of the SDK's own maximum in every mode measured.
"""
fps = self._continuous_max_framerate(exposure_time_ms)
rng = self._precise_framerate_range_tenths
if rng is not None:
try:
if self._precise_framerate_mode_key == self._current_mode_key():
fps = min(fps, rng[1] / 10.0)
except Exception:
pass
return fps

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

_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).

Works whether or not the stream is running: the option *write* is accepted while
the camera is stopped and takes effect on the next Start (the range *read* is not,
hence the cached range — see _refresh_precise_framerate_range).

Args:
fps: Desired frame rate in frames per second. If None or <= 0, returns
the camera's achievable continuous maximum without changing settings.

Returns:
The frame rate the camera will actually deliver, in fps: the requested rate
clamped to the PRECISE_FRAMERATE range and then bounded by the exposure/readout-
limited continuous maximum (PRECISE_FRAMERATE only slows the sensor down — its
reported maximum ignores the exposure time, so at 100 ms exposure the camera
still delivers 10 fps whatever the option says). When the option is unavailable
on this model it is the continuous maximum (see _continuous_max_framerate).
"""
continuous_max = self._continuous_max_framerate()
if fps is None or fps <= 0:
return continuous_max
framerate_range = self._refresh_precise_framerate_range()
if framerate_range is not None:
min_tenths, max_tenths = framerate_range
tenths = clamp_precise_framerate_tenths(fps, min_tenths, max_tenths)
else:
# Range unknown: the stream has not run since the last resolution /
# bit-depth change. Bound the request by the readout-limited maximum
# (within ~1.5% of the SDK's MAX_PRECISE_FRAMERATE in every binning /
# bit-depth mode measured on the ITR3CMOS26000KMA) and try the write —
# but the SDK validates a stopped-state write against the range of the
# mode it last streamed in, so it may well be rejected (see below).
tenths = max(1, int(round(min(fps, continuous_max) * 10.0)))
live = self._raw_camera_stream_started
if live:
# Writing PRECISE_FRAMERATE into a RUNNING stream corrupts the frame being
# integrated at that moment when a hardware ROI is set (bench: 1.8-3.6x
# over-exposed, saturated on a bright scene; full-frame is unaffected), so
# skip the write when the camera already reports the value. It cannot be
# avoided altogether: with a ROI the SDK re-clamps a stopped-state write
# to the full-frame maximum at Start, so a higher rate only takes while
# running — callers discard the frames around the write (see
# frame_rate_hint_live_write_ts / ContinuousFrameSource.start).
try:
if int(self._camera.get_Option(toupcam.TOUPCAM_OPTION_PRECISE_FRAMERATE)) == tenths:
return min(tenths / 10.0, continuous_max)
except toupcam.HRESULTException:
pass
try:
self._camera.put_Option(toupcam.TOUPCAM_OPTION_PRECISE_FRAMERATE, tenths)
if live:
self.frame_rate_hint_live_write_ts = time.time()
except toupcam.HRESULTException as ex:
# The sensor keeps whatever PRECISE_FRAMERATE was set before (it does not
# fall back to free-run), so the caller should re-apply the hint once the
# stream is running — ContinuousFrameSource.start() does exactly that.
self._log.warning(
f"set precise-framerate {tenths / 10.0:g} fps rejected while the stream is stopped "
f"({control.toupcam_exceptions.explain(ex)}); the previous pacing stays in effect "
"until the hint is re-applied with the stream running"
)
return continuous_max
return min(tenths / 10.0, continuous_max)

@staticmethod
def _user_gain_to_toupcam(user_gain):
"""
Expand Down
Loading
Loading