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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -280,6 +280,18 @@ def magnitude_to_ramp_index(
|
||||
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:
|
||||
"""``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
|
||||
string (see :data:`ARROW_RENDER_FAILED_MESSAGE`).
|
||||
"""
|
||||
out = image.view(_DrawResult)
|
||||
out.frame_diagnostic = diagnostic
|
||||
return out
|
||||
return carry_frame_diagnostic(image, diagnostic)
|
||||
|
||||
|
||||
class _DrawResult(np.ndarray):
|
||||
|
||||
@@ -29,6 +29,7 @@ from typing import Any
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.acquisition.arrows import (
|
||||
carry_frame_diagnostic,
|
||||
depth_map_to_bgr,
|
||||
draw_arrow_display,
|
||||
flow_colormap,
|
||||
@@ -451,21 +452,25 @@ def render_image(
|
||||
flow = frame.flow
|
||||
if sdk_result:
|
||||
flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0)
|
||||
return apply_orientation(
|
||||
draw_arrow_display(
|
||||
frame.image_bgr,
|
||||
flow,
|
||||
background="captured" if wanted == ARROW_STITCHED else "pure_color",
|
||||
canvas_bgr=canvas_bgr,
|
||||
draw_style=draw_style,
|
||||
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result,
|
||||
valid_mask=frame.valid_mask,
|
||||
domain_mask=domain_mask,
|
||||
colormap=flow_colormap(theme),
|
||||
policy=arrow_policy,
|
||||
),
|
||||
turn,
|
||||
drawn = draw_arrow_display(
|
||||
frame.image_bgr,
|
||||
flow,
|
||||
background="captured" if wanted == ARROW_STITCHED else "pure_color",
|
||||
canvas_bgr=canvas_bgr,
|
||||
draw_style=draw_style,
|
||||
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result,
|
||||
valid_mask=frame.valid_mask,
|
||||
domain_mask=domain_mask,
|
||||
colormap=flow_colormap(theme),
|
||||
policy=arrow_policy,
|
||||
)
|
||||
# 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
|
||||
# camera's own orientation, which no domain convention applies to.
|
||||
return ensure_bgr(np.asarray(frame.image_bgr))
|
||||
|
||||
@@ -248,6 +248,11 @@ def select_flow_samples(
|
||||
) * margin
|
||||
dx = dx * 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_y = np.clip(np.round(rr + dy).astype(np.int32), 0, h - 1)
|
||||
|
||||
|
||||
@@ -145,3 +145,16 @@ def test_paint_domain_background_colours() -> None:
|
||||
)
|
||||
assert tuple(canvas[0, 0]) == (1, 2, 3)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user