Files
orisys-app/shared/tests/test_worker_frame_rate.py
T
ykcheungab eb1dfbce8a fix(studio): 回放视图加一个无条件「停止」(Esc 同效),并让「文件一帧都不出」直接报错而不是一直等
- What: `ToolbarWidget` 新增 `set_reset_role("reset"/"stop")`,回放视图把轨道第二个按钮变成「停止」;
  `StudioMainWindow._stop_replay_activity()` 一次停掉回放(play_timer)与「从视频重算」通道(worker),
  并清掉三维面板上的重算画面,状态栏说明"已停止了什么";`_on_reset`/`_on_stop_shortcut`(Esc) 按角色路由;
  `_start_playback`/`_pause_playback` 结束时调用 `_update_run_enabled()`,让停止按钮在有东西可停时才可用;
  两个采集 worker 新增 `FILE_FIRST_FRAME_TIMEOUT_S=10s`:文件源一帧都没出来就报错结束会话
  (原先 `saw_frame` 为假时 EOF 分支永不执行,循环空转到天荒地老、窗口一直停在"连接中")。
- Why: 用户报告"视频播放完之后无法再次按回放,卡住了",并建议加手动停止。复现得到两种状态:
  ① 普通回放播到末尾后再按回放**是可以**重新从头播的(探针实测 0.967→0→0.371,计时器在跑),
  不是这条路径的问题;② 「从视频重算」通道跑文件时,工作线程可能既不出帧也不结束
  (实测:1.5 s 的合成视频跑 25 s,packets=0 且 is_running=True),于是界面永远停在运行/连接状态,
  操作者看到"卡住"。所以既给一个无论什么状态都能按的停止,也让"读不出帧"变成一条明确的错误。
- Verification: 新增/更新用例:`test_worker_frame_rate.py` 的"文件一帧都不出→error+消息"(缩短时限跑,
  真实 worker);`test_ui_smoke.py` 三项:回放视图的停止按钮(播放中可用、按下停回放、重算中停下重算、
  无事可停时说明)、Esc 同样能停、实时视图仍是「重置」;apps/studio 全量通过;shared 全量通过;
  ui_check 583 项 0 失败(新增回放停止的 5 项检查,并给"场景已加载却仍在显示消息"那条加了诊断细节,
  它此前偶发失败过一次、这次跑通)。
- Not in this change: 普通回放"播到末尾→再按回放"的路径(实测正常,未改);让回放直接播放"非记录"的视频
  (仍走「从视频重算」);`_recompute_running` 与 worker 真实状态的进一步合并(现在停止按钮同时看两者)。
2026-09-14 17:40:49 +08:00

425 lines
16 KiB
Python

