Fix replay 2D black canvas and journey UX clarity

Stored analysis overlays now use the decoded frame; latch resets and draw failures surface. Verified with bare-video, replay-wiring, visual_policy, and analysis-progress tests.
This commit is contained in:
ykcheungab
2026-10-06 16:26:47 +08:00
parent a4313f300d
commit 425fa0d44d
13 changed files with 322 additions and 48 deletions
+11
View File
@@ -218,6 +218,8 @@
"replay_empty": "No recording open\nPress “Open recording…” below",
"arrow_empty": "No image yet\nPick a video or camera, then press Run",
"arrow_empty_tip": "The deformation arrow image appears here while running or previewing",
"arrow_render_failed": "Flow arrows could not be drawn for this frame\nTry another view, or reload the video",
"arrow_capture_unavailable": "The captured frame is not available for this overlay\nThe raw camera view is still one selection away",
"depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)",
"depth_scale": "Depth scale {low} – {high}",
"depth_empty": "This frame's depth is all zeros (0 of {pixels} px non-zero): the ramp's low colour is a real zero response, not missing data",
@@ -448,6 +450,11 @@
"phase_running": "Acquiring",
"phase_error": "Error",
"rail_video_name": "Video file: {name}",
"rail_result_profile": "Result profile: {type}",
"rail_result_reused": "Stored result",
"rail_result_complete": "Analysis complete",
"rail_result_running": "Analysing…",
"rail_result_ready": "Result ready",
"data_valid": "Data valid",
"data_invalid": "{count} invalid sample(s)",
"calibrated": "Calibrated",
@@ -546,6 +553,10 @@
},
"analysis": {
"title": "Analysing the recording",
"title_reused": "Using a stored analysis result",
"title_complete": "Analysis complete",
"title_cancelled": "Analysis cancelled",
"title_failed": "Analysis failed",
"frames": "{written} / {expected} frames",
"segment": "segment {index} / {count}",
"elapsed": "{seconds:.1f} s elapsed",
+11
View File
@@ -218,6 +218,8 @@
"replay_empty": "尚未打开录制\n点击下方「打开录制…」",
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
"arrow_render_failed": "本帧无法绘制形变箭头\n请换一种视图,或重新加载视频",
"arrow_capture_unavailable": "当前无法取得采集图像用于叠加\n原始相机画面仍可在同一选择器中查看",
"depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)",
"depth_scale": "深度色标 {low} – {high}",
"depth_empty": "本帧深度全为 0({pixels} px 中 0 个非零):色标最低颜色是真实的零响应,不是缺数据",
@@ -448,6 +450,11 @@
"phase_running": "采集中",
"phase_error": "出错",
"rail_video_name": "视频文件: {name}",
"rail_result_profile": "结果配置: {type}",
"rail_result_reused": "已复用缓存结果",
"rail_result_complete": "分析完成",
"rail_result_running": "正在分析…",
"rail_result_ready": "结果可用",
"data_valid": "数据有效",
"data_invalid": "无效样本 {count}",
"calibrated": "已标定",
@@ -546,6 +553,10 @@
},
"analysis": {
"title": "正在分析录像",
"title_reused": "正在使用已缓存的分析结果",
"title_complete": "分析完成",
"title_cancelled": "分析已取消",
"title_failed": "分析失败",
"frames": "{written} / {expected} 帧",
"segment": "分段 {index} / {count}",
"elapsed": "已用 {seconds:.1f} s",
+62 -9
View File
@@ -994,7 +994,9 @@ class StudioMainWindow(QMainWindow):
self.toolbar.set_source_mode(str(self.prefs.get("source_mode") or "camera"))
self.toolbar.set_video_path(str(self.prefs.get("video_path") or ""))
self.toolbar.set_units(str(self.prefs.get("units") or "newton"))
self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure"))
self.arrow_panel.set_view_mode(
str(self.prefs.get("image_view_mode") or "arrows_stitched")
)
self.arrow_panel.set_rotation_deg(self.prefs.get("arrow_rotation_deg", 90))
# The finger's 2D presentation: a *display* convention for the unrolled canvas
# (see ``acquisition.display_orientation``), stored as a preference and applied
@@ -1487,6 +1489,32 @@ class StudioMainWindow(QMainWindow):
path = self.toolbar.video_path()
if path:
detail = t("status.rail_video_name", name=Path(path).name)
# Name the *result's* profile and whether it was reused or just finished, so
# changing the sensor picker cannot look like the open figures came from the
# newly selected product.
session = self.replay_ctl.available_result()
if session is not None:
provenance = getattr(getattr(session, "reader", None), "provenance", None)
result_type = str(getattr(provenance, "sensor_type", "") or "").strip()
if result_type:
detail = (
f"{detail} · {t('status.rail_result_profile', type=result_type)}"
if detail
else t("status.rail_result_profile", type=result_type)
)
progress = getattr(self.replay_ctl, "_progress", None)
progress_state = (
str(progress.state()) if progress is not None and hasattr(progress, "state") else ""
)
if progress_state == "reused":
status_text = t("status.rail_result_reused")
elif progress_state == "complete":
status_text = t("status.rail_result_complete")
elif progress_state == "running":
status_text = t("status.rail_result_running")
else:
status_text = t("status.rail_result_ready")
detail = f"{detail} · {status_text}" if detail else status_text
if self._show_fps and self._latest_fps is not None:
fps = f"{self._latest_fps:.1f} FPS"
# The cost goes next to the rate because one without the other cannot
@@ -1975,6 +2003,9 @@ class StudioMainWindow(QMainWindow):
sensor_type=str(sensor_type),
config_ref=self._resolve_config_ref(),
)
# A different product is a different UV island; the previous latch must not
# paint the new canvas (a box result has no mask and would inherit a finger's).
self._domain_mask.reset()
# The pane follows the sensor: a box sensor has no 3D scene, and a finger's
# missing package must not be reported while looking at a box.
self.layout_ctl.sync_3d_source()
@@ -1982,8 +2013,12 @@ class StudioMainWindow(QMainWindow):
self._apply_arrow_rotation_for_sensor()
self._update_run_enabled()
# The rail's sensor summary and the sensor dialog both state the product
# and its config, so both follow a change.
# and its config, so both follow a change. Replay still names the *result's*
# own profile when a recording is open, so the rail cannot imply old figures
# came from the newly selected product.
self._refresh_status_rail()
if self._view_preset == "replay" and self.replay.has_video():
self.replay_ctl.render_frame()
self._persist_prefs()
# ----------------------------------------------------------- shortcuts
@@ -2641,6 +2676,10 @@ class StudioMainWindow(QMainWindow):
return f"{text} · {empty}" if text else empty
return text
def reset_domain_mask(self) -> None:
"""Drop the latched UV island: a new recording or sensor has its own geometry."""
self._domain_mask.reset()
def _latched_domain_mask(self, frame: ImageFrame):
"""The 2D domain boundary to draw, latched for the session.
@@ -2651,7 +2690,15 @@ class StudioMainWindow(QMainWindow):
that had not moved while any difference in ``frame.valid_mask`` repainted the
whole boundary, which reads as lag. The latch holds the boundary until a frame
no longer describes the same island (see ``DomainMaskLatch``).
A frame with no mask (the box chain) must not keep a previous finger island: that
shape fails the domain check and left a silent black arrow canvas on reload.
"""
held = self._domain_mask.mask
if frame.valid_mask is None and held is not None and frame.image_bgr is not None:
image_hw = tuple(int(v) for v in np.asarray(frame.image_bgr).shape[:2])
if tuple(int(v) for v in held.shape[:2]) != image_hw:
self._domain_mask.reset()
return self._domain_mask.update(frame.valid_mask)
def _render_run_image(self, packet) -> None:
@@ -2727,14 +2774,20 @@ class StudioMainWindow(QMainWindow):
self._finger_orientation, draw_style=draw_style
),
)
if rendered is None:
diagnostic = (
None if rendered is None else getattr(rendered, "frame_diagnostic", None)
)
if rendered is None or diagnostic is not None:
# Never substitute another picture here: a missing depth map that showed
# the processed frame made "no depth" look like a depth reading.
self.arrow_panel.clear_preview(
t("panels.depth_unavailable")
if mode == DEPTH
else t("panels.arrow_empty")
)
# the processed frame made "no depth" look like a depth reading. A draw
# failure used to return the black base image; say so instead.
if mode == DEPTH and rendered is None:
message = t("panels.depth_unavailable")
elif diagnostic is not None:
message = t("panels.arrow_render_failed")
else:
message = t("panels.arrow_empty")
self.arrow_panel.clear_preview(message)
return
self.arrow_panel.show_frame(bgr_to_qimage(rendered))
@@ -180,7 +180,12 @@ class AnalysisSession:
)
self.reader = reader
self.policy = (policy or VisualPolicy()).normalised()
self.display_policy = (display_policy or self.policy).normalised()
# The pane's display settings may carry a different sample grid than the pass
# recorded. Location keys stay with the stored result so re-selection hits the
# same cells; only the activity threshold follows the display.
self.display_policy = self._align_display_policy(
(display_policy or self.policy).normalised()
)
self.session_id = int(session_id)
self.recording = None if recording is None else Path(recording)
self._frame_ids = np.asarray(reader.frame_ids, dtype=np.int64)
@@ -237,18 +242,44 @@ class AnalysisSession:
return dict(getattr(self.reader.provenance, "policy", {}) or {})
# ------------------------------------------------------------------- policy
def _align_display_policy(self, display_policy: VisualPolicy) -> VisualPolicy:
"""Keep the pass's sample locations; take activity thresholds from ``display``."""
wanted = display_policy.normalised()
stored = self.stored_policy()
if not stored:
return wanted
return VisualPolicy(
flow_on_px=float(wanted.flow_on_px),
flow_off_px=float(wanted.flow_off_px),
flow_grid_spacing=int(
stored.get("flow_grid_spacing", wanted.flow_grid_spacing)
),
flow_arrow_scale=float(
stored.get("flow_arrow_scale", wanted.flow_arrow_scale)
),
flow_edge_margin=int(
stored.get("flow_edge_margin", wanted.flow_edge_margin)
),
flow_length_margin=float(
stored.get("flow_length_margin", wanted.flow_length_margin)
),
max_arrows=int(stored.get("max_arrows", wanted.max_arrows)),
).normalised()
def serve_policy(self, display_policy: VisualPolicy) -> bool:
"""Adopt a display policy, if the stored grid can still serve it.
Returns ``False`` — and changes nothing — when the policy moves a location
setting (see :data:`GRID_KEYS`). Those decide where the candidates are, and the
recorded locations are the ones the pass measured. The threshold is not one of
them: it decides which recorded samples are active.
Returns ``False`` when the policy moves a location setting (see
:data:`GRID_KEYS`). Those decide where the candidates are, and the recorded
locations are the ones the pass measured. The activity threshold is still
adopted in that case — it decides which recorded samples are active, and a
display dialog that only changes ``flow_on_px`` must not be ignored because
its default grid disagrees with the pass.
"""
if policy_divergence(self.stored_policy(), display_policy):
return False
self.display_policy = display_policy.normalised()
return True
wanted = display_policy.normalised()
moved = policy_divergence(self.stored_policy(), wanted)
self.display_policy = self._align_display_policy(wanted)
return not moved
def threshold(self) -> float:
return float(self.display_policy.flow_on_px)
@@ -29,7 +29,7 @@ from typing import Any
import numpy as np
from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
ARROW_STITCHED,
DERIVED_MODES,
RESULT_FAILED,
RESULT_IMPOSSIBLE,
@@ -335,6 +335,7 @@ class ReplayController(QObject):
# with a video source and no replay renderer.
self._host.notify(t("toolbar.replay_view_only"))
return False
self._reset_display_geometry()
folder = video.parent if video.is_file() else video
if not (folder / "forces.csv").is_file():
if video.is_file() and self._engine.open_video(video):
@@ -374,6 +375,7 @@ class ReplayController(QObject):
# A recording is a recording: whatever workspace the previous bare video was
# analysed in stops being this timeline's input.
self._video_workspace = None
self._reset_display_geometry()
opened = self._engine.open_recording(Path(directory))
if not opened:
self._host.notify(t("playback.open_failed", error=Path(directory).name))
@@ -384,6 +386,12 @@ class ReplayController(QObject):
self.on_analysis_requested()
return True
def _reset_display_geometry(self) -> None:
"""A new clip owns its own UV island; the previous latch must not survive."""
reset = getattr(self._host, "reset_domain_mask", None)
if callable(reset):
reset()
# ------------------------------------------------------------- bare video setup
def on_analysis_setup(self) -> None:
"""Reopen the analysis setup dialog after a first pass (or before one).
@@ -1011,15 +1019,16 @@ class ReplayController(QObject):
picture stays in the same selector, one step away, and the recompute switch is
not involved.
Answers whether the selection moved. A derived selection is left alone: the mode
the operator chose is not this method's to override.
The first processed view is arrows over the captured frame: the camera picture
gives immediate context. A derived selection the operator already chose is left
alone — that mode is not this method's to override.
"""
current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
if current in DERIVED_MODES:
return False
# The plan for the frame just drawn offers the derived modes (the frame carries
# ``result_available``), so this selection is one the panel can honour.
self._image_panel.set_view_mode(ARROW_PURE)
self._image_panel.set_view_mode(ARROW_STITCHED)
self.render_frame()
return True
@@ -1222,7 +1231,36 @@ class ReplayController(QObject):
return held
# ------------------------------------------------------------------ drawing
def _analysis_image_frame(self, frame, *, source: str) -> ImageFrame:
def _captured_base_for(self, frame) -> np.ndarray | None:
"""The camera picture at the result's domain size, or ``None`` when unavailable.
Stored analysis frames keep a black placeholder as ``base_bgr``: the pass does
not embed the video. Overlay views need the decoded frame resized onto the
measurement grid — painting the stitch grid onto a larger camera frame would
leave the arrows in a corner (see :meth:`AnalysisSession.domain_hw`).
"""
video = self._engine.video_frame_bgr()
if video is None:
return None
height, width = (int(frame.flow.shape[0]), int(frame.flow.shape[1]))
image = np.asarray(video)
if image.ndim == 2:
import cv2
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
elif image.ndim == 3 and image.shape[2] == 4:
import cv2
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
if int(image.shape[0]) == height and int(image.shape[1]) == width:
return np.ascontiguousarray(image)
import cv2
return cv2.resize(image, (width, height), interpolation=cv2.INTER_AREA)
def _analysis_image_frame(
self, frame, *, source: str, base_bgr: np.ndarray | None = None
) -> ImageFrame:
"""One stored frame in the shape the window's single 2D sink takes.
The *only* place a replay builds a 2D frame from a result, so the analysis
@@ -1230,9 +1268,13 @@ class ReplayController(QObject):
build two slightly different pictures. ``source`` says which timeline the
operator is on — ``replay`` keeps the raw view on offer while the derived modes
are drawn from the result, so choosing a derived view is not a one-way door.
``base_bgr`` is the camera picture scaled to the domain when the overlay view
needs it; without one the stored black placeholder remains (pure-colour arrows
replace that canvas anyway).
"""
return ImageFrame(
frame.base_bgr,
frame.base_bgr if base_bgr is None else base_bgr,
flow=frame.flow,
depth=frame.depth,
valid_mask=frame.valid_mask,
@@ -1304,10 +1346,18 @@ class ReplayController(QObject):
else:
frame = None
if frame is not None:
captured = self._captured_base_for(frame)
if wanted == ARROW_STITCHED and captured is None:
# Overlay without a camera picture is a black canvas with dots — say
# so instead of implying the capture is missing from the result.
self._image_panel.clear_preview(t("panels.arrow_capture_unavailable"))
self._host.show_analysis_packet(session.packet(frame))
return
self._host.show_frame(
self._analysis_image_frame(
frame,
source=SOURCE_ANALYSIS if self.analysis_active() else SOURCE_REPLAY,
base_bgr=captured,
)
)
# The same frame, in the shape the 3D pane's producer consumes: one
@@ -57,6 +57,17 @@ _STATE_TOKEN = {
FAILED: "danger",
}
#: Headline key per state. Running keeps ``analysis.title``; the rest name the outcome
#: so a full frame counter without a progress bar is not read as a stuck pass.
_STATE_TITLE = {
IDLE: "analysis.title",
REUSED: "analysis.title_reused",
RUNNING: "analysis.title",
COMPLETE: "analysis.title_complete",
CANCELLED: "analysis.title_cancelled",
FAILED: "analysis.title_failed",
}
#: The progress bar's thousandths (Qt's own percentage unit, 0–1000).
_FULL = 1000
@@ -222,7 +233,6 @@ class AnalysisProgress(QFrame):
self._refresh()
def retranslate(self) -> None:
self.title_label.setText(t("analysis.title"))
self.cancel_button.setText(
t("analysis.cancelling") if self._cancelling else t("analysis.cancel")
)
@@ -238,18 +248,18 @@ class AnalysisProgress(QFrame):
self.cancel_requested.emit()
def _refresh(self) -> None:
"""Repaint from the state: colour, which chrome is offered, and the tooltip.
"""Repaint from the state: colour, title, which chrome is offered, tooltip.
The title is one string in every state — what a pass is doing does not change
its name — so the *state* reaches the operator through the colour token, the
detail line and whether cancel is offered, not through a second vocabulary.
Running keeps the progress bar and cancel. Reused / complete / cancelled /
failed change the headline so a full frame counter is not read as "still
analysing".
"""
token = _STATE_TOKEN.get(self._state, "hint_text")
running = self._state == RUNNING
self.cancel_button.setVisible(running)
self.cancel_button.setEnabled(running and not self._cancelling)
self.progress_bar.setVisible(running)
self.title_label.setText(t("analysis.title"))
self.title_label.setText(t(_STATE_TITLE.get(self._state, "analysis.title")))
self.title_label.setStyleSheet(
f"color: {color(self._theme, token)}; font-weight: 600;"
)
@@ -24,13 +24,17 @@ from orisys_app_shared.sensors import (
SHAPE_UNREADABLE,
STATE_UNREADABLE,
)
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from orisys_studio.ui.fit_text import ShrinkableLabel
@@ -79,10 +83,38 @@ class SensorDialog(QDialog):
#: finding — plus, when it must not be started, why. It sits directly under the selector
#: on purpose: the picker row carries the name alone, so this is the surface the rest has
#: to be readable on, without a hover and without hunting for it.
#:
#: Long warning lists used to grow the label into the calibration section and clip at
#: the next rule; the scroll area keeps that text readable without covering the rest.
self.profile_label = ShrinkableLabel()
self.profile_label.setObjectName("panelMeta")
self.profile_label.setWordWrap(True)
root.addWidget(self.profile_label)
self.profile_label.setAlignment(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
)
self.profile_label.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum
)
profile_host = QWidget()
profile_host_layout = QVBoxLayout(profile_host)
profile_host_layout.setContentsMargins(0, 0, 0, 0)
profile_host_layout.addWidget(self.profile_label)
self.profile_scroll = QScrollArea()
self.profile_scroll.setObjectName("sensorProfileScroll")
self.profile_scroll.setWidgetResizable(True)
self.profile_scroll.setFrameShape(QFrame.Shape.NoFrame)
self.profile_scroll.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
self.profile_scroll.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.profile_scroll.setWidget(profile_host)
self.profile_scroll.setMaximumHeight(160)
self.profile_scroll.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum
)
root.addWidget(self.profile_scroll)
root.addWidget(self._section_rule())
self.calib_title = QLabel()
@@ -0,0 +1,30 @@
"""Analysis progress banner titles follow the result state."""
from __future__ import annotations
from orisys_app_shared.i18n import set_locale, t
from orisys_studio.ui.analysis_progress import AnalysisProgress
def test_analysis_progress_titles_name_the_outcome(qt_app) -> None:
set_locale("en_US")
banner = AnalysisProgress(theme="dark")
banner.start()
assert banner.title_label.text() == t("analysis.title")
assert banner.progress_bar.isVisible()
assert banner.cancel_button.isVisible()
banner.reused(frames=12)
assert banner.title_label.text() == t("analysis.title_reused")
assert not banner.progress_bar.isVisible()
assert not banner.cancel_button.isVisible()
banner.complete(frames=12, elapsed_s=1.5)
assert banner.title_label.text() == t("analysis.title_complete")
banner.cancelled(frames=3)
assert banner.title_label.text() == t("analysis.title_cancelled")
banner.failed(reason="boom", frames=1)
assert banner.title_label.text() == t("analysis.title_failed")
@@ -42,6 +42,7 @@ from orisys_app_shared.acquisition.arrows import (
)
from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
ARROW_STITCHED,
DEPTH,
RECORDED,
RESULT_FAILED,
@@ -395,6 +396,18 @@ def test_a_bare_video_is_analysed_and_scrubbed_in_both_directions(
"the arrow view is the video's raw picture"
)
# ---- overlay: arrows on the captured frame, not on the black placeholder -
set_mode(window, ARROW_STITCHED)
QApplication.processEvents()
overlay = panel_bgr(window)
assert painted(overlay, canvas) > 0, "the overlay painted no arrows"
assert float(np.mean(overlay)) > 8.0, (
"the overlay is still a near-black canvas; the captured frame was not under the arrows"
)
assert not np.array_equal(overlay, middle_shot), (
"the overlay view is the pure-colour canvas"
)
# ---- the depth view, from the same stored frame -------------------------
set_mode(window, DEPTH)
QApplication.processEvents()