采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - 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:
@@ -910,15 +910,21 @@ def main() -> int:
|
||||
not rail_squeezed(window),
|
||||
f"{theme}: the status rail squeezes {rail_squeezed(window)}",
|
||||
)
|
||||
check(
|
||||
"sensor.json" in window.device_label.text(),
|
||||
f"{theme}: the status bar does not name the config file: "
|
||||
f"{window.device_label.text()!r}",
|
||||
)
|
||||
check(
|
||||
not hasattr(window.toolbar, "source_combo"),
|
||||
f"{theme}: the source mode is still a combobox",
|
||||
)
|
||||
# The status bar is a notice line, not a second state line: the config
|
||||
# file and the phase are already on the rail, and a message must expire.
|
||||
check(
|
||||
not hasattr(window, "device_label"),
|
||||
f"{theme}: the status bar still carries a permanent device line",
|
||||
)
|
||||
window.statusBar().showMessage("notice")
|
||||
check(
|
||||
window.statusBar().currentMessage() == "notice",
|
||||
f"{theme}: the status bar does not show a message at all",
|
||||
)
|
||||
|
||||
# The sensor dialog owns the pickers and the calibration facts.
|
||||
window._open_sensor_dialog()
|
||||
@@ -1617,6 +1623,29 @@ def main() -> int:
|
||||
f"the arrow footer does not say what is magnified: "
|
||||
f"{header_window.arrow_panel.scale_label.text()!r}",
|
||||
)
|
||||
# The header's mode label shares its row with three icon buttons and the unit
|
||||
# chip: the long form of the mode sentence was cut mid-word in English.
|
||||
for lang in ("zh_CN", "en_US"):
|
||||
header_window._set_language(lang)
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
for mode, name in ((False, "follow"), (True, "browse")):
|
||||
if mode:
|
||||
header_plots.set_window(0.2, 1.0)
|
||||
app.processEvents()
|
||||
label = header_plots.mode_label
|
||||
check(
|
||||
label.width() >= label.sizeHint().width(),
|
||||
f"{lang}/{name}: the chart mode label is clipped: "
|
||||
f"{label.width()} < {label.sizeHint().width()} ({label.text()!r})",
|
||||
)
|
||||
check(
|
||||
label.toolTip() != "",
|
||||
f"{lang}/{name}: the mode label does not explain itself",
|
||||
)
|
||||
# The locale is process-wide: leave it where the other sections expect it.
|
||||
header_window._set_language("zh_CN")
|
||||
app.processEvents()
|
||||
header_window.close()
|
||||
app.processEvents()
|
||||
|
||||
@@ -1761,6 +1790,25 @@ def main() -> int:
|
||||
finally:
|
||||
app_module.recordings_dir = original_root
|
||||
_shutil.rmtree(scratch, ignore_errors=True)
|
||||
|
||||
# The rail's FPS is the worker's *delivered* frame rate. It used to be a count
|
||||
# of loop iterations (a 30 fps device read 63), so the number the rail prints
|
||||
# must be the packet's, verbatim.
|
||||
window.act_show_fps.setChecked(True)
|
||||
app.processEvents()
|
||||
packet = _fake_packet(0, mono_t=0.0)
|
||||
packet.fps = 30.4
|
||||
stuck.emit(packet)
|
||||
window._on_run_tick()
|
||||
app.processEvents()
|
||||
check(
|
||||
"30.4 FPS" in window.toolbar.source_summary.text(),
|
||||
f"the rail does not show the delivered frame rate: "
|
||||
f"{window.toolbar.source_summary.text()!r}",
|
||||
)
|
||||
window.act_show_fps.setChecked(False)
|
||||
app.processEvents()
|
||||
|
||||
window._stop_run(reason="user_stop")
|
||||
stuck.release()
|
||||
app.processEvents()
|
||||
@@ -2460,9 +2508,16 @@ def main() -> int:
|
||||
all(plot.getViewBox().state["mouseEnabled"][0] for plot in plots.plots),
|
||||
"browsing does not enable panning",
|
||||
)
|
||||
window_texts = plots.stats_label.text()
|
||||
check(window_texts != "", "no selection statistics in browse mode")
|
||||
check("峰" in window_texts, f"the peak is missing from the readout: {window_texts!r}")
|
||||
# "Whole session" is the default fit, not a selection: the cards still state
|
||||
# the sample under the cursor, and the strip carries no selection summary.
|
||||
check(
|
||||
plots._selection is None,
|
||||
"fitted to the whole session, the cards still hold selection statistics",
|
||||
)
|
||||
check(
|
||||
plots.stats_label.text() == "",
|
||||
f"a fitted session shows a selection summary: {plots.stats_label.text()!r}",
|
||||
)
|
||||
|
||||
# Decimation must keep the spike while bounding the point count.
|
||||
drawn = max(int(curve.getData()[0].size) for curve in plots.curves)
|
||||
@@ -2476,6 +2531,50 @@ def main() -> int:
|
||||
plots.overview_curve.getData()[0].size > 0,
|
||||
"the whole-session overview strip is empty",
|
||||
)
|
||||
# A window narrower than the session is a selection: its peak becomes the big
|
||||
# number on each card and its mean/σ the line under it, while the strip keeps
|
||||
# only the size of the selection (the long per-channel line did not fit).
|
||||
check(plots._selection is not None, "zooming in did not record a selection")
|
||||
summary = plots.stats_label.text()
|
||||
check(summary != "", "the strip does not state the size of the selection")
|
||||
check(
|
||||
"峰" not in summary and "均" not in summary,
|
||||
f"the per-channel statistics are still in the strip: {summary!r}",
|
||||
)
|
||||
check(
|
||||
"选区" in summary or "Selection" in summary,
|
||||
f"the strip does not say that the numbers describe a selection: {summary!r}",
|
||||
)
|
||||
expected_peak = float(plots._selection.peaks[0])
|
||||
peak_text = (
|
||||
f"{expected_peak:.3f}" if plots.unit() == "N" else f"{expected_peak:.1f}"
|
||||
)
|
||||
check(
|
||||
plots.value_labels[0].text() == peak_text,
|
||||
f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}",
|
||||
)
|
||||
check(
|
||||
plots.value_details[0].text() != ""
|
||||
and ("均" in plots.value_details[0].text() or "mean" in plots.value_details[0].text()),
|
||||
f"the card does not state the selection's mean: "
|
||||
f"{plots.value_details[0].text()!r}",
|
||||
)
|
||||
for card, label, detail in zip(
|
||||
plots.value_cards, plots.value_labels, plots.value_details
|
||||
):
|
||||
check(
|
||||
detail.width() >= detail.sizeHint().width(),
|
||||
f"the selection detail line is clipped: "
|
||||
f"{detail.width()} < {detail.sizeHint().width()}",
|
||||
)
|
||||
# Leaving the selection hands the cards back to the cursor readout.
|
||||
plots.follow_latest()
|
||||
check(plots._selection is None, "the selection survived 'back to latest'")
|
||||
check(
|
||||
plots.value_details[0].text() == "",
|
||||
f"the mean line survived leaving the selection: "
|
||||
f"{plots.value_details[0].text()!r}",
|
||||
)
|
||||
|
||||
# A user gesture (the signal pyqtgraph emits for a wheel/drag) enters browse;
|
||||
# "back to latest" must actually leave it.
|
||||
|
||||
@@ -149,14 +149,39 @@ def _run_scene(window, app, scene: str):
|
||||
|
||||
def _demo_data(window) -> None:
|
||||
"""Fill the plots, the calibration table and the arrow canvas."""
|
||||
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||
|
||||
plots = window.force_plots
|
||||
# The panel reads a *selection* out of the attached source, so the demo needs
|
||||
# a real session store behind it (a store-less panel can only follow live).
|
||||
store = SessionStore()
|
||||
plots.set_store(store)
|
||||
for i in range(240):
|
||||
t = i / 30.0
|
||||
fn = 6.0 + 5.0 * math.sin(t * 1.4)
|
||||
fx = 4.0 * math.sin(t * 2.1 + 0.4)
|
||||
fy = -3.0 + 2.5 * math.cos(t * 1.1)
|
||||
store.append(
|
||||
SessionSample(
|
||||
frame_id=i,
|
||||
t_rel_s=t,
|
||||
fn_raw=fn * 7.9 + 9.0,
|
||||
fx_raw=fx * 7.9,
|
||||
fy_raw=fy * 7.9,
|
||||
fn_n=fn,
|
||||
fx_n=fx,
|
||||
fy_n=fy,
|
||||
calibrated_axes=("fn", "fx", "fy"),
|
||||
)
|
||||
)
|
||||
plots.push(t, fn, fx, fy, unit="N")
|
||||
|
||||
# The overview reads the *source*, and ``push`` only rebuilds it once a second
|
||||
# (the demo fills a whole session in a few milliseconds), so build it once here.
|
||||
plots._overview_dirty = True
|
||||
plots._last_overview_at = 0.0
|
||||
plots._sync_overview()
|
||||
|
||||
cal = window.cal_panel
|
||||
cal.set_running(True)
|
||||
cal.push_raw(9.81, -0.42, 0.10)
|
||||
@@ -227,6 +252,14 @@ def main() -> int:
|
||||
"replay view so the progress bar can be reviewed"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--select",
|
||||
default="",
|
||||
help=(
|
||||
"A,B — browse the live demo session inside that window so the "
|
||||
"selection readout (cards = peak / mean · σ) can be reviewed"
|
||||
),
|
||||
)
|
||||
parser.add_argument("--out", required=True, help="output PNG path")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -235,6 +268,14 @@ def main() -> int:
|
||||
except ValueError:
|
||||
parser.error("--size must look like 1440x880")
|
||||
|
||||
selection = None
|
||||
if args.select:
|
||||
try:
|
||||
start, end = (float(part) for part in args.select.split(",", 1))
|
||||
except ValueError:
|
||||
parser.error("--select must look like 2,6")
|
||||
selection = (start, end)
|
||||
|
||||
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
@@ -281,6 +322,13 @@ def main() -> int:
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
if selection is not None:
|
||||
# A selection is a browse window narrower than the session: the value
|
||||
# cards state its peak and mean · σ, the strip only its size.
|
||||
window.force_plots.set_window(*selection)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
ok = window.grab().save(str(out))
|
||||
|
||||
Reference in New Issue
Block a user