界面显示每帧耗时与摄像头自身帧率,让低帧率可归因 - What: SdkPacket 新增 capture_fps(采集源自报的发布速率,与 fps 同一窗口)与 capture_dropped(SDK 后台读取线程累计的"发布了但没人取走"的帧数);sdk_worker 的循环记录 (t0, 已发布序号) 并在每帧算出摄像头自身的发布速率,同时读 dropped_frames;新增 _capture_source()/_capture_dropped() 两个取值助手(原 _capture_sequence 里的取值逻辑抽出来复用)。app_window 在 视图▸显示帧率 打开时把这一段一起显示在轨道摘要上:16.7 FPS · 12 ms · 摄像头 30.0 (-13)——速率是我们消费的、耗时是每帧全部开销、摄像头速率是采集源自己发布的;两个速率不一致时后面的 (-N) 就是"摄像头有、我们没接住"的帧数。新增 locale 键 status.rail_camera_fps(中英)。tests/test_worker_frame_rate.py:假设备的采集源现在也像 orisys.capture.source 一样统计"发布时上一帧还没被取走"的丢帧,并支持给 get_img 加一个可配置的每帧耗时(PROCESS_COST_S);新增 test_the_camera_s_own_rate_is_reported_next_to_ours:循环跟得上时摄像头速率≈30、丢帧 0;把流水线放到 60 ms/帧后我们的速率掉到 22 以下、**摄像头速率仍报 30**、丢帧 >5。 - Why: 用户报"上方显示 16.7 fps 而不是实际 30",并且给出了关键数据:16.7 fps 但每帧只耗 12 ms。12 ms 的工作量塞进 60 ms 的周期里,说明循环大部分时间在等——而"等"有两种完全不同的原因:(a) 摄像头/驱动只发布 16.7(USB 带宽、MJPG/YUYV、格式协商),(b) 摄像头发布 30 而我们只取到一半(节拍与轮询问题)。原来的读数只有"我们消费了多少",两种情形长得一模一样,无法判断,所以这不是显示 bug 而是**读数缺一半**。SDK 的 status.last_seq 与 dropped_frames 正好提供另一半(last_seq 每发布一帧就自增,与消费者无关;dropped_frames 在发布时若上一帧仍在缓存里就 +1,即消费者的亏欠),因此把它读出来即可判定。 - Verification: shared 全量 160 passed(新增 2 项,含上面的归因测试);studio 90 passed;ui_check 554 项检查 0 失败;locale_check 287 字面键全部命中、中英键集一致。过程中自查出一个我自己引入的问题:最初把 (t0, 序号) 的记录与按时间裁剪放在**每次轮询**里,而等待期间该分支每秒会进几万次,列表推导把 GIL 抢光,假摄像头的发布速率从 30 掉到 8(test_the_same_frame_is_not_counted_twice 立刻变红)——改为"仅在该序号发生变化时记录",即为每发布帧 O(1) 而非每轮询 O(n)。 - Not in this change: 未改节拍/等待逻辑(假设备上 30 fps 与 60 ms 慢流水线两种情形的消费速率都符合预期,未发现节拍 bug);frames.csv 仍未记录 process_ms/capture_fps(下次改记录格式时一并加)。

