fix(camera): don't reuse a pre-trigger frame in DefaultCamera.read_camera_frame - #599
Merged
Merged
Conversation
…mera_frame read_camera_frame's fast path returns the cached _current_frame when it is younger than exposure + strobe. In software/hardware trigger mode this can return a frame captured BEFORE the current send_trigger(), because the newly triggered frame has not arrived yet. For the laser-AF focus camera this breaks move_to_target: after the piezo correction, _verify_spot_alignment triggers a read but gets the pre-move frame, sees the spot still displaced, fails the cross-correlation check, and reverts a correct focus move -> the FOV is left defocused, intermittently. The reuse window widened from ~2.7 ms to ~27 ms once the focus camera started recomputing its strobe delay in software-trigger mode (full 2064-row sensor, not the 256-row ROI), which turned a rare race into the common case. Fix: only reuse the cached frame when it was captured at/after the most recent trigger. Strobe handling is untouched; continuous mode is unaffected (no per-read trigger, so _last_trigger_timestamp stays old and every streamed frame passes the guard); and a future hardware-triggered focus camera is handled correctly (send_trigger stamps _last_trigger_timestamp in both paths). PhotometricsCamera.read_camera_frame has no reuse fast path and is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a race in DefaultCamera.read_camera_frame where a cached frame could be returned immediately after a trigger, even if that cached frame was captured before the trigger (breaking trigger→read workflows like laser autofocus verification).
Changes:
- Add a trigger-timestamp guard to the cached-frame fast path so only frames captured at/after the most recent trigger can be reused.
- Expand inline comments to document why the guard is necessary and why continuous/streaming behavior remains unchanged.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| # If the last frame we got was from <exposure time ago, use it. | ||
| if self._current_frame and time.time() - self._current_frame.timestamp <= total_exposure_time_ms / 1000.0: | ||
| # Only reuse the cached frame if it was captured AFTER the most recent trigger and is |
Collaborator
Author
There was a problem hiding this comment.
[Claude Code] Fixed in commit 112c50b - reworded the comment to "at or after the most recent trigger" to match the >= condition. The >= is intentional: a frame stamped at the same tick as the trigger is the triggered frame (or later), so it's safe to reuse.
… >=) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Alpaca233
added a commit
that referenced
this pull request
Jul 27, 2026
…-trigger contract (#603) Follow-up to #599 (two small non-behavioral improvements that were pushed to that branch after it merged, so they never landed): - **`DefaultCamera.read_camera_frame`:** snapshot `self._current_frame` into a local before evaluating the reuse condition. The camera callback thread can reassign `_current_frame` between the condition's reads, so the truthiness check, timestamp comparisons, and returned object could otherwise refer to different frames. A single read makes the evaluation atomic with respect to the callback. Comment trimmed to the non-obvious rationale. - **`AbstractCamera.read_camera_frame` docstring:** state the contract #599 enforces — in triggered modes, implementations must never return a pre-trigger frame (e.g. from a cached-frame fast path) — so future drivers with a frame-reuse fast path implement the same guard. Also fixes the docstring's backwards description of the `read_frame` relationship (`read_frame` delegates to `read_camera_frame`, not the other way around). No behavior change. `tests/squid/test_camera.py` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <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.
Problem
Laser (reflection) autofocus is less stable on recent builds: it intermittently leaves FOVs defocused, most noticeably when a larger focus correction is needed.
Root cause
DefaultCamera.read_camera_framehas a fast path that returns the cached_current_framewhen it is younger thanexposure + strobe:In a trigger-then-read pattern (
send_trigger()thenread_frame()) this can return the frame from before the trigger, because the freshly triggered frame has not arrived yet.The laser-AF loop is:
measure_displacement()— reads the spot, computes the correction (correct)._move_z()— moves the piezo (correct, instant)._verify_spot_alignment()— triggers a read and cross-correlates against the reference.At step 3 the read returns the pre-move frame, so verification sees the spot still displaced, fails the cross-correlation, and
move_to_targetreverts the correct correction — leaving the FOV defocused.The window governing the fast path grew from ~2.7 ms to ~27 ms once the focus camera began recomputing its strobe delay in software-trigger mode (
_update_strobe_timeuses the full 2064-row sensor, not the 256-row ROI), turning an unlikely race into the common case.Fix
Only reuse the cached frame if it was captured at/after the most recent trigger:
DefaultCameraare unaffected._last_trigger_timestampstays old and every streamed frame passes the guard (fast path preserved).send_triggerstamps_last_trigger_timestampin both software and hardware paths.PhotometricsCamera.read_camera_framehas no reuse fast path and needs no change.Testing
pytest tests/squid/test_camera.py→ 4 passed (test_read_frame_on_timeoutalready asserts eachsend_trigger→read_framereturns that trigger's own frame).python -m py_compile control/camera.py→ OK.🤖 Generated with Claude Code