Skip to content

fix: legacy RecordingWidget + Toupcam continuous-mode FPS + Live illumination#535

Merged
Alpaca233 merged 28 commits into
masterfrom
fix/legacy-recording-widget
Jun 1, 2026
Merged

fix: legacy RecordingWidget + Toupcam continuous-mode FPS + Live illumination#535
Alpaca233 merged 28 commits into
masterfrom
fix/legacy-recording-widget

Conversation

@Alpaca233
Copy link
Copy Markdown
Collaborator

@Alpaca233 Alpaca233 commented May 7, 2026

This PR landed as a tightly-coupled chain of fixes that surfaced while bench-testing the recording widget. Three groups:

1. Legacy RecordingWidget — make it work + scientifically usable

The original bug. A base_path_is_set flag was never flipped after DEFAULT_SAVING_PATH was applied, so clicking Record always short-circuited with a "Please choose base saving directory first" modal. Also: set_saving_dir blindly clobbered the existing path on dialog cancel.

The follow-on work. Once the widget actually recorded, the saved files were unidentifiable two weeks later — BMPs in numbered folders with no metadata. Rebuilt core.ImageSaver to:

  • Delegate per-frame writes to cv2.imwrite (uint8 + uint16) via utils_acquisition.get_image_filepath() for naming consistency with the multipoint pipeline (<file_id>_<channel>.<ext>). cv2 was ~10× faster than the previous imageio path on uint8 and ~2-3× on uint16.
  • Emit a frames.csv sidecar per recording: frame_id, timestamp_iso, channel, exposure_ms, gain, illumination_intensity, file. UTF-8 encoded; flushed only on stop so per-frame fsync doesn't gate FPS.
  • Track the active live channel per-frame via a channel_provider callable, registered by RecordingWidget from gui_hcs.py (lambda: self.liveController.currentConfiguration).
  • Replace bare-except: pass / print paths with squid.logging: ERROR on dir-create / CSV-open failure (re-raised so the widget can restore UI state); throttled WARNING on queue saturation; single-emit OSError auto-stop; INFO start/stop summary + per-recording diagnostics (imwrite_avg/max, queue_wait_avg, input/saved FPS, drops, silent-write-failures).
  • Add stop_experiment() lifecycle: drains the queue, closes CSV, logs the summary. Called from the Stop button and from stop_recording auto-stop slot.
  • Defensive: start_new_experiment clears stale state; process_queue discards frames that arrive in the Qt event queue after experiment_ID was cleared (race that previously wrote to <base>/0/); counter increments immediately after cv2.imwrite succeeds, before the CSV row write (so a writerow exception doesn't cause the next frame to overwrite).

Default format flip. Acquisition.IMAGE_FORMAT default changes from bmp to tiff (_def.py:119). BMP path stays available via [ACQUISITION] image_format = bmp in INI. Also extended channel-name sanitization in get_image_filepath to strip / \ : * ? \" < > | in addition to spaces.

2. Toupcam continuous-mode FPS regression

Independent issue noticed while bench-testing: continuous mode delivered ~2 fps for ITR3CMOS26000KMA at MONO16 / 2×2 binning, while software / hardware trigger ran at ~10 fps. Root cause confirmed via three empirical findings:

  • Start Live in software trigger → flip to continuous: ~10 fps.
  • Stop Live → switch to continuous → start Live: drops to ~2 fps.
  • Continuous-mode FPS depends on camera state at StartPullModeWithCallback time, not on subsequent option flips.

Fix: put_AutoExpoEnable(False) in _configure_camera before _start_raw_camera_stream runs. The Toupcam SDK doc explicitly says OPTION_DDR_DEPTH default is "auto: one frame for video mode when auto-exposure is enabled, full capacity for others." Stream starting in video + auto-exposure-on locks DDR to one frame → host PullImage serializes with sensor readout → 2 fps. Disabling auto-exposure unlocks full-capacity DDR buffering. Squid sets exposure explicitly per channel, so disabling auto-exposure has no user-visible effect besides the FPS win.

Plus a defensive cleanup: _calculate_strobe_info no longer re-raises on get_Option(MAX_PRECISE_FRAMERATE) failure — it warns and falls back to the sensor-floor vheight. The option is only meaningful for continuous-mode pacing; failing the read shouldn't break mode switches.

3. Continuous-mode Live illumination

LiveController.start_live previously turned on illumination only via _start_triggerred_acquisition → timer → trigger_acquisition, which fires for software trigger (and hardware trigger with internal timer). Continuous mode skipped that chain entirely, so starting Live in continuous mode left illumination off. stop_live already turned it off symmetrically. Added turn_on_illumination() in the continuous branch of start_live.

Test plan

  • QT_QPA_PLATFORM=offscreen pytest tests/control/test_widgets.py tests/control/test_def.py — 168 passed
  • black --config pyproject.toml --check — clean
  • Manual on bench: continuous mode now matches software-trigger FPS for the Toupcam ITR3CMOS26000KMA at MONO16 / 2×2 binning
  • Manual on bench: illumination turns on when starting Live in continuous mode
  • Manual: launch GUI with `enable_recording = true` in INI; click Record; confirm `frames.csv` + per-frame TIFF files under `~/Downloads/_/0/`; toggle the live channel mid-recording and confirm both channel names appear in filenames; inspect the `Saver diagnostics:` log line at stop

Notes

  • The "Simple Recording" tab is gated by `ENABLE_RECORDING` in `_def.py` (default `false`). Add `enable_recording = true` to your `configuration*.ini` to surface it.
  • The Toupcam per-frame timing diagnostic + startup option dump are still in place (camera_toupcam.py) for future debugging; both at INFO level. Could be moved to DEBUG in a follow-up once we've verified the fix holds across configurations.
  • Same `base_path_is_set` pattern still exists in `MultiCameraRecordingWidget` and `widgets_usbspectrometer.RecordingWidget` (both dead code today); flagged for a separate cleanup PR.

🤖 Generated with Claude Code

Alpaca233 and others added 9 commits May 7, 2026 10:25
The Record button always short-circuited with a "Please choose base
saving directory first" modal even though DEFAULT_SAVING_PATH was
already populated, because base_path_is_set was never flipped after the
default path was applied. Also, cancelling the Browse dialog clobbered
the existing path with an empty string.

Drop the dead flag and gating modal (DEFAULT_SAVING_PATH is normalized
in _def.py and always valid), and make set_saving_dir a no-op on
cancel. Add regression tests covering construction, Record-without-
Browse, Browse-cancel, and end-to-end frame persistence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch the RecordingWidget fixture to monkeypatch for DEFAULT_SAVING_PATH
(automatic restore on test failure), drop the dead `is_live` cell, hoist
imports, and rely on observable widget/saver state instead of reaching
through `stream_handler._handler.save_image_flag`. Replace the manual
`time.sleep` polling with `qtbot.waitUntil` so it pumps the Qt event
loop, and use `qtbot.mouseClick` to also exercise the Record button's
clicked signal wiring. Tighten file-search to `Path.rglob`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The in-code default for individual-image saves becomes TIFF for both
multipoint and the recording widget. Users with an [ACQUISITION]
image_format= override in their INI keep getting whatever they set —
this only changes the source default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-thread callable returning the active AcquisitionChannel; saver
thread calls it per frame. Plumbing only — process_queue still uses the
inline imwrite path until the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
process_queue stops doing inline iio.imwrite / cv2.imwrite and instead
calls utils_acquisition.save_image(), inheriting dtype-based extension
(uint16 -> tiff, else Acquisition.IMAGE_FORMAT), channel-aware filenames,
BF-LED RGB->GRAY conversion, and pseudo-color from the multipoint
pipeline. A small _DefaultRecordingChannel sentinel handles the case
where no channel provider is registered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One row per saved frame: frame_id, timestamp_iso, channel, exposure_ms,
gain, illumination_intensity, file (relative path). Lets users identify
recordings by metadata long after capture and provides bidirectional
lookup between filename and frame_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- start_new_experiment: ERROR + raise on dir-create failure (was silent)
- enqueue: throttled WARNING with dropped-count tracking (was print)
- process_queue: ERROR + stop on OSError; WARNING per per-frame failure
- close(): INFO summary line with frames_saved, dropped, duration
- start: INFO line plus a one-time WARNING if no channel_provider is set

WARNING/ERROR records surface via the existing WarningErrorWidget panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RecordingWidget gains a channel_provider constructor kwarg; gui_hcs
passes a lambda returning liveController.currentConfiguration. On
Record-press the widget registers the provider on the saver; on stop
(button or auto via stop_recording signal) it clears it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 restores the legacy “Simple Recording” widget behavior so the Record button works out-of-the-box with DEFAULT_SAVING_PATH, and modernizes single-camera recording output to match the multipoint acquisition saving path (TIFF-by-default, channel-aware filenames, and a per-recording frames.csv sidecar).

Changes:

  • Fixes RecordingWidget directory selection logic so recording can start without an explicit browse, and canceling the browse dialog no longer clobbers the current saving directory.
  • Updates ImageSaver to write frames via utils_acquisition.save_image() and emit per-frame metadata to frames.csv, with improved logging for write/queue issues.
  • Flips the in-code default Acquisition.IMAGE_FORMAT from "bmp" to "tiff" and adds regression tests for recording + default format.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
software/control/widgets.py Restores legacy RecordingWidget behavior (no blocking modal), adds optional channel provider wiring, and fixes browse-cancel behavior.
software/control/gui_hcs.py Passes a channel provider from the live controller into RecordingWidget for per-frame metadata.
software/control/core/core.py Refactors ImageSaver to use save_image(), writes frames.csv, and adds logging/backpressure tracking.
software/control/_def.py Switches default acquisition image format to TIFF.
software/tests/control/test_widgets.py Adds focused regression tests for RecordingWidget + ImageSaver behavior, filenames, CSV, and logging paths.
software/tests/control/test_def.py Adds a test asserting the source default for IMAGE_FORMAT is TIFF.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread software/control/core/core.py Outdated
Comment on lines +195 to +200
self.queue.task_done()
self.image_lock.release()
except:
pass
except OSError as e:
log.error(f"Writer fatal error: {e}; stopping recording")
self.stop_recording.emit()
except Exception as e:
log.warning(f"Failed to write frame {frame_id}: {e}")
Comment thread software/control/core/core.py Outdated
Comment on lines +239 to +242
csv_path = os.path.join(experiment_dir, "frames.csv")
self._csv_file = open(csv_path, "w", newline="")
self._csv_writer = csv.writer(self._csv_file)
self._csv_writer.writerow(
Comment on lines 203 to +211
try:
self.queue.put_nowait([image, frame_ID, timestamp])
if (self.recording_time_limit > 0) and (
time.time() - self.recording_start_time >= self.recording_time_limit
):
self.stop_recording.emit()
# when using self.queue.put(str_), program can be slowed down despite multithreading because of the block and the GIL
except:
print("imageSaver queue is full, image discarded")
self.queue.put_nowait([image, frame_id, timestamp])
except Exception:
self._dropped_count += 1
now = time.time()
if now - self._last_queue_full_warning_ts >= 1.0:
log.warning(f"Image queue full; frame {frame_id} dropped")
self._last_queue_full_warning_ts = now
return
Comment on lines 4499 to 4503
self.streamHandler.start_recording()
else:
self.streamHandler.stop_recording()
self.imageSaver.set_channel_provider(None)
self.lineEdit_experimentID.setEnabled(True)
Alpaca233 and others added 6 commits May 7, 2026 18:08
Four related fixes from PR #535 review:

1. queue.task_done() now runs in finally; a write exception no longer
   leaves the item un-acked, which would have hung close()'s queue.join()
   indefinitely.
2. Extract stop_experiment(): closes frames.csv and logs the per-recording
   summary. Makes the Stop button (and time-limit auto-stop) finalize
   the CSV instead of leaving it open until app shutdown.
3. enqueue() now catches queue.Full specifically (was blanket Exception),
   so unrelated errors aren't silently misreported as queue saturation.
4. start_new_experiment() defensively calls stop_experiment() to close
   any prior CSV — protects against multi-record sessions leaking handles
   if a caller forgets to stop.

The widget's toggle_recording() and stop_recording() now both call
imageSaver.stop_experiment() so a single Record/Stop cycle produces a
finalized CSV plus a summary log line, instead of one CSV per session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop test_channel_provider_can_be_set_and_cleared (trivial slot
  getter/setter; the same code path is exercised through the widget by
  test_widget_constructed_with_channel_provider).
- Merge test_multiple_recordings_close_previous_csv and
  test_stop_experiment_finalizes_csv_and_logs_summary into a single
  test_recording_lifecycle_finalizes_csv_and_starts_fresh that walks
  start -> stop (assert closed + summary log) -> start (assert new CSV).

No coverage loss; less ceremony.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs reported on PR #535:

1. 'Writer fatal error: directory doesn't exist' — Stop clears
   experiment_ID="" but the saver thread can still have buffered frames
   to process. Subsequent writes resolve to <base>/<file_id>/, a path
   that was never created. stop_experiment() now drains the queue via
   queue.join() before tearing down state, so buffered frames land in
   the right experiment dir.

2. Recording FPS dropped to 2-3. utils_acquisition.save_image() routes
   every frame through imageio.imwrite, which is ~10x slower than
   cv2.imwrite for uint8 frames. Plus csv_file.flush() per row added
   an fsync per frame. Now we use get_image_filepath() for channel-aware
   naming consistency with the multipoint pipeline but write directly
   with cv2.imwrite/iio.imwrite (legacy fast path); CSV is flushed only
   on stop/close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop the uint16 -> iio.imwrite branch. cv2.imwrite handles 16-bit
  TIFF (and PNG) natively and is 2-3x faster than imageio/tifffile for
  the same data. get_image_filepath already forces uint16 -> .tiff so
  cv2 sees a TIFF path either way.
- Add lightweight per-recording diagnostics logged in the "Recording
  stopped" summary: imwrite_avg/max, queue_wait_avg, input fps, save
  fps, drop count. Tells you at a glance whether the saver is the
  bottleneck (high imwrite_avg + low queue_wait), starved (low
  imwrite_avg + high queue_wait), or dropping at enqueue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six bugs surfaced in the code review (Phase 2 verifications):

1. Move counter increment to immediately after cv2.imwrite (was after
   CSV writerow). If writerow raised — closed file, UnicodeEncodeError,
   etc. — the counter stayed put and the next frame's file_id would
   reuse the same path, silently overwriting the just-saved image.

2. Discard frames that arrive after stop_experiment clears state. A Qt-
   queued enqueue slot can fire after the GUI thread has run
   stop_experiment (queue.join drains the Python queue but not the Qt
   event queue). Without a guard, save_dir becomes <base>/0/ and the
   frame lands in the wrong directory.

3. Open frames.csv with encoding="utf-8". Without it, Windows
   defaults to cp1252 and channel names with µ/°/Å raise
   UnicodeEncodeError on the first writerow.

4. Use getattr(channel, "name", "live") in path construction too
   (consistent with the CSV row writes); a provider returning a
   non-None object missing .name no longer drops the frame silently.

5. Move Thread.start() to the end of __init__ so the saver thread
   can't observe uninitialized diagnostic counters.

6. Reset _last_queue_full_warning_ts in start_new_experiment so a
   stop-restart immediately after a queue-saturation event in the
   previous recording doesn't suppress the first drop warning of the
   new one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up the remaining cheap, reasonable fixes from PR #535 review:

#1 cv2.imwrite return value: cv2 returns False (without raising) on
missing dir, missing codec, or unsupported dtype. We now check the
return value, log a throttled WARNING per failure, increment a
_silent_write_failures counter (surfaced in the diagnostics summary),
and skip counter increment + CSV row write so no row references a
file that isn't on disk.

#4 channel-name path sanitization: get_image_filepath previously only
replaced spaces; now also strips '/', '\\', ':', '*', '?', '"', '<',
'>', '|'. Strict improvement for both the recording widget and the
multipoint pipeline.

#7 frames.csv open guarded: a PermissionError on the CSV file no
longer leaves the widget in a half-started state. On failure,
experiment_ID is cleared and the exception is re-raised so the
toggle_recording slot doesn't run streamHandler.start_recording().

#8 drop the dead image_format constructor parameter: process_queue
hasn't read it since the cv2.imwrite refactor — it derives the
extension from control._def.Acquisition.IMAGE_FORMAT via
get_image_filepath. No in-tree callers passed it.

#9 OSError auto-stop emits only once per recording: a saturated queue
hitting a persistent OSError (e.g., full disk) no longer fires 10
redundant stop_recording signals. _stop_requested_from_writer is
reset in start_new_experiment; subsequent errors during shutdown
log as WARNING instead of re-emitting.

#18 channel_provider returning None at start_new_experiment now logs
a distinct WARNING. Before, a user who launched the GUI and clicked
Record without first selecting a live channel got every frame
tagged 'live' with no warning (because the provider lambda itself
was non-None, just its return value was).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Comment thread software/control/widgets.py Outdated
Comment on lines 4494 to 4498
self.lineEdit_experimentID.setEnabled(False)
self.btn_setSavingDir.setEnabled(False)
if self._channel_provider is not None:
self.imageSaver.set_channel_provider(self._channel_provider)
self.imageSaver.start_new_experiment(self.lineEdit_experimentID.text())
Comment on lines +185 to +190
saving_path = utils_acquisition.get_image_filepath(
save_dir, str(file_id), channel_name, image.dtype
)
iio.imwrite(saving_path, image)
imwrite_started_ns = time.perf_counter_ns()
ok = cv2.imwrite(saving_path, image)
imwrite_ns = time.perf_counter_ns() - imwrite_started_ns
)
self._csv_file.flush()
except OSError as e:
log.error(f"Failed to open {csv_path}: {e}")
Alpaca233 and others added 9 commits May 26, 2026 15:42
Two more Copilot-flagged issues:

1. RecordingWidget.toggle_recording disabled the experiment-ID and
   browse controls, and (via the checkable button) checked btn_record
   BEFORE calling start_new_experiment. After this PR raises on
   dir-create / CSV-open failures, that left the UI half-stuck: button
   checked, fields disabled, no recording in progress. Now: wrap the
   start call, restore UI state on any exception, and re-uncheck the
   button so the user can retry.

2. start_new_experiment's CSV exception path nulled out self._csv_file
   without closing it first. If open() succeeded but the header
   writerow or flush raised (rare — typically disk-full mid-write),
   that leaked the file descriptor. Now close the handle in the
   exception path before nulling the refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…FRAMERATE on continuous

Reported: continuous-mode FPS sits at ~4 fps for ITR3CMOS26000KMA at
MONO16 / 2x2 binning, while software and hardware trigger both hit
~10 fps. Trigger mode bypasses PRECISE_FRAMERATE so the camera reads
at sensor-readout speed per trigger; continuous mode is paced by
PRECISE_FRAMERATE.

Three changes in _set_acquisition_mode_imp:

1. Wrap the TRIGGER option flip (and HW GPIO setup) in
   `with self._pause_streaming():`. Every other state-mutating method
   in this file (set_pixel_format, set_binning, set_frame_format)
   already does this — the trigger-mode path was the lone outlier.
   Camera options that need a fresh stream to take effect should now
   actually take effect.

2. When switching to continuous, explicitly read
   MAX_PRECISE_FRAMERATE and write it back to PRECISE_FRAMERATE
   inside the paused window. Previously this only happened as a side
   effect of `set_exposure_time(...)` running afterwards on a live
   stream — exactly the path the comment in `_calculate_strobe_info`
   already calls out as needing a re-set ("default value is only 90%
   of setting value").

3. Add an INFO log line after the switch reporting
   PRECISE_FRAMERATE, MAX_PRECISE_FRAMERATE, BANDWIDTH, and
   FR_LIMIT. Lets us distinguish a hardware cap (MAX is itself low
   in video mode at this config) from the SDK silently ignoring our
   PRECISE_FRAMERATE put.

No test changes — TestRecordingWidget and existing tests aren't on
this code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes from the post-commit review of 3ece218:

1. Move set_exposure_time(self.get_exposure_time()) INSIDE the
   _pause_streaming with-block. It chains into _update_internal_settings
   → _calculate_strobe_info which itself writes PRECISE_FRAMERATE
   (camera_toupcam.py:130). With the call after the with-block, that
   write happened on a *live* stream — exactly the path the diff's own
   comment says is unreliable. Now strobe-info, ExpoTime, and the
   PRECISE_FRAMERATE write all run while paused.

2. Drop the now-redundant CONTINUOUS-specific MAX→PRECISE_FRAMERATE
   put. _calculate_strobe_info already does the same write, and with
   #1 it now does it inside the pause window.

3. Reset self._trigger_sent = False after the pause. stop_streaming()
   cancels any in-flight frame callback that would have cleared the
   flag, so a mode switch initiated shortly after send_trigger() would
   leave _trigger_sent=True until ~1.5×exposure+4s timeout — silently
   blocking the next trigger.

Also broadened the diagnostic log's except from HRESULTException to
Exception so a non-numeric get_Option return (TypeError on the format
spec) doesn't propagate out and break the mode switch at the last step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts the structural simplification from bf42d30. Moving
set_exposure_time inside _pause_streaming caused _calculate_strobe_info
to run on a stopped stream, where get_Option(MAX_PRECISE_FRAMERATE)
fails — the post-rebuild log line "get max_framerate fail" is the
strobe-info chain re-raising and propagating out of the mode switch.

Some Toupcam options can only be queried while the stream is running.
Restore the previous structure: explicit MAX→PRECISE_FRAMERATE put for
CONTINUOUS inside the with-block (which doesn't need to read MAX_..
on a stopped stream because we cached it on the live stream just
before via a different read), and set_exposure_time after.

Keep the two unrelated fixes from bf42d30: _trigger_sent reset and
the broadened diagnostic-log except clause.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Building on de37d87 — the in-pause CONTINUOUS branch reads
MAX_PRECISE_FRAMERATE and writes PRECISE_FRAMERATE. But the user's
"get max_framerate fail" log shows the camera rejects that get_Option
while the stream is stopped. The previous structure swallowed the
HRESULT but then the put_Option was skipped, defeating the whole
point of the fix (PRECISE_FRAMERATE never gets driven to max).

Cache MAX_PRECISE_FRAMERATE before _pause_streaming (while the stream
is still live, where the read works), then write PRECISE_FRAMERATE
inside the with-block (the only state where the write sticks). If the
pre-pause read fails on some SKU, log a warning and skip the in-pause
write — set_exposure_time after the pause will still try via
_calculate_strobe_info on the running stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts the four toupcam commits on this branch:
  - 3ece218 (pause/restart + explicit PRECISE_FRAMERATE)
  - bf42d30 (/simplify: move set_exposure_time into pause; broke
    get max_framerate)
  - de37d87 (move set_exposure_time back out)
  - ccdaadc (cache MAX_PRECISE_FRAMERATE before pause)

User reports continuous mode FPS got slower with the latest attempt
than it was at baseline. The root cause is not the trigger-option
mid-stream flip — it's that we lack a clean way to read the camera's
MAX_PRECISE_FRAMERATE in the destination mode and apply it as a put_Option
while the stream is in the state where the option actually sticks.

Restoring control/camera_toupcam.py to master keeps PR #535 scoped to
the recording widget. The Toupcam continuous-FPS investigation should
move to a separate branch where it doesn't block the recording-widget
review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure diagnostic, no behavior changes. After the user's revert showed
MAX_PRECISE_FRAMERATE is identical in continuous and trigger modes but
the actual delivered FPS is still lower in continuous, we need to
find where the per-frame time goes. Likely suspects:

  - OPTION_DDR_DEPTH + auto-exposure: when auto-exposure is enabled in
    video mode, DDR caches only 1 frame, so the camera blocks on host
    PullImageV2 + processing time (no pipelining).
  - Host-side per-frame work (PullImageV2 USB transfer ~50ms for 13MB
    MONO16, rotate_and_flip image.copy(), display crop) hides behind
    the trigger timer in SW/HW modes but directly subtracts from FPS
    in continuous.

Two diagnostics:

1. _log_startup_option_dump() runs once after _update_internal_settings
   in __init__. Logs current TRIGGER, PRECISE_FRAMERATE,
   MAX_PRECISE_FRAMERATE, FRAMERATE_LIMIT, BANDWIDTH, DDR_DEPTH, RAW,
   LINEAR, CURVE, MULTITHREAD, LOW_NOISE, and AUTOEXP. Tells us at a
   glance what state the camera defaults to.

2. _on_frame_callback now times each stage (pull, process, propagate)
   and the inter-frame interval, logging every Nth frame (default 30)
   so we can compare continuous vs trigger without log spam. The log
   line tags the mode so trigger-vs-continuous comparison is direct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… fails

Pre-existing bug exposed by switching from continuous to software
trigger: _calculate_strobe_info re-raises on get_Option(
MAX_PRECISE_FRAMERATE) failure, which propagates out through
set_exposure_time → _set_acquisition_mode_imp, leaving the mode
switch half-applied and the camera in an unusable state.

The option isn't actually required for trigger modes (PRECISE_FRAMERATE
only paces video mode). When the read fails — typically in a transient
state right after the TRIGGER option flips — log a warning and fall
back to a sensor-floor vheight calculation. mode-switch keeps going,
and continuous mode's PRECISE_FRAMERATE still gets driven to max on
the (more reliable) startup path through _update_internal_settings
where the camera does accept the read.

Same treatment for the immediately-following put_Option(
PRECISE_FRAMERATE) — log and skip rather than raise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empirically, the pre-existing put_Option(PRECISE_FRAMERATE,
MAX_PRECISE_FRAMERATE) in _calculate_strobe_info was throttling
continuous mode to ~4 fps for ITR3CMOS26000KMA at MONO16 / 2x2 binning,
while software / hardware trigger modes ran at the expected ~10 fps.
The asymmetry is by design — PRECISE_FRAMERATE only paces continuous
mode, so a "bad" write only affects that path.

The legacy comment "default value is only 90% of setting value"
motivated explicitly setting MAX, but on this camera (and likely
others) the SDK's ~90%-of-MAX default is what the firmware can actually
sustain. Writing the burst MAX makes the camera fall back to a
fail-safe rate that's *slower* than the default — the opposite of the
intended effect.

Remove the put entirely. Keep the get so the strobe-time calc still
has a max_framerate_fps value for vheight (in practice vheight floors
at roi_height + 56 anyway). When the get fails, fall back to a high
value so the floor still applies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Alpaca233 and others added 3 commits May 31, 2026 18:20
Root cause for slow continuous mode, confirmed by the user's three
empirical findings:

  1. Start Live in software trigger first, then switch to continuous
     without stopping Live: ~10 fps continuous.
  2. Stop Live, switch to continuous, then start Live: drops to ~2 fps.
  3. ~18 fps continuous was briefly reached in 82d01d1 but couldn't
     be reproduced — order-dependent on camera state at Start().

Pattern: continuous-mode FPS depends on the camera's state at
StartPullModeWithCallback time, not on subsequent option flips.

The Toupcam SDK doc for OPTION_DDR_DEPTH explicitly says default is
"auto: one frame for video mode when auto-exposure is enabled, full
capacity for others." Stream starts in video mode (TRIGGER=0 default)
+ default auto-exposure on => DDR caches one frame => host
PullImage path serializes with sensor readout => slow. Stream started
in trigger mode doesn't apply the auto-exposure DDR cap, and the
allocation isn't re-evaluated on trigger flip, so that path is fast.

Fix: put_AutoExpoEnable(False) in _configure_camera, which runs BEFORE
_start_raw_camera_stream in __init__. Squid sets exposure explicitly
per channel, so disabling auto-exposure has no user-visible effect
besides unlocking full DDR buffering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
start_live() only ran the illumination-on path indirectly through
_start_triggerred_acquisition() -> timer -> trigger_acquisition(), which
only fires for software trigger (and hardware trigger with internal
timer). Continuous trigger mode skips that entire chain — the camera
just autonomously streams frames — so illumination was never turned on
when Live started in continuous mode.

stop_live() already calls turn_off_illumination() for all modes, so
only the "on" side was broken. Add a symmetric turn_on_illumination()
call in the continuous-mode branch of start_live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Alpaca233 Alpaca233 changed the title fix: restore legacy RecordingWidget Record button fix: legacy RecordingWidget + Toupcam continuous-mode FPS + Live illumination Jun 1, 2026
Resolved conflict in software/control/gui_hcs.py: master added a new
LaserEngineWidget construction block immediately before the
RecordingWidget instantiation, while this branch added the
channel_provider kwarg to RecordingWidget. Kept both: laser-engine
widget block from master plus channel_provider kwarg from this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Alpaca233 Alpaca233 merged commit a894eda into master Jun 1, 2026
3 checks passed
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