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",
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user