From a585eb34efc693a7f6aa3ca485f79b36e5bf737d Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 5 Jul 2026 19:43:11 -0400 Subject: [PATCH 1/2] feat(record-zstack): cap Hamamatsu recording frame rate via DCAM INTERNALFRAMERATE Add HamamatsuCamera.set_frame_rate() (was inheriting the AbstractCamera no-op) so Record+Z-Stack recording caps the ORCA's internal free-run rate instead of free-running at the exposure/readout-limited max and downsampling in software. - Forces fast readout (DCAMPROP.READOUTSPEED.FASTEST) before reading the INTERNALFRAMERATE ceiling, so high (>30 fps) targets aren't pinned to the slow/low-noise mode's limit. - Clamps the request to the property's valid range, sets it, and returns the achieved rate (base contract). Any failure falls back to the exposure-limited max (== prior behavior), so recording stays correct even if the props are unsupported. Untested on hardware locally (no DCAM lib on the dev Mac); verified py_compile + black only. Hardware verification pending on ORCA-Fusion BT (C15440-20UP). Known follow-up: readout speed is left in fast mode after recording (not restored); make it configurable / restore for low-noise low-fps recording. Co-Authored-By: Claude Opus 4.8 (1M context) --- software/control/camera_hamamatsu.py | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/software/control/camera_hamamatsu.py b/software/control/camera_hamamatsu.py index 2d6c3d45c..fdb9e7b2b 100644 --- a/software/control/camera_hamamatsu.py +++ b/software/control/camera_hamamatsu.py @@ -210,6 +210,54 @@ 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). + """ + fallback = 1000.0 / self.get_total_frame_time() + 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) or attr is None: + self._log.warning( + "INTERNALFRAMERATE not available on this camera; leaving free-run rate at the camera default." + ) + 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 -> {float(achieved):.3f} fps") + return float(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.") From 01c07aa9af002cc8b73d9799792f9d4718df91c3 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 5 Jul 2026 19:59:47 -0400 Subject: [PATCH 2/2] fix(record-zstack): honor set_frame_rate never-raises contract + review nits Address review of the Hamamatsu set_frame_rate override: - Guard the exposure-limited-max fallback: get_strobe_time() can raise on a DCAM read error, which previously escaped the method despite the "returns a usable rate on any failure" docstring. Now falls back to the requested rate. - Drop the unreachable `attr is None` branch (prop_getattr returns attr or False). - Fix the misleading "camera default" log (readout speed is already set by then). - Drop redundant float() on an already-float prop_getvalue result. Co-Authored-By: Claude Opus 4.8 (1M context) --- software/control/camera_hamamatsu.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/software/control/camera_hamamatsu.py b/software/control/camera_hamamatsu.py index fdb9e7b2b..f66a479bc 100644 --- a/software/control/camera_hamamatsu.py +++ b/software/control/camera_hamamatsu.py @@ -224,7 +224,14 @@ def set_frame_rate(self, fps: float) -> float: 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). """ - fallback = 1000.0 / self.get_total_frame_time() + 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: @@ -241,9 +248,9 @@ def set_frame_rate(self, fps: float) -> float: 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) or attr is None: + if isinstance(attr, bool): self._log.warning( - "INTERNALFRAMERATE not available on this camera; leaving free-run rate at the camera default." + "INTERNALFRAMERATE not available on this camera; leaving the internal frame rate uncapped." ) return fallback target = max(attr.valuemin, min(float(fps), attr.valuemax)) @@ -252,8 +259,8 @@ def set_frame_rate(self, fps: float) -> float: achieved = self._camera.prop_getvalue(DCAM_IDPROP.INTERNALFRAMERATE) if isinstance(achieved, bool): return fallback - self._log.info(f"set_frame_rate({fps}) set INTERNALFRAMERATE -> {float(achieved):.3f} fps") - return float(achieved) + 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