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", "replay_empty": "No recording open\nPress “Open recording…” below",
"arrow_empty": "No image yet\nPick a video or camera, then press Run", "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_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_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_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", "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_running": "Acquiring",
"phase_error": "Error", "phase_error": "Error",
"rail_video_name": "Video file: {name}", "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_valid": "Data valid",
"data_invalid": "{count} invalid sample(s)", "data_invalid": "{count} invalid sample(s)",
"calibrated": "Calibrated", "calibrated": "Calibrated",
@@ -546,6 +553,10 @@
}, },
"analysis": { "analysis": {
"title": "Analysing the recording", "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", "frames": "{written} / {expected} frames",
"segment": "segment {index} / {count}", "segment": "segment {index} / {count}",
"elapsed": "{seconds:.1f} s elapsed", "elapsed": "{seconds:.1f} s elapsed",
+11
View File
@@ -218,6 +218,8 @@
"replay_empty": "尚未打开录制\n点击下方「打开录制…」", "replay_empty": "尚未打开录制\n点击下方「打开录制…」",
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」", "arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
"arrow_empty_tip": "运行或预览后在此显示形变箭头图", "arrow_empty_tip": "运行或预览后在此显示形变箭头图",
"arrow_render_failed": "本帧无法绘制形变箭头\n请换一种视图,或重新加载视频",
"arrow_capture_unavailable": "当前无法取得采集图像用于叠加\n原始相机画面仍可在同一选择器中查看",
"depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)", "depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)",
"depth_scale": "深度色标 {low} – {high}", "depth_scale": "深度色标 {low} – {high}",
"depth_empty": "本帧深度全为 0({pixels} px 中 0 个非零):色标最低颜色是真实的零响应,不是缺数据", "depth_empty": "本帧深度全为 0({pixels} px 中 0 个非零):色标最低颜色是真实的零响应,不是缺数据",
@@ -448,6 +450,11 @@
"phase_running": "采集中", "phase_running": "采集中",
"phase_error": "出错", "phase_error": "出错",
"rail_video_name": "视频文件: {name}", "rail_video_name": "视频文件: {name}",
"rail_result_profile": "结果配置: {type}",
"rail_result_reused": "已复用缓存结果",
"rail_result_complete": "分析完成",
"rail_result_running": "正在分析…",
"rail_result_ready": "结果可用",
"data_valid": "数据有效", "data_valid": "数据有效",
"data_invalid": "无效样本 {count}", "data_invalid": "无效样本 {count}",
"calibrated": "已标定", "calibrated": "已标定",
@@ -546,6 +553,10 @@
}, },
"analysis": { "analysis": {
"title": "正在分析录像", "title": "正在分析录像",
"title_reused": "正在使用已缓存的分析结果",
"title_complete": "分析完成",
"title_cancelled": "分析已取消",
"title_failed": "分析失败",
"frames": "{written} / {expected} 帧", "frames": "{written} / {expected} 帧",
"segment": "分段 {index} / {count}", "segment": "分段 {index} / {count}",
"elapsed": "已用 {seconds:.1f} s", "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_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_video_path(str(self.prefs.get("video_path") or ""))
self.toolbar.set_units(str(self.prefs.get("units") or "newton")) 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)) 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 # The finger's 2D presentation: a *display* convention for the unrolled canvas
# (see ``acquisition.display_orientation``), stored as a preference and applied # (see ``acquisition.display_orientation``), stored as a preference and applied
@@ -1487,6 +1489,32 @@ class StudioMainWindow(QMainWindow):
path = self.toolbar.video_path() path = self.toolbar.video_path()
if path: if path:
detail = t("status.rail_video_name", name=Path(path).name) 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: if self._show_fps and self._latest_fps is not None:
fps = f"{self._latest_fps:.1f} FPS" fps = f"{self._latest_fps:.1f} FPS"
# The cost goes next to the rate because one without the other cannot # 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), sensor_type=str(sensor_type),
config_ref=self._resolve_config_ref(), 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 # 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. # missing package must not be reported while looking at a box.
self.layout_ctl.sync_3d_source() self.layout_ctl.sync_3d_source()
@@ -1982,8 +2013,12 @@ class StudioMainWindow(QMainWindow):
self._apply_arrow_rotation_for_sensor() self._apply_arrow_rotation_for_sensor()
self._update_run_enabled() self._update_run_enabled()
# The rail's sensor summary and the sensor dialog both state the product # 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() self._refresh_status_rail()
if self._view_preset == "replay" and self.replay.has_video():
self.replay_ctl.render_frame()
self._persist_prefs() self._persist_prefs()
# ----------------------------------------------------------- shortcuts # ----------------------------------------------------------- shortcuts
@@ -2641,6 +2676,10 @@ class StudioMainWindow(QMainWindow):
return f"{text} · {empty}" if text else empty return f"{text} · {empty}" if text else empty
return text 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): def _latched_domain_mask(self, frame: ImageFrame):
"""The 2D domain boundary to draw, latched for the session. """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 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 whole boundary, which reads as lag. The latch holds the boundary until a frame
no longer describes the same island (see ``DomainMaskLatch``). 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) return self._domain_mask.update(frame.valid_mask)
def _render_run_image(self, packet) -> None: def _render_run_image(self, packet) -> None:
@@ -2727,14 +2774,20 @@ class StudioMainWindow(QMainWindow):
self._finger_orientation, draw_style=draw_style 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 # Never substitute another picture here: a missing depth map that showed
# the processed frame made "no depth" look like a depth reading. # the processed frame made "no depth" look like a depth reading. A draw
self.arrow_panel.clear_preview( # failure used to return the black base image; say so instead.
t("panels.depth_unavailable") if mode == DEPTH and rendered is None:
if mode == DEPTH message = t("panels.depth_unavailable")
else t("panels.arrow_empty") elif diagnostic is not None:
) message = t("panels.arrow_render_failed")
else:
message = t("panels.arrow_empty")
self.arrow_panel.clear_preview(message)
return return
self.arrow_panel.show_frame(bgr_to_qimage(rendered)) self.arrow_panel.show_frame(bgr_to_qimage(rendered))
@@ -180,7 +180,12 @@ class AnalysisSession:
) )
self.reader = reader self.reader = reader
self.policy = (policy or VisualPolicy()).normalised() 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.session_id = int(session_id)
self.recording = None if recording is None else Path(recording) self.recording = None if recording is None else Path(recording)
self._frame_ids = np.asarray(reader.frame_ids, dtype=np.int64) 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 {}) return dict(getattr(self.reader.provenance, "policy", {}) or {})
# ------------------------------------------------------------------- policy # ------------------------------------------------------------------- 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: def serve_policy(self, display_policy: VisualPolicy) -> bool:
"""Adopt a display policy, if the stored grid can still serve it. """Adopt a display policy, if the stored grid can still serve it.
Returns ``False`` — and changes nothing — when the policy moves a location Returns ``False`` when the policy moves a location setting (see
setting (see :data:`GRID_KEYS`). Those decide where the candidates are, and the :data:`GRID_KEYS`). Those decide where the candidates are, and the recorded
recorded locations are the ones the pass measured. The threshold is not one of locations are the ones the pass measured. The activity threshold is still
them: it decides which recorded samples are active. 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): wanted = display_policy.normalised()
return False moved = policy_divergence(self.stored_policy(), wanted)
self.display_policy = display_policy.normalised() self.display_policy = self._align_display_policy(wanted)
return True return not moved
def threshold(self) -> float: def threshold(self) -> float:
return float(self.display_policy.flow_on_px) return float(self.display_policy.flow_on_px)
@@ -29,7 +29,7 @@ from typing import Any
import numpy as np import numpy as np
from orisys_app_shared.acquisition.image_backend import ( from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE, ARROW_STITCHED,
DERIVED_MODES, DERIVED_MODES,
RESULT_FAILED, RESULT_FAILED,
RESULT_IMPOSSIBLE, RESULT_IMPOSSIBLE,
@@ -335,6 +335,7 @@ class ReplayController(QObject):
# with a video source and no replay renderer. # with a video source and no replay renderer.
self._host.notify(t("toolbar.replay_view_only")) self._host.notify(t("toolbar.replay_view_only"))
return False return False
self._reset_display_geometry()
folder = video.parent if video.is_file() else video folder = video.parent if video.is_file() else video
if not (folder / "forces.csv").is_file(): if not (folder / "forces.csv").is_file():
if video.is_file() and self._engine.open_video(video): 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 # A recording is a recording: whatever workspace the previous bare video was
# analysed in stops being this timeline's input. # analysed in stops being this timeline's input.
self._video_workspace = None self._video_workspace = None
self._reset_display_geometry()
opened = self._engine.open_recording(Path(directory)) opened = self._engine.open_recording(Path(directory))
if not opened: if not opened:
self._host.notify(t("playback.open_failed", error=Path(directory).name)) self._host.notify(t("playback.open_failed", error=Path(directory).name))
@@ -384,6 +386,12 @@ class ReplayController(QObject):
self.on_analysis_requested() self.on_analysis_requested()
return True 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 # ------------------------------------------------------------- bare video setup
def on_analysis_setup(self) -> None: def on_analysis_setup(self) -> None:
"""Reopen the analysis setup dialog after a first pass (or before one). """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 picture stays in the same selector, one step away, and the recompute switch is
not involved. not involved.
Answers whether the selection moved. A derived selection is left alone: the mode The first processed view is arrows over the captured frame: the camera picture
the operator chose is not this method's to override. 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 "") current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
if current in DERIVED_MODES: if current in DERIVED_MODES:
return False return False
# The plan for the frame just drawn offers the derived modes (the frame carries # 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. # ``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() self.render_frame()
return True return True
@@ -1222,7 +1231,36 @@ class ReplayController(QObject):
return held return held
# ------------------------------------------------------------------ drawing # ------------------------------------------------------------------ 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. """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 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 build two slightly different pictures. ``source`` says which timeline the
operator is on — ``replay`` keeps the raw view on offer while the derived modes 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. 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( return ImageFrame(
frame.base_bgr, frame.base_bgr if base_bgr is None else base_bgr,
flow=frame.flow, flow=frame.flow,
depth=frame.depth, depth=frame.depth,
valid_mask=frame.valid_mask, valid_mask=frame.valid_mask,
@@ -1304,10 +1346,18 @@ class ReplayController(QObject):
else: else:
frame = None frame = None
if frame is not 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._host.show_frame(
self._analysis_image_frame( self._analysis_image_frame(
frame, frame,
source=SOURCE_ANALYSIS if self.analysis_active() else SOURCE_REPLAY, 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 # The same frame, in the shape the 3D pane's producer consumes: one
@@ -57,6 +57,17 @@ _STATE_TOKEN = {
FAILED: "danger", 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). #: The progress bar's thousandths (Qt's own percentage unit, 0–1000).
_FULL = 1000 _FULL = 1000
@@ -222,7 +233,6 @@ class AnalysisProgress(QFrame):
self._refresh() self._refresh()
def retranslate(self) -> None: def retranslate(self) -> None:
self.title_label.setText(t("analysis.title"))
self.cancel_button.setText( self.cancel_button.setText(
t("analysis.cancelling") if self._cancelling else t("analysis.cancel") t("analysis.cancelling") if self._cancelling else t("analysis.cancel")
) )
@@ -238,18 +248,18 @@ class AnalysisProgress(QFrame):
self.cancel_requested.emit() self.cancel_requested.emit()
def _refresh(self) -> None: 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 Running keeps the progress bar and cancel. Reused / complete / cancelled /
its name — so the *state* reaches the operator through the colour token, the failed change the headline so a full frame counter is not read as "still
detail line and whether cancel is offered, not through a second vocabulary. analysing".
""" """
token = _STATE_TOKEN.get(self._state, "hint_text") token = _STATE_TOKEN.get(self._state, "hint_text")
running = self._state == RUNNING running = self._state == RUNNING
self.cancel_button.setVisible(running) self.cancel_button.setVisible(running)
self.cancel_button.setEnabled(running and not self._cancelling) self.cancel_button.setEnabled(running and not self._cancelling)
self.progress_bar.setVisible(running) 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( self.title_label.setStyleSheet(
f"color: {color(self._theme, token)}; font-weight: 600;" f"color: {color(self._theme, token)}; font-weight: 600;"
) )
@@ -24,13 +24,17 @@ from orisys_app_shared.sensors import (
SHAPE_UNREADABLE, SHAPE_UNREADABLE,
STATE_UNREADABLE, STATE_UNREADABLE,
) )
from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QPushButton, QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout, QVBoxLayout,
QWidget,
) )
from orisys_studio.ui.fit_text import ShrinkableLabel 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 #: 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 #: 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. #: 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 = ShrinkableLabel()
self.profile_label.setObjectName("panelMeta") self.profile_label.setObjectName("panelMeta")
self.profile_label.setWordWrap(True) 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()) root.addWidget(self._section_rule())
self.calib_title = QLabel() 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 ( from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE, ARROW_PURE,
ARROW_STITCHED,
DEPTH, DEPTH,
RECORDED, RECORDED,
RESULT_FAILED, 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" "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 ------------------------- # ---- the depth view, from the same stored frame -------------------------
set_mode(window, DEPTH) set_mode(window, DEPTH)
QApplication.processEvents() QApplication.processEvents()
@@ -280,6 +280,18 @@ def magnitude_to_ramp_index(
return np.clip(scaled * 255.0, 0.0, 255.0).astype(np.uint8) return np.clip(scaled * 255.0, 0.0, 255.0).astype(np.uint8)
def carry_frame_diagnostic(image: np.ndarray, diagnostic=None) -> np.ndarray:
"""``image`` as a draw result that still carries ``frame_diagnostic``.
Used by :func:`draw_arrow_display` and by callers that must re-wrap after a
transform (orientation) that would otherwise drop the attribute. ``None`` means
the draw succeeded.
"""
out = np.asarray(image).view(_DrawResult)
out.frame_diagnostic = diagnostic
return out
def _flow_result(image: np.ndarray, diagnostic=None) -> np.ndarray: def _flow_result(image: np.ndarray, diagnostic=None) -> np.ndarray:
"""``image`` as the result of :func:`draw_arrow_display`, carrying ``diagnostic``. """``image`` as the result of :func:`draw_arrow_display`, carrying ``diagnostic``.
@@ -295,9 +307,7 @@ def _flow_result(image: np.ndarray, diagnostic=None) -> np.ndarray:
panel's job is ``code -> localized text``; this module deliberately carries no locale panel's job is ``code -> localized text``; this module deliberately carries no locale
string (see :data:`ARROW_RENDER_FAILED_MESSAGE`). string (see :data:`ARROW_RENDER_FAILED_MESSAGE`).
""" """
out = image.view(_DrawResult) return carry_frame_diagnostic(image, diagnostic)
out.frame_diagnostic = diagnostic
return out
class _DrawResult(np.ndarray): class _DrawResult(np.ndarray):
@@ -29,6 +29,7 @@ from typing import Any
import numpy as np import numpy as np
from orisys_app_shared.acquisition.arrows import ( from orisys_app_shared.acquisition.arrows import (
carry_frame_diagnostic,
depth_map_to_bgr, depth_map_to_bgr,
draw_arrow_display, draw_arrow_display,
flow_colormap, flow_colormap,
@@ -451,21 +452,25 @@ def render_image(
flow = frame.flow flow = frame.flow
if sdk_result: if sdk_result:
flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0) flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0)
return apply_orientation( drawn = draw_arrow_display(
draw_arrow_display( frame.image_bgr,
frame.image_bgr, flow,
flow, background="captured" if wanted == ARROW_STITCHED else "pure_color",
background="captured" if wanted == ARROW_STITCHED else "pure_color", canvas_bgr=canvas_bgr,
canvas_bgr=canvas_bgr, draw_style=draw_style,
draw_style=draw_style, shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result,
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result, valid_mask=frame.valid_mask,
valid_mask=frame.valid_mask, domain_mask=domain_mask,
domain_mask=domain_mask, colormap=flow_colormap(theme),
colormap=flow_colormap(theme), policy=arrow_policy,
policy=arrow_policy,
),
turn,
) )
# Orientation may rebuild the array; keep the draw failure on the result so the
# panel can say so instead of showing a silent black canvas.
diagnostic = getattr(drawn, "frame_diagnostic", None)
oriented = apply_orientation(drawn, turn)
if diagnostic is None:
return oriented
return carry_frame_diagnostic(oriented, diagnostic)
# ``recorded``: the frame is the picture, with nothing drawn over it — and the # ``recorded``: the frame is the picture, with nothing drawn over it — and the
# camera's own orientation, which no domain convention applies to. # camera's own orientation, which no domain convention applies to.
return ensure_bgr(np.asarray(frame.image_bgr)) return ensure_bgr(np.asarray(frame.image_bgr))
@@ -248,6 +248,11 @@ def select_flow_samples(
) * margin ) * margin
dx = dx * scale dx = dx * scale
dy = dy * scale dy = dy * scale
# Non-finite tips are discarded by ``valid`` below; zero them before the integer
# cast so numpy does not warn on every sparse recovered frame during replay.
finite_tip = np.isfinite(dx) & np.isfinite(dy)
dx = np.where(finite_tip, dx, 0.0)
dy = np.where(finite_tip, dy, 0.0)
end_x = np.clip(np.round(cc + dx).astype(np.int32), 0, w - 1) end_x = np.clip(np.round(cc + dx).astype(np.int32), 0, w - 1)
end_y = np.clip(np.round(rr + dy).astype(np.int32), 0, h - 1) end_y = np.clip(np.round(rr + dy).astype(np.int32), 0, h - 1)
+13
View File
@@ -145,3 +145,16 @@ def test_paint_domain_background_colours() -> None:
) )
assert tuple(canvas[0, 0]) == (1, 2, 3) assert tuple(canvas[0, 0]) == (1, 2, 3)
assert tuple(canvas[1, 1]) == (10, 20, 30) assert tuple(canvas[1, 1]) == (10, 20, 30)
def test_select_flow_samples_screens_non_finite_tips() -> None:
"""Sparse recovered fields put NaN off the recorded cells; cast must stay quiet."""
h, w = 30, 30
flow = np.full((h, w, 2), np.nan, dtype=np.float64)
flow[15, 15] = (0.5, 0.0)
policy = VisualPolicy(flow_on_px=0.4, flow_grid_spacing=10, flow_edge_margin=0)
with np.errstate(invalid="raise"):
samples = select_flow_samples(flow, None, policy)
pairs = set(zip(samples.rows.tolist(), samples.cols.tolist()))
assert (15, 15) in pairs
assert samples.active_count >= 1