This commit is contained in:
ykcheungab
2026-09-14 12:07:28 +08:00
parent 5ef4d955fb
commit a1314a02cb
6 changed files with 137 additions and 5 deletions
@@ -41,6 +41,12 @@ class SdkPacket:
timestamp: float
fps: float
process_ms: float
#: The rate the *capture source* published frames at, over the same window as
#: ``fps``. ``fps`` is what this loop consumed; when the two disagree, the
#: difference is frames the camera had and the loop did not take.
capture_fps: float = 0.0
#: How many published frames were replaced before anyone consumed them.
capture_dropped: int = 0
normal_force: float = 0.0
shear_x: float = 0.0
shear_y: float = 0.0
@@ -123,9 +123,7 @@ def _capture_sequence(sensor) -> Optional[int]:
every call decodes the next frame, so its counter says nothing about repeats
and is ignored (``None``).
"""
source = getattr(sensor, "capture_source", None)
if source is None:
source = getattr(sensor, "_capture_source", None)
source = _capture_source(sensor)
if source is None or not bool(getattr(source, "running", False)):
return None
status = getattr(source, "status", None)
@@ -135,6 +133,28 @@ def _capture_sequence(sensor) -> Optional[int]:
return None
def _capture_dropped(sensor) -> Optional[int]:
"""Frames the capture source published that nobody took in time.
The SDK's background reader counts one of these every time it replaces a
frame that was never consumed, so this is the consumer's shortfall — the
number that says "the camera had it and we did not take it".
"""
source = _capture_source(sensor)
status = getattr(source, "status", None) if source is not None else None
try:
return int(getattr(status, "dropped_frames"))
except (TypeError, ValueError):
return None
def _capture_source(sensor):
source = getattr(sensor, "capture_source", None)
if source is None:
source = getattr(sensor, "_capture_source", None)
return source
def process_chain(sensor) -> tuple[Optional[np.ndarray], str]:
"""Run the live processing chain for one already-read frame.
@@ -714,6 +734,13 @@ class SdkAcquisitionWorker(RecordSinkMixin):
#: When that frame arrived, and how far apart this device's frames are
#: meant to be: the wait below never steps over the next frame's arrival.
last_frame_at = 0.0
#: ``(collection time, published sequence)`` over the same window as
#: ``frame_times``. The sequence advances whenever the *capture source*
#: publishes, whether or not this loop takes the frame, so comparing the
#: two rates says whether a low frame rate is the camera's or ours.
source_times: list[tuple[float, int]] = []
source_dropped = 0
last_source_seq: Optional[int] = None
frame_period = 1.0 / max(1.0, float(mode.fps or 30.0))
# Per-session monotonic frame index: the join key shared by the video,
# the CSV and the calibration records.
@@ -748,6 +775,17 @@ class SdkAcquisitionWorker(RecordSinkMixin):
# without a background reader (a video file) decodes a new frame
# per call, so it has no repeat marker and is left alone.
delivered = _capture_sequence(sensor)
if delivered is not None and delivered != last_source_seq:
# Bookkeeping per *published* frame, not per poll: this branch
# is entered many times while waiting, and a list comprehension
# on every one of them starves the capture thread of the GIL
# (measured: the fake camera's own 30 fps dropped to 8).
last_source_seq = delivered
source_times.append((t0, delivered))
source_cutoff = t0 - FPS_WINDOW_S
source_times = [
item for item in source_times if item[0] >= source_cutoff
]
if delivered is not None and delivered == last_delivered:
# Wait for the next frame, but never *past* the moment it is
# due: sleeping a whole timer tick before looking again is how
@@ -820,6 +858,17 @@ class SdkAcquisitionWorker(RecordSinkMixin):
# number that says whether the loop can still keep up with the
# device, which the frame rate alone cannot.
t_done = time.perf_counter()
# What the capture source published over the window, so a rate
# below the camera's own can be attributed instead of guessed.
capture_fps = 0.0
if len(source_times) > 1:
source_span = source_times[-1][0] - source_times[0][0]
published = source_times[-1][1] - source_times[0][1]
if source_span > 0.0:
capture_fps = max(0.0, published / source_span)
taken = _capture_dropped(sensor)
if taken is not None:
source_dropped = taken
invalid_reason = forces.get("invalid_reason") or compute_error
raw_frame = _ensure_bgr(raw)
# The frame the chain above actually consumed, kept as the
@@ -842,6 +891,8 @@ class SdkAcquisitionWorker(RecordSinkMixin):
),
fps=fps,
process_ms=(t_done - t0) * 1000.0,
capture_fps=capture_fps,
capture_dropped=source_dropped,
normal_force=forces["normal_force"],
shear_x=forces["shear_x"],
shear_y=forces["shear_y"],