三维设置加箭头阈值,静默处理网格冲突

对话框写回 flow_on_px(二维三维共用),去掉 analysis_grid_mismatch 告警,缩短 UV/形变等工程腔文案并认 source=analysis。已用 pane UI 与 controller 单测验证。
This commit is contained in:
ykcheungab
2026-10-08 16:01:15 +08:00
parent f2bbab76a6
commit f2d7b3e83c
7 changed files with 130 additions and 43 deletions
+9 -8
View File
@@ -225,7 +225,7 @@
"depth_unavailable": "No depth data for this frame",
"depth_scale": "Depth scale {low} – {high}",
"arrow_scale": "Arrows: displacement ×{factor}",
"arrow_scale_tip": "Display magnification of the relative displacement: the arrows are drawn from the algorithm output of the captured image, with the measured displacement multiplied by this factor and capped at 0.8 of the grid spacing (a capped arrow no longer shows its true factor).",
"arrow_scale_tip": "Arrow length is magnified by displacement.",
"arrow_pipeline": "Captured image → algorithm output",
"arrow_pipeline_tip": "Arrows are drawn over the algorithm output, not over the raw camera frame: points below the threshold get no arrow",
"arrow_area_masked": "Valid area",
@@ -315,7 +315,6 @@
"analysis_cancelled": "the analysis was cancelled: no complete result was written, so no playback is served",
"analysis_no_source": "this recording has no video segment to analyse",
"analysis_busy": "an analysis is already running: wait for it, or cancel it",
"analysis_grid_mismatch": "the current display settings moved the sample grid ({keys}); the stored candidate locations cannot serve it, so the replay keeps drawing the result's own grid",
"analysis_no_chain": "this recording does not say which sensor and configuration produced it ({error}), so no result can be read and no pass can be run",
"analysis_setup_declined": "no analysis was set up for this video: the setup prompt was dismissed, so there is nothing to draw from. Use Analysis ▸ Analysis setup… or open the video again"
},
@@ -504,7 +503,7 @@
"housing": "Housing",
"deform": "Exaggerate",
"deform_tip": "Display-only magnification of the measured deformation. It changes the picture and nothing else: the recorded data, the calibration and the camera are not touched.",
"deform_unavailable": "This data source provides no 3D displacement: the pane can only colour the surface, so exaggeration is unavailable.",
"deform_unavailable": "This data has no 3D displacement; exaggeration is unavailable.",
"field": "Colour by",
"field_tip": "Colour the surface by depth or optical flow.",
"field_depth": "Depth response",
@@ -512,10 +511,12 @@
"field_substituted": "This frame shows {shown}, not the chosen {chosen}.",
"flow_arrows": "Surface flow arrows",
"flow_arrows_tip": "Draw optical-flow arrows on the sensing surface using the same sample points as the 2D view.",
"flow_arrows_unavailable": "This scene package has no UV→scene surface mapping, so flow arrows cannot be drawn.",
"flow_arrows_unavailable": "This scene cannot draw flow arrows.",
"contact_arrow": "Contact normal arrow",
"contact_arrow_tip": "Draw a yellow local-normal arrow at the contact centre (length from the normal response, capped — not a calibrated force vector).",
"contact_arrow_unavailable": "This scene package has no UV→scene surface mapping, so the contact arrow cannot be drawn.",
"contact_arrow_unavailable": "This scene cannot draw the contact arrow.",
"flow_threshold": "Arrow threshold",
"flow_threshold_tip": "Arrows below this displacement (px) are not drawn; shared by 2D and 3D.",
"quantity_flow_magnitude": "Optical flow magnitude",
"quantity_depth_response": "Depth response",
"preset_default": "Default view",
@@ -556,9 +557,9 @@
"severity_info": "Note",
"severity_warning": "Warning",
"severity_error": "Error",
"stats": "shown {displayed} · display drops {dropped} · compose {compose} ms · paint {paint} ms",
"source_analysis": "Analysis result: one finished pass, read by frame",
"source_analysis_tip": "The picture comes from a stored analysis result: the chain has already run and its output is on disk, so scrubbing and playing are file reads. Changing a threshold or a colour does not recompute."
"stats": "shown {displayed} · frames not shown in time {dropped} · compose {compose} ms · paint {paint} ms",
"source_analysis": "Analysis result: read by frame",
"source_analysis_tip": "The picture comes from a stored analysis result. Changing a threshold or colour does not recompute."
},
"analysis": {
"title": "Analysing the recording",
+9 -8
View File
@@ -225,7 +225,7 @@
"depth_unavailable": "本帧没有深度数据",
"depth_scale": "深度色标 {low} – {high}",
"arrow_scale": "箭头按位移放大 ×{factor}",
"arrow_scale_tip": "相对位移的显示放大倍率:箭头由原始采集图像经算法输出,长度按测量位移放大后绘制,上限为网格间距的 0.8 倍;触顶的箭头不再呈现真实倍率。",
"arrow_scale_tip": "箭头长度按位移放大显示。",
"arrow_pipeline": "原始图像 → 算法输出",
"arrow_pipeline_tip": "箭头画在算法输出上,不是相机原始帧:阈值以下的区域不画箭头",
"arrow_area_masked": "有效区域",
@@ -315,7 +315,6 @@
"analysis_cancelled": "分析已取消:未写出完整结果,因此不提供回放",
"analysis_no_source": "这段录制没有可用于分析的视频分段",
"analysis_busy": "分析进行中:先等待或取消当前这一遍",
"analysis_grid_mismatch": "当前显示设置改了采样网格({keys}),存下来的候选位置无法服务它:回放仍按结果自己的网格绘制",
"analysis_no_chain": "这条录像没有说明用哪套传感器与配置录下来({error}),因此既无法读取结果,也无法重算",
"analysis_setup_declined": "这段视频没有建立分析:设置提示被关掉了,因此没有可画的东西。用「分析 ▸ 分析设置…」或重新打开视频即可分析"
},
@@ -504,7 +503,7 @@
"housing": "显示外壳",
"deform": "放大形变",
"deform_tip": "只放大画面上的形变显示。它只改图像:记录的数据、标定与算法相机都不受影响。",
"deform_unavailable": "当前数据源未提供三维位移:本面板只有光流幅值的曲面着色,形变放大不可用。",
"deform_unavailable": "当前数据没有三维位移,形变放大不可用。",
"field": "颜色来源",
"field_tip": "用深度或光流给表面着色。",
"field_depth": "深度响应",
@@ -512,10 +511,12 @@
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
"flow_arrows": "表面光流箭头",
"flow_arrows_tip": "在传感表面上按与二维相同的采样点绘制光流箭头。",
"flow_arrows_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制光流箭头。",
"flow_arrows_unavailable": "当前场景画不了光流箭头。",
"contact_arrow": "接触法向箭头",
"contact_arrow_tip": "在接触中心沿局部法向绘制黄色示意箭头(长度由法向响应限幅,不是标定合力)。",
"contact_arrow_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制接触法向箭头。",
"contact_arrow_unavailable": "当前场景画不了接触箭头。",
"flow_threshold": "箭头阈值",
"flow_threshold_tip": "低于此位移(像素)不画箭头;二维与三维共用。",
"quantity_flow_magnitude": "光流幅值",
"quantity_depth_response": "深度响应",
"preset_default": "默认视角",
@@ -556,9 +557,9 @@
"severity_info": "提示",
"severity_warning": "警告",
"severity_error": "错误",
"stats": "已显示 {displayed} · 显示跳帧 {dropped} · 合成 {compose} ms · 绘制 {paint} ms",
"source_analysis": "分析结果:已跑完的一遍,按帧读取",
"source_analysis_tip": "画面来自存下来的分析结果:算法链路已经跑完并写入缓存,拖动时间轴与播放都只是读文件。换阈值或配色不会重算。"
"stats": "已显示 {displayed} · 未赶上显示的帧 {dropped} · 合成 {compose} ms · 绘制 {paint} ms",
"source_analysis": "分析结果:按帧读取",
"source_analysis_tip": "画面来自已存的分析结果。改阈值或配色不会重算。"
},
"analysis": {
"title": "正在分析录像",
@@ -51,7 +51,6 @@ from orisys_studio.replay_analysis import (
AnalysisSession,
ResultLibrary,
ResultPolicyError,
policy_divergence,
)
from orisys_studio.shell_ports import ReplayShell
@@ -776,27 +775,19 @@ class ReplayController(QObject):
state this view is served in.
A policy that moves the sample *grid* cannot be served by the stored candidate
locations; :meth:`AnalysisSession.serve_policy` refuses it, the message names the
keys, and the frame is still redrawn from the result's own grid, because
re-gridding it would draw samples nothing measured.
locations; :meth:`AnalysisSession.serve_policy` refuses it and the frame is
still redrawn from the result's own grid — without a status-bar warning —
because re-gridding it would draw samples nothing measured.
"""
session = self.available_result()
if session is None:
return
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
policy = policy_for_settings(settings)
if not session.serve_policy(policy):
# A box result's grid is the live painter's, not a display preference.
# The display policy is the shared selector's, so the two always differ,
# and saying so would scold the operator for a grid they cannot change.
from orisys_app_shared.acquisition.arrows import uses_sdk_box_grid
if not uses_sdk_box_grid(session.stored_policy()):
moved = policy_divergence(session.stored_policy(), policy)
self._host.notify(
t("playback.analysis_grid_mismatch", keys="·".join(moved))
)
# A policy that moves the sample grid is refused inside ``serve_policy``;
# the frame is still redrawn from the result's own grid. No status-bar
# lecture: the operator cannot change the stored locations from here.
session.serve_policy(policy_for_settings(settings))
self._engine.redraw()
def on_analysis_requested(self) -> None:
+37 -10
View File
@@ -155,6 +155,10 @@ class ThreeDPanel(QFrame):
#: Whether the pane explains the pipeline around the picture (the window's
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
self._show_diagnostics = False
#: Sample-grid fields have no controls: they stay at the values restored from
#: prefs (or the dataclass defaults) so a colour-scale click cannot reset them.
self._flow_grid_spacing = 10
self._max_arrows = 400
root = QVBoxLayout(self)
root.setContentsMargins(8, 6, 8, 6)
@@ -244,6 +248,20 @@ class ThreeDPanel(QFrame):
self.contact_arrow_check.toggled.connect(self._emit_settings)
form.addRow(self.contact_arrow_check)
# One threshold for both the 2D canvas and the 3D overlays. ``flow_off_px``
# is derived (on − 0.1): neither view applies hysteresis today, and a band
# only one side honoured was the 2D/3D arrow-count mismatch.
self.flow_threshold_label = QLabel()
self.flow_threshold_spin = QDoubleSpinBox()
self.flow_threshold_spin.setRange(0.0, 50.0)
self.flow_threshold_spin.setDecimals(2)
self.flow_threshold_spin.setSingleStep(0.1)
self.flow_threshold_spin.setSuffix(" px")
self.flow_threshold_spin.setValue(0.4)
self.flow_threshold_spin.setMaximumWidth(SPIN_WIDTH)
self.flow_threshold_spin.valueChanged.connect(self._emit_settings)
form.addRow(self.flow_threshold_label, self.flow_threshold_spin)
self.scale_label = QLabel()
self.scale_combo = QComboBox()
self.scale_combo.addItem("fixed", "fixed")
@@ -539,20 +557,15 @@ class ThreeDPanel(QFrame):
self.state_label.setVisible(self._show_diagnostics and state != "live")
def set_source_kind(self, kind: str) -> None:
"""Say where the field comes from: ``live``, ``replay`` or ``recompute``.
Three states, and the difference between the last two is the point of the
whole replay story:
"""Say where the field comes from: live, replay, recompute or analysis.
* ``live`` — acquired from the device now;
* ``replay`` — a recording is open and **no** 3D field is stored in it, so the
pane is showing the surface model only;
* ``recompute`` — a recompute pass is running over the recording's video, so
what is on screen is what *this* algorithm version says about that footage.
It is not the recorded measurement, and the label says so.
* ``replay`` — a recording is open and **no** 3D field is stored in it;
* ``recompute`` — a recompute pass is running over the recording's video;
* ``analysis`` — a finished stored result is being read frame by frame.
"""
kind = str(kind)
if kind not in ("live", "replay", "recompute"):
if kind not in ("live", "replay", "recompute", "analysis"):
kind = "live"
self._source_kind = kind
self.source_label.setText(t(f"scene3d.source_{kind}"))
@@ -661,6 +674,7 @@ class ThreeDPanel(QFrame):
self.gain_spin,
self.flow_arrows_check,
self.contact_arrow_check,
self.flow_threshold_spin,
self.scale_combo,
self.fps_combo,
self.reset_button,
@@ -694,6 +708,9 @@ class ThreeDPanel(QFrame):
self.field_combo.setCurrentIndex(index)
self.flow_arrows_check.setChecked(bool(settings.flow_arrows_enabled))
self.contact_arrow_check.setChecked(bool(settings.contact_arrow_enabled))
self.flow_threshold_spin.setValue(float(settings.flow_on_px))
self._flow_grid_spacing = max(1, int(settings.flow_grid_spacing))
self._max_arrows = max(1, int(settings.max_arrows))
self.depth_adaptive_check.setChecked(bool(settings.depth_scale_adaptive))
scale = settings.color_scale
if scale is not None:
@@ -725,6 +742,9 @@ class ThreeDPanel(QFrame):
deform = bool(self.deform_check.isChecked()) and bool(
self._capabilities and self._capabilities.displacement_available
)
flow_on = max(0.0, float(self.flow_threshold_spin.value()))
# Derived off band: kept for prefs / VisualPolicy, not applied as hysteresis.
flow_off = max(0.0, flow_on - 0.1)
return DisplaySettings(
display_gain=float(self.gain_spin.value()),
deform_enabled=deform,
@@ -740,6 +760,10 @@ class ThreeDPanel(QFrame):
field=self.field(),
flow_arrows_enabled=bool(self.flow_arrows_check.isChecked()),
contact_arrow_enabled=bool(self.contact_arrow_check.isChecked()),
flow_on_px=flow_on,
flow_off_px=flow_off,
flow_grid_spacing=int(self._flow_grid_spacing),
max_arrows=int(self._max_arrows),
depth_scale_adaptive=bool(self.depth_adaptive_check.isChecked()),
)
@@ -810,6 +834,9 @@ class ThreeDPanel(QFrame):
self._render_field_note()
self.flow_arrows_check.setText(t("scene3d.flow_arrows"))
self.contact_arrow_check.setText(t("scene3d.contact_arrow"))
self.flow_threshold_label.setText(t("scene3d.flow_threshold"))
self.flow_threshold_label.setToolTip(t("scene3d.flow_threshold_tip"))
self.flow_threshold_spin.setToolTip(t("scene3d.flow_threshold_tip"))
self.scale_label.setText(t("scene3d.scale"))
self.range_label.setText(t("scene3d.scale_range"))
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
@@ -49,7 +49,8 @@ class FingerOrientationDialog(QDialog):
self.orientation_note.setWordWrap(True)
form.addRow(self.orientation_label, self.orientation_combo)
form.addRow(self.orientation_note)
# Tip lives on the combo; the note repeated the same sentence.
self.orientation_note.setVisible(False)
root.addLayout(form)
self.button_box = QDialogButtonBox(
+35
View File
@@ -978,10 +978,45 @@ def test_the_controller_redraws_on_a_display_change_and_runs_no_pass(pass_root):
assert session.threshold() == pytest.approx(0.5)
# The frame is rebuilt from the same stored record, not from a new one.
assert session.frame(int(session.frame_ids[0])).frame_id == FIRST_FRAME_ID
assert host.notes == [], "a threshold change must not lecture about the grid"
finally:
session.reader.close()
def test_a_refused_grid_change_redraws_without_a_status_warning():
"""Moving max_arrows cannot be served; the frame is redrawn and nothing is said.
No evidence recording: the session is a stub whose ``serve_policy`` refuses, which
is exactly the path that used to push ``playback.analysis_grid_mismatch``.
"""
class _Reader:
def close(self) -> None:
pass
class _Session:
def __init__(self) -> None:
self.calls: list = []
self.reader = _Reader()
def serve_policy(self, policy) -> bool:
self.calls.append(policy)
return False
def close(self) -> None:
pass
controller, host, engine, parts = _controller()
session = _Session()
controller.results.open_session(session) # type: ignore[arg-type]
engine.use_analysis(session)
engine.redraws = 0
controller.on_display_settings(DisplaySettings(max_arrows=50))
assert engine.redraws == 1, "the refused policy still redraws the stored frame"
assert len(session.calls) == 1
assert host.notes == [], "a grid mismatch must not reach the status bar"
controller.results.close()
# ------------------------------------------------------------ analysis on open
def test_a_running_session_does_not_hide_a_stored_result(pass_root):
"""A result on disk is served while a session is open: reading it opens no chain.
+31
View File
@@ -56,6 +56,7 @@ def test_the_pane_holds_a_legend_and_no_settings(panel):
panel.housing_check,
panel.deform_check,
panel.gain_spin,
panel.flow_threshold_spin,
panel.scale_combo,
panel.depth_adaptive_check,
panel.fps_combo,
@@ -106,6 +107,36 @@ def test_adaptive_depth_scale_round_trips_through_the_dialog(panel):
assert panel.settings().depth_scale_adaptive is False
def test_arrow_threshold_round_trips_and_feeds_the_shared_policy(panel):
"""The threshold the dialog writes is the one both views sample with."""
from orisys_app_shared.scene3d.contracts import DisplaySettings
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
panel.apply_settings(
DisplaySettings(flow_on_px=0.9, flow_grid_spacing=12, max_arrows=200)
)
assert panel.flow_threshold_spin.value() == pytest.approx(0.9)
settings = panel.settings()
assert settings.flow_on_px == pytest.approx(0.9)
assert settings.flow_off_px == pytest.approx(0.8)
assert settings.flow_grid_spacing == 12
assert settings.max_arrows == 200
# A colour-scale click must not wipe the held grid or the typed threshold.
panel.housing_check.setChecked(True)
again = panel.settings()
assert again.flow_on_px == pytest.approx(0.9)
assert again.flow_grid_spacing == 12
assert policy_for_settings(again).flow_on_px == pytest.approx(0.9)
def test_source_kind_analysis_uses_its_own_label(panel):
"""A stored result is not labelled as live acquisition."""
panel.set_diagnostics_visible(True)
panel.set_source_kind("analysis")
assert panel.source_label.text() == t("scene3d.source_analysis")
assert panel.source_label.toolTip() == t("scene3d.source_analysis_tip")
def test_the_pane_header_offers_the_two_actions(qt_app):
"""A gear and a view reset: the two things that belong to *looking*."""
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel