采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - What: 1) 选区统计不再挤在曲线下方(中文长句会顶出面板右边界)——框选后三个数值卡的大号数字变成该窗口的峰值、下方小字为该窗口的均值·σ(chart.selection_detail),峰值语义放在卡片 tooltip;曲线下方只保留「选区 4.00s / 121 点」(完整一行留在它的 tooltip 里)。「全程总览」不算选区(那是默认适配),此时卡片回到"游标处样本";切换单位/换数据源/返回最新都会正确切回。2) 上方的 63.0 FPS 是采集循环自己的轮询速率,根因是同一帧被反复当成新样本:CaptureSource.read_latest 返回后台线程的最新帧,没有新帧时把同一帧再给一次,循环未做判断,于是 30 fps 的设备录出约 63 行/秒(frame_id 还各不相同)。新增 _capture_sequence():读 capture_source.status.last_seq,计数未变即"还没有新帧",睡 2 ms 重试、不进处理链、不发 packet;只有带后台读取线程的实时相机有该标记,视频文件是同步解码、每次都取下一帧,因此忽略(行为不变)。packet.fps 改为速率 (窗口帧数-1)/(首末到达差),窗口 1 s——按"1 秒内到达几帧"数数会系统性偏大。容器速率仍取传感器配置值,与此层无关。3) 底部状态栏只留一次性通知:删除右下角常驻「配置 sensor.json」(顶栏摘要按钮已写明),删除与顶栏重复的 status.run_started(运行中(0))与 status.connecting(正在连接…);QStatusBar 换成 _NoticeBar——Qt 的 showMessage(text) 默认 msecs=0 表示永不消失,这正是那句「运行中(0)」一直挂到底部的原因,子类统一补 6 秒超时;status.units_raw_fallback 之前每帧都发(等于永久占住通知行),改为每次运行只提示一次。附带修复:英文下表头模式名被截断(长句移入 tooltip,标签只留模式名 chart.mode_*_tip);数据源切换后曲线下方的选区文案会残留,改为在 _on_source_changed 里清零。 - Why: 用户评审 3 点——选区统计显示不全、顶部 fps 应受硬件限制最大 30、右下角配置文件名与左下角「运行中(0)」和顶栏信息重复。第 2 点查出根因是重复采样(数据缺陷,不只是显示问题),修掉后设备速率与记录行数才真正一致。 - Verification: ui_check.py 546 项 0 失败(新增:选区统计在卡片、卡片小字不裁切、状态栏无常驻标签且消息过期、顶栏 FPS 即 packet 交付速率、表头模式名中英不裁切);test_ui_smoke.py 71 项 0 失败(新增选区读数与通知行过期两例);drive_shared.py 148 项 0 失败(新增 shared/tests/test_worker_frame_rate.py:同一帧返回 3 次时 packet 数≈交付帧数,去掉判据为 11317:39,且 fps 与交付速率一致);locale_studio.py 267 键齐全、zh==en、prefs 对齐;reproduce.py 退出 0;ui_snapshot.py 新增 --select A,B,六张复核图(选区中英、实时、录制中、回放、标定 1280x720)。 - Not in this change: 录制文件格式与 CSV 列序不变;去重只影响"哪些帧算一帧",视频文件源行为不变;曲线下方不再显示三通道峰/均/σ(数值在卡片上,完整一行在 tooltip 里)。

This commit is contained in:
ykcheungab
2026-09-13 21:19:23 +08:00
parent 824544fc7a
commit e6a1e77a5b
13 changed files with 783 additions and 66 deletions
+206
View File
@@ -0,0 +1,206 @@
"""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: ~100 frames per second.
FRAME_PERIOD_S = 0.01
#: 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
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".
"""
def __init__(self, period_s: float = FRAME_PERIOD_S) -> None:
self.status = _Status()
self.running = True
self._period = float(period_s)
self._stop = threading.Event()
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
self.status.last_seq += 1
def close(self) -> None:
self.running = False
self._stop.set()
self._thread.join(timeout=1.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.
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)
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_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)
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)