"""Which frame the acquisition loop counts, and at what rate.
``CaptureSource.read_latest`` returns the newest frame from its background reader
and, if that reader has not produced another one yet, the *same* frame again. The
loop must not treat the repeat as a new sample: doing so duplicated every
measurement (a 30 fps device recorded 63 rows per second) and the rail read the
loop's own polling rate as the device's frame rate.
"""
from __future__ import annotations
import sys
import threading
import time
import types
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[2]
BOX_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "box" / "sensor.json"
#: The fake device's own frame period: 30 frames per second, the rate the studio
#: sensor configs declare.
FRAME_PERIOD_S = 1.0 / 30.0
#: How many times the SDK hands the same frame back before the next one arrives.
REPEATS_PER_FRAME = 3
class _Status:
"""Stands in for ``orisys.capture.config.CaptureStatus``."""
def __init__(self) -> None:
self.last_seq = 0
self.dropped_frames = 0
#: What the driver reported back for the negotiated stream.
self.width = 1920
self.height = 1080
self.fps = 30.0
self.fourcc = "MJPG"
self.backend_name = "MSMF"
class _CaptureConfig:
"""The requested stream, as ``CaptureConfig`` carries it."""
def __init__(self) -> None:
self.width = 1920
self.height = 1080
self.fps = 30.0
self.fourcc = "MJPG"
self.source = 0
class _CaptureSource:
"""A background reader that publishes a new frame on its own thread.
The real one is a thread too: the sequence advances whether or not the
consumer asks for a frame, which is exactly why an unchanged sequence means
"no new frame" rather than "the consumer has not called yet". It also counts
a drop whenever it replaces a frame nobody took, like
``orisys.capture.source.CaptureSource._background_loop`` does.
"""
def __init__(self, period_s: float = FRAME_PERIOD_S) -> None:
self.status = _Status()
self.config = _CaptureConfig()
self.running = True
self._period = float(period_s)
self._stop = threading.Event()
self._taken_seq = 0
self._thread = threading.Thread(target=self._publish, daemon=True)
self._thread.start()
def _publish(self) -> None:
while not self._stop.is_set():
if self._stop.wait(timeout=self._period):
return
# Replacing a frame that was never consumed is a drop on our side.
if self.status.last_seq > self._taken_seq:
self.status.dropped_frames += 1
self.status.last_seq += 1
def take(self) -> None:
self._taken_seq = self.status.last_seq
def close(self) -> None:
self.running = False
self._stop.set()
self._thread.join(timeout=1.0)
#: Per-frame processing cost the fake device charges, so a test can make the
#: pipeline slower than the camera (the real box chain costs ~10 ms at 480p).
PROCESS_COST_S = 0.0
class _Info:
"""The channel names the worker reads off ``sensor.info``."""
FNORMAL = "FNORMAL"
FSHEARX = "FSHEARX"
FSHEARY = "FSHEARY"
FNORMAL_RAW = "FNORMAL_RAW"
FSHEARX_RAW = "FSHEARX_RAW"
FSHEARY_RAW = "FSHEARY_RAW"
#: Every sensor the fake SDK handed out, so a test can read its frame counter.
CREATED: list = []
class _Sensor:
def __init__(self, *args, **kwargs) -> None:
import numpy as np
self.configured: dict = {}
self.connected = True
self.capture_source = _CaptureSource()
self.frame = np.zeros((8, 8, 3), dtype=np.uint8)
self.info = _Info()
self.force_calibrated = True
CREATED.append(self)
def configure(self, **kwargs):
self.configured = kwargs
def read_info(self, *names):
"""The SDK's readout: one value per requested channel, in order."""
values = {
_Info.FNORMAL: 1.5,
_Info.FSHEARX: 0.25,
_Info.FSHEARY: -0.25,
_Info.FNORMAL_RAW: 15.0,
_Info.FSHEARX_RAW: 2.5,
_Info.FSHEARY_RAW: -2.5,
}
return tuple(values.get(str(name), 0.0) for name in names)
def get_img(self):
# ``read_latest``: hand back the newest frame immediately, the same one
# again when nothing new has arrived. A test can make this cost time, so
# the loop can be put under a pipeline slower than the camera.
if PROCESS_COST_S > 0:
time.sleep(PROCESS_COST_S)
self.capture_source.take()
return self.frame
def disconnect(self):
self.connected = False
self.capture_source.close()
def _install() -> object:
saved = {name: sys.modules.get(name) for name in ("orisys", "orisys.finger")}
module = types.ModuleType("orisys")
module.__version__ = "fake"
module.Sensor = _Sensor # type: ignore[attr-defined]
finger_module = types.ModuleType("orisys.finger")
finger_module.FingerRuntime = _Sensor # type: ignore[attr-defined]
module.finger = finger_module # type: ignore[attr-defined]
sys.modules["orisys"] = module
sys.modules["orisys.finger"] = finger_module
return saved
def _restore(saved) -> None:
for name, original in saved.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
def _collect(duration_s: float = 1.2):
"""Run the real worker loop against the fake device.
Returns ``(packets, delivered)`` — what the loop recorded, and how many frames
the fake capture source actually delivered in the same period.
"""
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
CREATED.clear()
saved = _install()
worker = SdkAcquisitionWorker()
packets: list = []
lock = threading.Lock()
def sink(packet) -> None:
with lock:
packets.append(packet)
try:
worker.add_sink(sink)
# Camera id 0, not "0": a string source is a file, and a file runs on the
# video's timeline rather than the wall clock (see the file test below).
worker.start(0, config_ref=BOX_CONFIG)
time.sleep(duration_s)
worker.stop(wait=True)
delivered = CREATED[-1].capture_source.status.last_seq if CREATED else 0
finally:
_restore(saved)
return packets, int(delivered)
def _rate(packets) -> float:
span = float(packets[-1].mono_t) - float(packets[0].mono_t)
return (len(packets) - 1) / span if span > 0 else 0.0
def test_the_same_frame_is_not_counted_twice():
"""One packet per delivered frame, whatever the loop's polling rate is."""
packets, delivered = _collect()
assert packets, "the loop emitted nothing at all"
assert delivered > 10, f"the fake device only delivered {delivered} frames"
assert len(packets) <= delivered * 1.5, (
f"{len(packets)} packets for {delivered} delivered frames: the repeats "
"are being recorded as samples"
)
assert len(packets) >= delivered * 0.7, (
f"only {len(packets)} packets for {delivered} delivered frames"
)
def test_a_30_fps_device_is_consumed_at_30_fps():
"""The loop must not add a timer tick of latency per frame.
``time.sleep(0.002)`` costs a full 15.6 ms on Windows, so a loop that sleeps
before looking for the next frame spends ~47 ms per frame on a 30 fps device
and records 16.7 fps with half the frames dropped.
"""
packets, delivered = _collect(duration_s=1.5)
assert packets, "the loop emitted nothing at all"
rate = _rate(packets)
assert 27.0 <= rate <= 31.5, (
f"a 30 fps device was consumed at {rate:.1f} fps "
f"({len(packets)} packets for {delivered} delivered frames)"
)
def test_the_reported_rate_is_the_delivered_rate():
"""``packet.fps`` describes the frames, not how often the loop asked."""
packets, delivered = _collect()
reported = [float(packet.fps) for packet in packets if packet.fps]
assert reported, "no packet carried a frame rate"
measured = _rate(packets)
settled = reported[-5:]
assert all(value > 10.0 for value in settled), settled
assert abs(settled[-1] - measured) < 0.25 * measured, (settled[-1], measured)
def test_the_camera_s_own_rate_is_reported_next_to_ours(monkeypatch):
"""A low frame rate must be attributable, not just visible.
With a pipeline that fits the frame period the two rates agree. With one that
does not, the *camera's* rate stays at 30 while ours falls and the source
counts the frames nobody took — which is the whole difference between "the
camera is slow" and "we are slow".
"""
packets, _ = _collect(duration_s=1.5)
assert packets, "the loop emitted nothing at all"
camera = [float(packet.capture_fps) for packet in packets if packet.capture_fps]
assert camera, "no packet carried the capture source's own rate"
assert 28.0 <= camera[-1] <= 31.0, f"the fake camera publishes at 30 fps, got {camera[-1]}"
assert packets[-1].capture_dropped == 0, (
f"the loop dropped {packets[-1].capture_dropped} frames while keeping up"
)
# Now make the pipeline three times slower than the camera's own period.
monkeypatch.setattr(sys.modules[__name__], "PROCESS_COST_S", 0.06)
slow, _ = _collect(duration_s=2.0)
try:
assert slow, "the loop emitted nothing at all"
ours = _rate(slow)
assert ours < 22.0, f"a 60 ms pipeline still kept up at {ours:.1f} fps"
assert 28.0 <= float(slow[-1].capture_fps) <= 31.0, (
"the camera's rate was reported as slow too: "
f"{slow[-1].capture_fps}"
)
assert slow[-1].capture_dropped > 5, (
"the loop fell behind without recording a single dropped frame: "
f"{slow[-1].capture_dropped}"
)
finally:
monkeypatch.setattr(sys.modules[__name__], "PROCESS_COST_S", 0.0)
def test_a_low_camera_rate_is_recorded_as_a_negotiation(monkeypatch):
"""A camera that cannot do the configured rate must be visible as such.
The pipeline cannot turn 16.7 fps into 30, so the only honest thing left is to
record what was asked for and what the driver gave, and to have a one-line
description ready for the operator.
"""
from orisys_app_shared.acquisition.sdk_worker import (
SdkAcquisitionWorker,
short_capture_fallacy,
)
CREATED.clear()
saved = _install()
worker = SdkAcquisitionWorker()
try:
worker.start(0, config_ref=BOX_CONFIG)
deadline = time.perf_counter() + 2.0
while time.perf_counter() < deadline and not CREATED:
time.sleep(0.01)
assert CREATED, "the fake sensor was never constructed"
source = CREATED[-1].capture_source
source.status.fps = 16.67 # what the driver actually negotiated
status = worker.capture_status()
assert status["requested"]["fps"] == 30.0
assert status["requested"]["width"] == 1920
assert status["negotiated"]["fps"] == 16.67, status
assert status["negotiated"]["fourcc"] == "MJPG"
assert status["negotiated"]["backend"] == "MSMF"
# The *measured* rate is the evidence: DSHOW echoes the requested fps back
# as the "negotiated" one, so trusting that number would hide a camera
# that advertises 30 and delivers 15 (measured on this project's camera).
detail = short_capture_fallacy(status, measured_fps=16.67)
assert "16.67" in detail and "30" in detail and "1920x1080" in detail, detail
assert short_capture_fallacy(status, measured_fps=29.5) == ""
# A driver reporting 30 while delivering half of it is the case that
# matters, and it must not slip through.
source.status.fps = 30.0
echoing = worker.capture_status()
assert short_capture_fallacy(echoing, measured_fps=15.0) != ""
# No measured rate yet: nothing to say.
assert short_capture_fallacy(echoing) == ""
# ...and the session's own measurement is part of what gets recorded, so
# a unit can be judged by the rate it really delivered.
deadline = time.perf_counter() + 2.0
while time.perf_counter() < deadline and not worker.capture_status().get("measured_fps"):
time.sleep(0.05)
measured = float(worker.capture_status().get("measured_fps") or 0.0)
assert 20.0 <= measured <= 35.0, (
f"the fake camera publishes at ~30 fps, recorded {measured}"
)
finally:
worker.stop(wait=True)
_restore(saved)
def test_a_file_source_runs_on_the_video_s_own_timeline():
"""A pass over a video is stamped with the video's time, not the wall clock."""
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
CREATED.clear()
saved = _install()
worker = SdkAcquisitionWorker()
packets: list = []
lock = threading.Lock()
def sink(packet) -> None:
with lock:
packets.append(packet)
try:
worker.add_sink(sink)
worker.start("recorded.mp4", config_ref=BOX_CONFIG)
time.sleep(0.4)
worker.stop(wait=True)
finally:
_restore(saved)
assert len(packets) > 3, f"the pass produced {len(packets)} packets"
steps = [
round(packets[index + 1].mono_t - packets[index].mono_t, 6)
for index in range(min(5, len(packets) - 1))
]
assert packets[0].mono_t == 0.0, packets[0].mono_t
assert all(abs(step - 1.0 / 30.0) < 1e-6 for step in steps), steps
assert packets[-1].mono_t < 0.4 + 1.0, (
f"the pass ran on the wall clock: {packets[-1].mono_t} s of video in "
f"{0.4} s of real time"
)
def test_a_file_that_yields_no_frames_ends_the_session_with_an_error(monkeypatch):
"""A video this build cannot decode must report it, not wait forever.
The end-of-file branch needs at least one frame to have arrived (``saw_frame``),
so a file that never produces one used to leave the loop spinning: no frames, no
error, and the window in "connecting" until somebody pressed Stop. The shipping
deadline is ten seconds; it is shortened here, because the point is the behaviour
rather than the wait.
"""
import orisys_app_shared.acquisition.sdk_worker as worker_module
monkeypatch.setattr(worker_module, "FILE_FIRST_FRAME_TIMEOUT_S", 0.3)
# A sensor that never produces a frame: what an unreadable codec looks like.
monkeypatch.setattr(_Sensor, "get_img", lambda self: None)
CREATED.clear()
saved = _install()
errors: list = []
packets: list = []
worker = worker_module.SdkAcquisitionWorker(on_error=errors.append)
try:
worker.add_sink(packets.append)
worker.start("unreadable.mp4", config_ref=BOX_CONFIG)
deadline = time.perf_counter() + 10.0
while time.perf_counter() < deadline and worker.is_running:
time.sleep(0.05)
assert not worker.is_running, (
"the worker is still running on a file that produced no frames"
)
assert worker.consume_end_reason() == "error", worker.consume_end_reason()
assert packets == []
assert errors and "no frames" in errors[0], errors
finally:
worker.stop(wait=True)
_restore(saved)
if __name__ == "__main__": # pragma: no cover - manual runner
failures = 0
for name, function in sorted(globals().items()):
if name.startswith("test_") and callable(function):
try:
function()
print(f" ok {name}")
except Exception as exc: # noqa: BLE001
failures += 1
print(f" FAIL {name}: {type(exc).__name__}: {exc}")
print(f"failures: {failures}")
raise SystemExit(1 if failures else 0)