Fix closed-loop latency instrumentation and two live-mode freezes - #21
Conversation
The optical validation showed the software log understating physical latency by 2.8x (411 ms measured vs 148 ms logged) and the declared fps (30) not matching the real one (38.95). A statistical audit of latency_log.csv found it was an instrumentation artefact, not a measurement: 346/354 rows with a sub-millisecond serial round trip, an end-to-end column near-uniform over one frame interval, and a NEGATIVE correlation between the serial leg and the total. Both causes were confirmed in code and fixed, along with two freezes found while validating the result on the rig. Instrumentation - arduino.py: reset_input_buffer() ran AFTER readline(), so from the second trigger on, readline returned the PREVIOUS command's ACK, already buffered. Root cause of the 346 impossible rows. Drained before the write instead. - camera.py/arduino.py: the module-level FRAME_T0 was set by the reader thread on every cap.read(), including frames never consumed, so it pointed at a frame later than the one that produced the decision. The global is gone; the capture timestamp travels with the frame. - arduino.py: `if response == "OK"` never matched -- the firmware replies "Red LED 1 ON". Every command was logged as a nack and send_command returned False even though the LED fired. Any non-empty reply is now an ACK. - recorder.py: VideoWriter was stamped with the configured fps on a ~39 fps stream. It now takes the measured rate. - latency_logging.py: rewritten. Three files per session in the recording folder: 6_Latency_<base>.csv (schema compatible with the DRerio unit), 7_FrameLedger_<base>.csv, 8_LatencyMeta_<base>.json. The ledger exists because neither live_frame_count nor the mp4 index is a camera frame index -- frames are skipped at get_frame() and dropped at both queues. Drops are now counted instead of silent. Freezes found on the rig - The live preview ran cv2.imshow/waitKey/destroyAllWindows on the processing thread while Tk owned the main thread. Preview now renders through Tk via root.after, throttled to 20 fps and downscaled to 800 px on the worker, dropping frames rather than blocking. - Arduino.connect() set timeout=0.25 but left write_timeout at pyserial's default of None, which on Windows is WriteFile + GetOverlappedResult(bWait=True) -- an unbounded wait. One stalled write froze the analysis thread for 60 s, until the port was closed. The session log made this unambiguous: arduino.command.sent, which is emitted immediately after ser.write(), appeared at the instant of arduino.connection.closed. write_timeout is now set and SerialTimeoutException is reported as a lost trigger. - Serial I/O moved off the analysis thread entirely. send_command_async() queues (maxsize 8) and a dedicated ArduinoTxThread writes, so a stalled port costs counted, logged trigger drops instead of a frozen application. t_send/t_ack are still taken around the real write; the queue hop shows up in decision_to_send_ms. - join_threads() used unbounded joins, turning any stalled worker into a frozen app with no diagnostic. All joins are bounded at 5 s and name the offending thread. Both live loops now log exceptions instead of dying silently, and Camera.release() no longer calls cap.release() while the reader is still inside cap.read(). CAP_PROP_BUFFERSIZE and the free-running reader thread are deliberately left alone: the architectural difference from the DRerio unit is real and should be measured, not normalised away before measuring. Verified: ruff clean, 44 tests pass, including 10 new ones whose FakeSerial models the stale-buffer ordering -- three of them fail when the old ACK ordering is restored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes closed-loop latency instrumentation so logged timing reflects physical reality, and hardens live-mode execution to prevent UI/analysis freezes (moving preview rendering to Tk’s main thread and isolating serial I/O from the analysis thread).
Changes:
- Reworks latency logging to remove the stale global frame timestamp, record per-trigger legs, add a frame ledger, and write session metadata sidecars.
- Moves Arduino trigger dispatch to an async TX thread with bounded write timeouts; updates ACK handling to match real firmware replies.
- Fixes live preview freezes by rendering via Tk (
root.after) instead of HighGUI calls on a worker thread; adds stall detection and bounded thread joins.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_latency_instrumentation.py | Adds regression tests covering stale-buffer ACK ordering and frame timestamp provenance. |
| src/zebtrack/ui/gui.py | Carries capture timestamps with frames, logs drops/ledger entries, and renders live preview via Tk main thread. |
| src/zebtrack/latency_logging.py | Rewritten session logger producing trigger CSV, frame ledger CSV, and metadata JSON with measured fps + drop counts. |
| src/zebtrack/io/recorder.py | Allows stamping video container fps with measured camera rate and logs the chosen fps. |
| src/zebtrack/io/camera.py | Adds per-frame capture timestamp/sequence tracking, measured fps reporting, and avoids cross-thread capture property queries. |
| src/zebtrack/io/arduino.py | Adds async TX dispatch, enforces write_timeout, drains input buffer before write, and fixes ACK acceptance logic. |
| src/zebtrack/core/detector.py | Publishes decision metadata/timing for latency instrumentation without changing the public return tuple. |
| src/zebtrack/core/controller.py | Starts/stops latency sessions with recording lifecycle, stamps container fps with measured fps, and bounds thread joins. |
Suppressed comments (2)
src/zebtrack/latency_logging.py:193
- log_video_frame also reads _SESSION outside _LOCK, which can race with stop_session in the same way as note_capture. Acquire _LOCK before checking _SESSION so stop_session can reliably quiesce logging before closing files.
s = _SESSION
if s is None:
return None
try:
with _LOCK:
src/zebtrack/latency_logging.py:227
- log_trigger reads _SESSION before acquiring _LOCK. If stop_session runs concurrently, this can capture a stale session handle and still write to it even after _SESSION is cleared. Grab _LOCK before reading _SESSION to make session teardown race-free.
s = _SESSION
if s is None:
return
try:
with _LOCK:
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| def note_capture(t_capture): | ||
| """Record a consumed frame's capture timestamp (for the fps estimate).""" | ||
| s = _SESSION | ||
| if s is None: | ||
| return | ||
| with _LOCK: | ||
| if s.t_first_capture is None: | ||
| s.t_first_capture = t_capture | ||
| s.t_last_capture = t_capture | ||
| s.n_captures += 1 | ||
|
|
||
|
|
||
| def note_drop(kind): | ||
| """Count a dropped frame. kind is 'video' or 'analysis'.""" | ||
| s = _SESSION | ||
| if s is None: | ||
| return | ||
| with _LOCK: | ||
| if kind == "video": | ||
| s.video_drops += 1 | ||
| else: | ||
| s.analysis_drops += 1 | ||
|
|
| except serial.SerialTimeoutException: | ||
| # The write did not complete within write_timeout. Report the | ||
| # trigger as lost rather than waiting on the driver. | ||
| log.error("arduino.command.write_timeout", command=command_num) | ||
| return False |
| write_fps = float(fps) if fps else float(settings.video_processing.fps) | ||
| self.video_fps = write_fps | ||
| log_context.info( | ||
| "recorder.video_fps", | ||
| fps_used=write_fps, | ||
| fps_configured=settings.video_processing.fps, | ||
| measured=bool(fps), | ||
| ) |
| f2a = (t_ack - frame_t0) * 1000.0 if frame_t0 else "" | ||
| c2d = (t_decision - frame_t0) * 1000.0 if (t_decision and frame_t0) else "" | ||
| d2s = (t_send - t_decision) * 1000.0 if t_decision else "" |
Follow-up to the Copilot review on #21. Three of the four points were real; the fourth is addressed differently and the reasoning is recorded here. Session teardown race (latency_logging.py). note_capture, note_drop, log_video_frame and log_trigger all read _SESSION before taking _LOCK, so a call that had already captured the handle could write to it after stop_session cleared the global and closed the files. All four now read _SESSION under the lock, and stop_session holds the lock across the whole teardown -- snapshot, sidecar write and close -- so a logging call either completes against a live session or sees None and skips. The new concurrency test shows the impact was not theoretical: against the old code the ledger holds 13779 rows while the metadata claims 13777. Unlogged write timeouts (arduino.py, latency_logging.py). A SerialTimeoutException returned False without writing a row, so a trigger the firmware may never have acted on was indistinguishable from one never attempted -- in a latency study that is a silently dropped data point. log_trigger now accepts t_ack=None and emits the row with the acknowledgement columns empty and ack_ok false. Triggers dropped because the TX queue is full are counted as trigger_queue_drops in the metadata, closing the same gap on the path added by the async dispatcher. 0.0 as a legal timestamp (latency_logging.py). perf_counter's epoch is arbitrary, so 0.0 is a reading rather than an absence, but frame_t0 and t_decision were tested for truthiness and would have blanked the latency columns for it. All timestamp handling is now explicit about None, via a single _fmt helper. Declined as proposed (recorder.py). The review asked for a bare None check on fps. That would let fps=0.0 through to VideoWriter and produce a container no player can time, which is worse than the truthiness bug it replaces. Written instead as `fps is not None and float(fps) > 0`: explicit about None, still rejecting values that cannot be stamped. Verified: ruff clean, 47 tests pass. The three new tests fail against the pre-review code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — three of the four were real. Addressed in d906120. Session teardown race ( Worth recording that this was not theoretical. The new concurrency test, run against the pre-fix code, produces a ledger with 13779 rows while the metadata claims 13777 — the sidecar counters and the CSVs disagreeing is exactly the kind of quiet corruption this module exists to prevent. Unlogged write timeouts. Agreed, and the contract violation was worse than stated: a trigger the firmware may never have acted on was indistinguishable from one never attempted. In a latency study that is a silently dropped data point. 0.0 as a legal timestamp. Correct, and it applied to the CSV formatting as well as the arithmetic.
Three regression tests added, all verified to fail against the pre-review code. Suite is at 47 passing, ruff clean. |
Why
Optical validation of the DRerio sessions showed the software log understating physical latency by 2.8x (411 ms real vs 148 ms logged), and the declared fps (30) not matching the real one (38.95). A statistical audit of
latency_log.csvshowed it was an instrumentation artefact, not a measurement:Both causes were confirmed in code. While validating the fix on the rig, two separate freezes surfaced and are fixed here too.
Instrumentation fixes
reset_input_buffer()ran afterreadline(), so from the second trigger on,readlinereturned the previous command's ACK, already sitting in the buffer. Root cause of the 346 impossible rows. Now drained before the write.FRAME_T0was set by the reader thread on everycap.read(), including frames never consumed —get_frame()returns only the most recent. It pointed at a frame later than the one that produced the decision. Global removed; the capture timestamp travels with the frame.if response == "OK"never matched — the firmware replies"Red LED 1 ON". Every command was logged as a nack andsend_commandreturnedFalsedespite the LED firing. Any non-empty reply is now an ACK.VideoWriterwas stamped with the configured fps on a ~39 fps stream. It now takes the measured rate.live_frame_countnor the mp4 index is a camera frame index — frames are skipped atget_frame()and dropped again at both queues. Added a frame ledger; drops are counted instead of silent.latency_logging.pyis rewritten and writes three files per session into the recording folder:6_Latency_<base>.csv(schema compatible with the DRerio unit),7_FrameLedger_<base>.csv,8_LatencyMeta_<base>.json.Freezes found on the rig
1. Live preview via HighGUI on a worker thread.
cv2.imshow/waitKey/destroyAllWindowsran on the processing thread while Tk owned the main thread. HighGUI drives a Win32 message loop belonging to the calling thread; with Tk running its own (modal dialogs included), the two can attach andwaitKeynever returns. Preview now renders through Tk viaroot.after, throttled to 20 fps and downscaled to 800 px on the worker, dropping frames rather than blocking.2. Unbounded serial write.
connect()settimeout=0.25but leftwrite_timeoutat pyserial's default ofNone— on Windows that isWriteFile+GetOverlappedResult(bWait=True), a wait with no bound. One stalled write froze the analysis thread for 60 s, until the port was closed. The session log made it unambiguous:write_timeoutis now set andSerialTimeoutExceptionis reported as a lost trigger. Beyond that, serial I/O moved off the analysis thread entirely:send_command_async()queues (maxsize 8) and a dedicatedArduinoTxThreadwrites. A stalled port now costs counted, logged trigger drops instead of a frozen application.t_send/t_ackare still taken around the real write, so the latency columns keep their meaning; the queue hop shows up indecision_to_send_ms, where it is visible rather than hidden.Hardening so a stall can never freeze everything again: all joins in
join_threads()bounded at 5 s and naming the offending thread; both live loops log exceptions instead of dying silently;Camera.release()no longer callscap.release()while the reader is still insidecap.read();get_properties()no longer queries the capture backend from another thread; acamera.read.stalledwarning fires on reads over 2 s.Deliberately not changed
CAP_PROP_BUFFERSIZEand the free-running reader thread. Prediction: this unit will measure much lower camera delay than the DRerio one (which measured 102.6 ms ≈ exactly 4.0 frame intervals, typical of a DirectShow buffer) because its reader drains the buffer while DRerio's sleeps between reads. That is a real architectural difference between two published systems — it should be measured, not normalised away beforehand.Verification
ruff check src testscleanFakeSerialmodels the stale-buffer ordering. Three of them fail when the old ACK ordering is restored — verified regression coverage.close()returning in 1 s.Not verified: no live hardware run since the last fix. The freeze diagnosis comes from the session log plus the code, not from a reproduction on the rig.
Follow-up, not in this PR
cv2.VideoCapture(1)has taken 35 seconds to open on two consecutive sessions. That is not normal and it is the same device that stalled the serial port afterwards — worth suspecting the USB hub or the camera driver. The newcamera.read.stalledwarning will show ifcap.read()starts stalling too.