回放并入主窗口,并修掉开场尖峰 / 录制时长 / 录制帧率 / 回放被摄像头覆盖等 9 项评审问题 - What: 回放不再是独立窗口——删除 PlaybackDialog,拆成 replay.py::ReplayController(读盘、游标、深度重算,只发信号)与 ui/replay_bar.py(录制选择、可拖拽进度条、单位、重算深度、提示行),品牌轨「历史回放」直接换主窗口布局,文件菜单两条入口都切到该视图,曲线改读 RecordingCurveSource(区间按需读盘 + 缓存包络),2D 面板切「原始图像 / 深度图」;传感器与标定合并到 SensorDialog(类型/配置下拉 + 当前生效标定 + 最近导出系数),标定面板加纵向滚动区、九按钮两行、六列表头 ResizeToContents。评审 9 项:1) 开场尖峰两个根因——push() 遇单位变化只记 _unit 而保留旧单位样本(raw 计数被画在牛顿轴上),改为「切换即换单位」:清缓冲按新单位重建、触发切换的那一帧仍刷数值卡;_start_run 先 session_store.reset() 再 set_store(),新会话不再带上次的尾巴(复现脚本 .repro/probe_item1.py:旧 39 样本峰值 9.841 → 新 37 样本峰值 2.390)。2) 箭头页脚改为「箭头按位移放大 ×{factor}」。3) 表头单位芯片由只读 QLabel 改为可点按钮,一次点击同时改曲线、数值卡与设置区下拉。4-6) 全程总览/框选/返回最新 改为 ui/icons.py 按主题令牌运行时绘制的图标,tooltip 与 accessibleName 保留文字,全程总览区域名仍是文字。7) stats() 在录制进行中即返回 _last_t - _t0,记录条开始计时(此前只在 _finalise 写、整场停在 00:00.0)。8) 容器 fps 改用传感器配置的速率(packet.fps 是"每秒完成多少帧"的计数器,突发/文件源会读到 48),视频按 1/fps 节流并把超前的帧计入 resampled_video(frames.csv/forces.csv 一行不少,报告写明原因),session.json 同时记 fps 与 measured_fps。9) _replay_owns_image() 让回放视图独占 2D 面板,实时帧不再覆盖;push() 在录制源挂载时连单位都不切,曲线/数值卡/单位一并归录制。附带:数值卡改读游标处样本(_readout_at_cursor)、回放按录制实际有的单位打开(_replay_unit_for + ReplayController.set_unit,避免 raw-only 录制在 newton 偏好下画三张空图)、视频速率下限。 - Why: 用户评审 9 点,其中 1/7/8/9 是代码缺陷(开场尖峰、录制时长不更新、录制 48fps 与实际 30fps 不符、回放时 2D 面板仍被摄像头刷新),2-6 是可读性问题(×2 含义不明、单位与视图控制应可点/应符号化)。 - Verification: ui_check.py 524 项 0 失败(新增 chart header and unit chip / acquisition correctness / replay owns the 2D pane 三段)、test_ui_smoke.py 69 项 0 失败(新增 7 个回归用例:单位切换不混单位、单位芯片、图标语义、录制中时长、传感器帧率+节流、回放独占面板、回放游标读数与单位回退)、drive_shared.py 146 项 0 失败、locale_studio.py(268 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图(深色实时/回放、浅色 1280x720 实时与标定)。 - Not in this change: 录制文件格式(forces.csv 列序、frames.csv、session.json 路径与分段规则)不变,节流只影响"哪些帧进视频",并显式计数上报;三维面板仍是占位符。
This commit is contained in:
@@ -92,6 +92,9 @@ GAP_BACKLOG = 4096
|
||||
#: Hard cap on the ordered stream. Past this the producer has to give up a row,
|
||||
#: and it says so by writing a gap marker instead.
|
||||
ROW_BACKLOG = 8192
|
||||
#: Video pacing tolerance, in fractions of the target frame period: a frame that
|
||||
#: arrives slightly early is still the frame for this slot.
|
||||
VIDEO_PACING_TOLERANCE = 0.5
|
||||
|
||||
|
||||
def format_value(value: float) -> str:
|
||||
@@ -162,6 +165,9 @@ class RecordStats:
|
||||
dropped_gaps: int = 0
|
||||
#: Lost rows that could not even be marked: the gap backlog was full.
|
||||
unrecorded_gaps: int = 0
|
||||
#: Frames the device delivered faster than the recording rate, so they were
|
||||
#: not written to the video. Their data rows are still complete.
|
||||
resampled_video: int = 0
|
||||
write_errors: int = 0
|
||||
failed: bool = False
|
||||
last_error: str = ""
|
||||
@@ -178,6 +184,7 @@ class RecordStats:
|
||||
"dropped_video": self.dropped_video,
|
||||
"dropped_gaps": self.dropped_gaps,
|
||||
"unrecorded_gaps": self.unrecorded_gaps,
|
||||
"resampled_video": self.resampled_video,
|
||||
"write_errors": self.write_errors,
|
||||
"failed": self.failed,
|
||||
"last_error": self.last_error,
|
||||
@@ -239,6 +246,8 @@ class ExperimentRecorder:
|
||||
self._events: list[dict] = []
|
||||
self._t0: Optional[float] = None
|
||||
self._last_t: Optional[float] = None
|
||||
#: When the next video frame is due, in the same acquisition clock.
|
||||
self._next_video_t: Optional[float] = None
|
||||
self._started_at = ""
|
||||
self._consecutive_errors = 0
|
||||
self._on_alarm = None
|
||||
@@ -296,7 +305,18 @@ class ExperimentRecorder:
|
||||
|
||||
def stats(self) -> RecordStats:
|
||||
with self._lock:
|
||||
return RecordStats(**self._stats.as_dict())
|
||||
snapshot = RecordStats(**self._stats.as_dict())
|
||||
# While a recording runs, the duration is the span captured so far.
|
||||
# It is written into the stats at the end of the flush, so a *live*
|
||||
# reading used to report 0 s for the whole recording.
|
||||
if (
|
||||
snapshot.duration_s <= 0.0
|
||||
and self._t0 is not None
|
||||
and self._last_t is not None
|
||||
and self._last_t >= self._t0
|
||||
):
|
||||
snapshot.duration_s = round(float(self._last_t - self._t0), 6)
|
||||
return snapshot
|
||||
|
||||
def set_alarm_handler(self, handler) -> None:
|
||||
"""``handler(message)`` is called from the writer thread on a problem."""
|
||||
@@ -356,6 +376,7 @@ class ExperimentRecorder:
|
||||
self._events = []
|
||||
self._t0 = None
|
||||
self._last_t = None
|
||||
self._next_video_t = None
|
||||
self._consecutive_errors = 0
|
||||
self._started_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
self._report = None
|
||||
@@ -528,21 +549,53 @@ class ExperimentRecorder:
|
||||
|
||||
frame = None
|
||||
if self.config.video or self.config.lossless_frames:
|
||||
candidate = self._capture_frame(packet)
|
||||
if candidate is not None:
|
||||
size = int(candidate.nbytes)
|
||||
if depth >= max(16, int(self.config.max_queue)):
|
||||
self._shed_video("record queue overflow")
|
||||
elif not self._affordable(size):
|
||||
self._shed_video("video byte budget reached")
|
||||
else:
|
||||
frame = candidate
|
||||
# Reserved here and released exactly once, by the writer that
|
||||
# consumes it (see ``_handle_frame``).
|
||||
with self._lock:
|
||||
self._queued_bytes += size
|
||||
# A frame that arrives faster than the recording rate keeps its data
|
||||
# row but gets no picture (see ``_video_frame_due``).
|
||||
if self._video_frame_due(sample.mono_t):
|
||||
candidate = self._capture_frame(packet)
|
||||
if candidate is not None:
|
||||
size = int(candidate.nbytes)
|
||||
if depth >= max(16, int(self.config.max_queue)):
|
||||
self._shed_video("record queue overflow")
|
||||
elif not self._affordable(size):
|
||||
self._shed_video("video byte budget reached")
|
||||
else:
|
||||
frame = candidate
|
||||
# Reserved here and released exactly once, by the writer
|
||||
# that consumes it (see ``_handle_frame``).
|
||||
with self._lock:
|
||||
self._queued_bytes += size
|
||||
self._enqueue(("frame", self._next_seq(), sample, frame))
|
||||
|
||||
def _video_frame_due(self, mono_t) -> bool:
|
||||
"""Whether this frame belongs in the video at the configured rate.
|
||||
|
||||
The video is written on the target rate's own schedule, so the container's
|
||||
fps describes the spacing of the pictures it holds. Writing *every*
|
||||
delivered frame into a 30 fps container makes playback run fast as soon
|
||||
as the pipeline delivers more than 30 frames per second (a burst of
|
||||
buffered frames at open, or a file source), which is exactly the
|
||||
"recorded 48 fps but the sensor is 30" mismatch.
|
||||
|
||||
A skipped frame is not lost data: every row is still in
|
||||
``frames.csv``/``forces.csv``, and the count is reported separately.
|
||||
"""
|
||||
try:
|
||||
stamp = float(mono_t)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
period = 1.0 / max(1.0, float(self.config.fps))
|
||||
with self._lock:
|
||||
if self._next_video_t is None:
|
||||
self._next_video_t = stamp
|
||||
if stamp + period * VIDEO_PACING_TOLERANCE < self._next_video_t:
|
||||
self._stats.resampled_video += 1
|
||||
return False
|
||||
# Schedule from the later of the two: a slow patch must not let the
|
||||
# schedule fall behind and then emit a catch-up burst.
|
||||
self._next_video_t = max(self._next_video_t + period, stamp)
|
||||
return True
|
||||
|
||||
def _next_seq(self) -> int:
|
||||
with self._lock:
|
||||
self._seq += 1
|
||||
@@ -844,6 +897,15 @@ class ExperimentRecorder:
|
||||
problems.append(
|
||||
f"{stats.dropped_gaps} frame(s) were recorded as gaps (no data row)"
|
||||
)
|
||||
if stats.resampled_video:
|
||||
# Not data loss, but the operator has to know the video is not one
|
||||
# picture per delivered frame: the device ran faster than the
|
||||
# recording rate.
|
||||
problems.append(
|
||||
f"{stats.resampled_video} frame(s) were delivered faster than the "
|
||||
f"{float(self.config.fps):g} fps recording rate, so they are in "
|
||||
"frames.csv but not in the video"
|
||||
)
|
||||
if stats.unrecorded_gaps:
|
||||
problems.append(
|
||||
f"{stats.unrecorded_gaps} lost frame(s) could not be marked: "
|
||||
|
||||
@@ -196,7 +196,10 @@ QFrame#brandBar QPushButton#navButton[navActive="true"] {{
|
||||
font-weight: 600;
|
||||
}}
|
||||
QFrame#brandBar QPushButton#navButton:focus {{
|
||||
border-color: {c['brand_bar_fg']};
|
||||
/* A focus ring must not read as "this view is the current one": the active
|
||||
view is marked by colour + weight, so the ring stays muted. */
|
||||
border-color: {c['brand_bar_fg_muted']};
|
||||
color: {c['brand_bar_fg']};
|
||||
}}
|
||||
/* ---------- Buttons ---------- */
|
||||
QPushButton {{
|
||||
@@ -314,6 +317,33 @@ QPushButton[compact="true"] {{
|
||||
padding: 6px 9px;
|
||||
font-size: 12px;
|
||||
}}
|
||||
/* An icon-only action: the tooltip carries the meaning, the icon the shape. */
|
||||
QPushButton[iconOnly="true"] {{
|
||||
min-width: 24px;
|
||||
padding: 4px 6px;
|
||||
}}
|
||||
/* The unit chip is a button (it switches newton <-> raw), so it needs the
|
||||
badge's look plus a hover that says it can be pressed. */
|
||||
QPushButton#unitBadge {{
|
||||
color: {c['text_secondary']};
|
||||
background-color: {c['card_bg']};
|
||||
border: 1px solid {c['card_border']};
|
||||
border-radius: 6px;
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
min-height: 18px;
|
||||
}}
|
||||
QPushButton#unitBadge:hover {{
|
||||
color: {c['text_primary']};
|
||||
border-color: {c['accent']};
|
||||
}}
|
||||
QPushButton#unitBadge:disabled {{
|
||||
color: {c['text_muted']};
|
||||
background-color: {c['card_bg']};
|
||||
border-color: {c['separator']};
|
||||
}}
|
||||
/* Status-rail controls are one instrument line: they must not carry the
|
||||
padding a dialog button needs, or the top of the window grows to twice the
|
||||
height of the rail's own content. */
|
||||
|
||||
Reference in New Issue
Block a user