Skip to content

fix(camera): don't reuse a pre-trigger frame in DefaultCamera.read_camera_frame - #599

Merged
Alpaca233 merged 2 commits into
masterfrom
fix/laser-af-stale-frame-verify
Jul 26, 2026
Merged

fix(camera): don't reuse a pre-trigger frame in DefaultCamera.read_camera_frame#599
Alpaca233 merged 2 commits into
masterfrom
fix/laser-af-stale-frame-verify

Conversation

@Alpaca233

Copy link
Copy Markdown
Collaborator

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_frame has a fast path that returns the cached _current_frame when it is younger than exposure + strobe:

if self._current_frame and time.time() - self._current_frame.timestamp <= total_exposure_time_ms / 1000.0:
    return self._current_frame

In a trigger-then-read pattern (send_trigger() then read_frame()) this can return the frame from before the trigger, because the freshly triggered frame has not arrived yet.

The laser-AF loop is:

  1. measure_displacement() — reads the spot, computes the correction (correct).
  2. _move_z() — moves the piezo (correct, instant).
  3. _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_target reverts 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_time uses 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:

if (
    self._current_frame
    and self._current_frame.timestamp >= self._last_trigger_timestamp
    and time.time() - self._current_frame.timestamp <= total_exposure_time_ms / 1000.0
):
    return self._current_frame
  • Strobe untouched — illumination-sync behavior and imaging use of DefaultCamera are unaffected.
  • Continuous / live mode unchanged — no per-read trigger is sent, so _last_trigger_timestamp stays old and every streamed frame passes the guard (fast path preserved).
  • Future hardware-triggered focus camera works — send_trigger stamps _last_trigger_timestamp in both software and hardware paths.
  • PhotometricsCamera.read_camera_frame has no reuse fast path and needs no change.

Testing

  • pytest tests/squid/test_camera.py4 passed (test_read_frame_on_timeout already asserts each send_triggerread_frame returns that trigger's own frame).
  • python -m py_compile control/camera.py → OK.

🤖 Generated with Claude Code

…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread software/control/camera.py Outdated

# 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
Alpaca233 merged commit f8fe36d into master Jul 26, 2026
3 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants