fixed commetn from songxiao

This commit is contained in:
ykcheungab
2026-09-25 17:46:37 +08:00
parent 6effe74172
commit 12781854ca
56 changed files with 4935 additions and 419 deletions
+21
View File
@@ -46,6 +46,9 @@
"stop": "Cancel acquisition",
"preview": "Preview / Stop preview",
"reset": "Reset tracker",
"reset_view": "Reset views",
"reset_view_tip": "Put the chart window, cursor and 3D camera back to their defaults (F8). Touches neither the device nor the zero estimate.",
"pause": "Pause / resume (F7)",
"help": "Help",
"about": "About"
},
@@ -53,14 +56,22 @@
"plot_settings_title": "Plot settings",
"plot_window": "Time window",
"plot_window_tip": "Force plot time window (seconds)",
"plot_fn_min": "FN min",
"plot_fn_min_tip": "Normal-force axis minimum (Newton mode). It may be negative: leaving room below zero keeps the idle curve off the FX frame and makes small presses visible",
"plot_fn_max": "FN max",
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
"plot_shear_max": "Shear max",
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
"plot_fn_raw_min": "FN raw min",
"plot_fn_raw_min_tip": "Normal-force axis minimum in raw (uncalibrated) mode; may be negative",
"plot_fn_raw_max": "FN raw max",
"plot_fn_raw_max_tip": "Normal-force axis max in raw (uncalibrated) mode. 0 = auto range on the data; the default is sized so idle jitter is invisible in the plot",
"plot_shear_raw_max": "Shear raw max",
"plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range",
"plot_deadband_n": "Zero threshold (N)",
"plot_deadband_n_tip": "Display deadband floor in Newton mode: a reading inside the threshold shows as 0 and the curve flattens on the zero line. 0 = auto (the noise estimated from the quiet startup window; Newton mode has no fixed floor). Display only — the record and forces.csv keep the measured values",
"plot_deadband_raw": "Zero threshold (raw)",
"plot_deadband_raw_tip": "Display deadband floor in raw mode (raw counts). 0 = auto (the built-in 50-count floor). Raise it by hand when the calibration data is suspect; display only",
"reset_defaults": "Reset to defaults",
"plot_filters": "Filters (applied while acquiring; screen and record agree)",
"filter_preset": "Preset",
@@ -115,6 +126,13 @@
"playback_needs_recording": "This video has no recording beside it (no forces.csv): replay needs a recording; to just watch the video use View ▸ Recompute forces from the video",
"playback_video_only": "Play this video (no recording beside it: the picture plays, the force charts are empty)",
"reset": "Reset",
"reset_tip": "Zero the device tracker. This affects the zero point only, never the view — to put the views back use View → Reset views.",
"pause": "⏸ Pause",
"pause_resume": "▶",
"pause_tip": "Pause the computation and the display (F7): no flow, depth or contact is computed, the picture holds its last frame, and the device stays connected for an immediate resume. A recording that spans a pause has a gap.",
"resume_tip": "Resume the computation and the display (F7), from the newest frame.",
"resume_label": "▶ Resume",
"resume_label_tip": "Resume the computation and the display (F7): it carries on from the newest frame with the device still connected.",
"stop_playback": "Stop",
"stop_playback_tip": "Stop the replay: plain playback and a “recompute from the video” pass both stop here (Esc does the same).",
"stopped": "Stopped: {what}",
@@ -400,6 +418,9 @@
"camera_disconnected": "Camera disconnected",
"reset_ok": "Tracker reset",
"reset_pending": "Zeroing requested; waiting for the acquisition thread to confirm…",
"view_reset": "Views reset",
"paused": "Paused: nothing is computed, the device stays connected",
"resumed": "Resumed",
"finger_run_unavailable": "Run is not available for this sensor type",
"video_required": "Select a video file first",
"config_missing": "Sensor config not found for {type}",
+22 -1
View File
@@ -46,6 +46,9 @@
"stop": "取消采集",
"preview": "预览 / 停止预览",
"reset": "重置追踪器",
"reset_view": "复位视图",
"reset_view_tip": "把曲线窗口、游标与三维视角恢复为默认(F8)。不改动设备,也不清除零点估计。",
"pause": "暂停 / 继续(F7)",
"help": "帮助",
"about": "关于"
},
@@ -53,14 +56,22 @@
"plot_settings_title": "绘图设置",
"plot_window": "时间窗口",
"plot_window_tip": "力曲线时间窗口(秒)",
"plot_fn_min": "FN 下限",
"plot_fn_min_tip": "法向力纵轴下限(牛顿模式)。可以是负数:留出零点以下的空间,静止曲线不会压在 FX 框线上,小压力也更清楚",
"plot_fn_max": "FN 上限",
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
"plot_shear_max": "剪切上限",
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
"plot_fn_raw_min": "FN 原始下限",
"plot_fn_raw_min_tip": "未标定(原始)模式下的法向力纵轴下限,可为负",
"plot_fn_raw_max": "FN 原始上限",
"plot_fn_raw_max_tip": "未标定(原始)模式下的法向力纵轴上限。0 = 按数据自动量程;默认值按实测峰值设定,使静止时的抖动在图中不可见",
"plot_shear_raw_max": "原始剪切上限",
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
"plot_deadband_n": "归零阈值(N)",
"plot_deadband_n_tip": "牛顿模式下的显示死区下限:读数在阈值内即显示为 0,曲线压平在零线上。0 = 自动(用启动静默窗口估计的噪声,牛顿模式无固定下限)。只影响显示,记录与 forces.csv 仍是实测值",
"plot_deadband_raw": "归零阈值(原始)",
"plot_deadband_raw_tip": "原始模式下的显示死区下限(原始计数)。0 = 自动(默认下限 50 计数)。标定数据可疑时可手动放大,只影响显示",
"reset_defaults": "恢复默认",
"plot_filters": "滤波(作用于采集,界面与记录同步)",
"filter_preset": "预设",
@@ -107,7 +118,7 @@
"run_unavailable": "当前不可运行",
"run_disabled_video": "请先选择一个视频文件",
"run_disabled_sensor": "此传感器类型暂不支持运行",
"playback": "▶ 回放",
"playback": "▶ 播放",
"playback_pause": "⏸ 暂停",
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
@@ -115,6 +126,13 @@
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值",
"playback_video_only": "播放这段视频(没有配套记录:可以看画面,力曲线为空)",
"reset": "重置",
"reset_tip": "把设备追踪器归零(清零)。只影响零点,不动界面;想恢复视图请用「视图 → 复位视图」。",
"pause": "⏸ 暂停",
"pause_resume": "▶",
"pause_tip": "暂停计算与显示(F7):不再做光流/深度/接触计算,画面停在最后一帧,设备保持连接,随时可继续。跨过暂停的记录会有一段空档。",
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
"resume_label": "▶ 继续",
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
"stop_playback": "停止",
"stop_playback_tip": "停止回放:无论是普通播放还是「从视频重算」通道,都在这里停下(Esc 同效)。",
"stopped": "已停止:{what}",
@@ -400,6 +418,9 @@
"camera_disconnected": "摄像机已断开",
"reset_ok": "追踪器已重置",
"reset_pending": "已请求归零,等待采集线程确认…",
"view_reset": "视图已复位",
"paused": "已暂停:不再计算,设备保持连接",
"resumed": "已继续",
"finger_run_unavailable": "此传感器类型暂不支持运行",
"video_required": "请先选择视频文件",
"config_missing": "未找到传感器配置: {type}",
+5 -1
View File
@@ -15,17 +15,21 @@
"scene3d_display": {
"deform_enabled": false,
"display_gain": 0.0,
"housing_visible": true,
"housing_visible": false,
"max_fps": 30
},
"sensor_type": "box",
"config_name": "",
"units": "newton",
"plot_window_sec": 4.0,
"plot_fn_min": 0.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
"plot_fn_raw_min": 0.0,
"plot_fn_raw_max": 1000000.0,
"plot_shear_raw_max": 500000.0,
"plot_deadband_n": 0.0,
"plot_deadband_raw": 0.0,
"image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90,
"settings_expanded": false,
@@ -0,0 +1,44 @@
{
"name": "mems-prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365, 263, 123, 63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1, 2, 3, 6, 7, 8, 11, 12, 13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3, 3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend": "numba",
"blend_list": ["numpy", "numba", "none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 5,
"div_threshold": 0.05,
"expansion_threshold": 0.01,
"kernel_size": [5, 5],
"denoise_sigma": 12
}
}
@@ -6,7 +6,7 @@
"resolution_raw": [1280, 720],
"fps": 30,
"resolution_stitch": [280, 300],
"source_scale": 0.4,
"source_scale": 0.42,
"old":[672, 728, 1,0.5],
"work": [504, 546,1],
"work2": [420, 450,0.8],
+225 -38
View File
@@ -76,8 +76,12 @@ from orisys_studio.ui.toolbar import ToolbarWidget
logger = logging.getLogger(__name__)
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
#: Types the Run button accepts. ``finger-mems`` is a studio-side product (deprecated in the
#: SDK) that reuses the box/mini pipeline, so it is runnable exactly like box.
RUNNABLE_TYPES = frozenset({"box", "mini", "finger", "finger-mems"})
#: Types whose 2D arrow view is rotated 90° (the sensor is mounted that way). finger-mems is
#: a box-style stitched camera, so it rotates like box until the device says otherwise.
ARROW_ROTATE_TYPES = frozenset({"box", "mini", "finger-mems"})
DEFAULT_SPLITTER_SIZES = (620, 320, 500)
MIN_PANEL_WIDTHS = {
# The 3D pane used to be the one panel whose content was a stack of form fields, so
@@ -204,6 +208,10 @@ class StudioMainWindow(QMainWindow):
self.session.tick.connect(self._on_run_tick)
# The phase is the session's, not the window's; the chip is one view of it.
self.session.phaseChanged.connect(self._on_session_phase_changed)
#: True while the operator has paused the live session. The acquisition thread
#: stops computing; this flag stops the *display* half of the run tick, so a
#: frame that was already in flight cannot repaint the panels they paused on.
self._display_paused = False
self._active_calibration_id = ""
#: Last display-unit decision (``None`` before the first sample): the
#: status rail's calibration readout is derived from it, because an axis
@@ -738,6 +746,7 @@ class StudioMainWindow(QMainWindow):
self.toolbar.replay_video_clicked.connect(self.replay_ctl.on_open_recording)
self.toolbar.recompute_clicked.connect(self.replay_ctl.on_recompute_button)
self.toolbar.run_toggled.connect(self._on_run_toggled)
self.toolbar.stop_clicked.connect(self._on_stop_clicked)
self.toolbar.record_toggled.connect(self._on_record_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
self.toolbar.units_changed.connect(self.display_ctl.on_units_changed)
@@ -974,6 +983,22 @@ class StudioMainWindow(QMainWindow):
self.act_reset.setShortcut("Ctrl+R")
self.act_reset.triggered.connect(self._on_reset)
self.view_menu.addAction(self.act_reset)
# The graphics half of the old 重置: curves, window, cursor and the 3D camera.
# Separate because wanting a fresh device baseline is not the same intention as
# wanting the views back, and only the first one belongs to the device.
self.act_reset_view = QAction(self)
self.act_reset_view.setShortcut("F8")
self.act_reset_view.triggered.connect(self._on_reset_view)
self.view_menu.addAction(self.act_reset_view)
# Pause has a shortcut as well as a rail button: an operator who is holding a
# sample still wants to stop the numbers moving without reaching for the mouse.
self.act_pause = QAction(self)
self.act_pause.setShortcut("F7")
self.act_pause.setCheckable(True)
self.act_pause.toggled.connect(self._on_pause_action_toggled)
self.view_menu.addAction(self.act_pause)
self.view_menu.addSeparator()
self.theme_menu = self.view_menu.addMenu("")
@@ -1111,6 +1136,12 @@ class StudioMainWindow(QMainWindow):
return self.layout_ctl.view_preset
def _update_run_enabled(self) -> None:
"""What the rail's transport, stop and reset can do in the current view.
One place decides it for both views: the rail's buttons mean the same
things there (the transport is a play/pause toggle, stop ends what is
running), so a second copy of the rule is a second thing to keep in step.
"""
if self.toolbar.run_role() == "playback":
# In the replay view the button is a transport: it needs something to
# play — a recording, or a bare video — not a runnable sensor. While it
@@ -1131,14 +1162,16 @@ class StudioMainWindow(QMainWindow):
reason = t("toolbar.playback_disabled")
self.toolbar.set_run_tooltip_reason(reason)
self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
# The stop button is offered whenever there is something to stop; the
# operator must never have to guess whether that is the case.
if self.toolbar.reset_role() == "stop":
self.toolbar.set_reset_enabled(
# Stop leaves the replay, whatever it is doing: plain playback, a
# recompute pass, or a session the pass started. The operator must never
# have to work out which of those is stuck before they can stop it.
self.toolbar.set_stop_enabled(
self.replay_ctl.is_playing()
or self._recompute_running
or self._session_active
)
# The tracker reset belongs to a device session; this view has none.
self.toolbar.set_reset_enabled(False)
self.toolbar.set_record_enabled(
self._running or self.recorder.is_recording
)
@@ -1155,10 +1188,16 @@ class StudioMainWindow(QMainWindow):
source_ok = False
reason = t("toolbar.run_disabled_video")
self.toolbar.set_run_tooltip_reason(reason)
# While a session exists the transport stays enabled — pausing and resuming
# are the whole point of it being checked, not an exception to an idle rule.
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
# Recording needs a live session; while recording the button stays
# enabled so it can always be stopped.
self.toolbar.set_record_enabled(self._running or self.recorder.is_recording)
# Stop releases the device and ends the session. It covers a device open
# that hangs as well, so it is offered for as long as a session exists at
# all — including the moment the worker is still winding down.
self.toolbar.set_stop_enabled(self._session_active)
if not self._running:
self.toolbar.refresh_labels()
self._sync_shortcut_actions()
@@ -1189,6 +1228,10 @@ class StudioMainWindow(QMainWindow):
self.act_stop.setText(t("menu.stop"))
self.act_preview.setText(t("menu.preview"))
self.act_reset.setText(t("menu.reset"))
self.act_reset_view.setText(t("menu.reset_view"))
self.act_reset_view.setToolTip(t("menu.reset_view_tip"))
self.act_pause.setText(t("menu.pause"))
self._sync_pause_action()
self.theme_menu.setTitle(t("menu.theme"))
self.act_theme_dark.setText(t("menu.theme_dark"))
self.act_theme_light.setText(t("menu.theme_light"))
@@ -1494,10 +1537,17 @@ class StudioMainWindow(QMainWindow):
dialog = PlotSettingsDialog(
self,
window_sec=settings["window_sec"],
# FN's lower bound may be negative: that is the "show me below zero" the
# operator asked for, and it is what puts the 0 tick back on the axis.
fn_min=settings["fn_min"],
fn_max=settings["fn_max"],
shear_max=settings["shear_max"],
fn_raw_min=settings["fn_raw_min"],
fn_raw_max=settings["fn_raw_max"],
shear_raw_max=settings["shear_raw_max"],
# Display-only zeroing floors; 0 means "use the estimated band".
deadband_n=settings["deadband_n"],
deadband_raw=settings["deadband_raw"],
units=settings["units"],
filters=self._filter_settings(),
# Asked *now*: the answer is a property of this moment, and the
@@ -1507,16 +1557,9 @@ class StudioMainWindow(QMainWindow):
)
if dialog.exec() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
self.display_ctl.apply_plot_settings(
{
"window_sec": window_sec,
"fn_max": fn_max,
"shear_max": shear_max,
"fn_raw_max": fn_raw_max,
"shear_raw_max": shear_raw_max,
}
)
# One call for the whole picture: the dialog owns the schema, the controller
# writes what it was given and re-reads the rest.
self.display_ctl.apply_plot_settings(dialog.settings())
filters = dialog.filter_values()
# The same keys ``_filter_settings()`` reads back, so what the dialog
# shows next time is what the session will actually be given.
@@ -1718,20 +1761,43 @@ class StudioMainWindow(QMainWindow):
if getattr(self, "act_run", None) is None:
return
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
# The menu's Stop mirrors the toolbar's stop affordance, not the acquisition
# The menu's Stop mirrors the rail's Stop button, not the acquisition
# session alone: a replay or a recompute pass is something to stop.
#
# This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5
# did not) and never delivers the shortcut of a disabled action, so a Stop that
# is disabled while a replay is playing is a keyboard escape hatch that does not
# work — the one thing ``_on_stop_shortcut`` exists to guarantee.
self.act_stop.setEnabled(
self._session_active
or self.replay_ctl.is_playing()
or self._recompute_running
)
self.act_stop.setEnabled(self.toolbar.btn_stop.isEnabled())
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
# A live pause is only meaningful while the pipeline is running; in the replay
# view its own transport owns play/pause.
self.act_pause.setEnabled(
self.toolbar.run_role() != "playback" and self._running
)
self.act_pause.setToolTip(
t("toolbar.resume_tip") if self.act_pause.isChecked() else t("toolbar.pause_tip")
)
def _sync_pause_action(self) -> None:
"""The View-menu action follows the session; it never leads it.
The rail's transport owns the pause state (checked = acquiring and not
paused, unchecked with a session = paused), so the action is a mirror and a
shortcut into it — two controls that cannot disagree.
"""
action = getattr(self, "act_pause", None)
if action is None:
return
paused = bool(self._running and self._display_paused)
if action.isChecked() != paused:
blocked = action.blockSignals(True)
action.setChecked(paused)
action.blockSignals(blocked)
action.setToolTip(
t("toolbar.resume_tip") if paused else t("toolbar.pause_tip")
)
def _on_play_shortcut(self) -> None:
"""Space plays or pauses the recording, if there is one to move through."""
@@ -1753,6 +1819,14 @@ class StudioMainWindow(QMainWindow):
``_sync_shortcut_actions`` enables the action for, so the action can never be
enabled with nothing behind it.
"""
self._stop_activity()
def _stop_activity(self) -> None:
"""End whatever this view is doing. The rail's Stop and Esc share this body.
One implementation, because the button and the shortcut are the same act:
two copies would be two answers to "what does Stop stop".
"""
if self.toolbar.run_role() == "playback":
if (
self.replay_ctl.is_playing()
@@ -1765,6 +1839,10 @@ class StudioMainWindow(QMainWindow):
return
self._stop_run(reason="user_stop")
def _on_stop_clicked(self) -> None:
"""The rail's Stop button — the same escape hatch Esc reaches."""
self._stop_activity()
def _on_preview_shortcut(self) -> None:
if not self.toolbar.btn_preview.isEnabled():
return
@@ -1876,14 +1954,28 @@ class StudioMainWindow(QMainWindow):
self._show_image_frame(ImageFrame(frame, source=SOURCE_PREVIEW))
def _on_run_toggled(self, checked: bool) -> None:
"""The rail's transport. One button, three states, routed by the session.
``checked`` means "acquiring and not paused" in the live view and "playing"
in the replay view. Unchecked is ambiguous on its own — it is *idle* when no
session exists and *paused* when one does — so whether a session is already
running is what decides between starting and resuming, and between pausing
and doing nothing. Stopping is never this button's job: it has its own.
"""
if self.toolbar.run_role() == "playback":
# The primary button of the replay view is its transport.
self.replay_ctl.on_transport_toggled(bool(checked))
return
if checked:
self._start_run()
if self._running:
# A live session already exists and the button was unchecked: that
# is the paused state, so checking it again means resume.
self._on_pause_toggled(False)
else:
self._stop_run(reason="user_stop")
self._start_run()
return
if self._running:
self._on_pause_toggled(True)
def _start_run(self) -> None:
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
@@ -1919,11 +2011,11 @@ class StudioMainWindow(QMainWindow):
self.force_plots.configure_presentation(
sensor_type=sensor_type,
unit=units,
enabled=(
sensor_type == "finger"
and units == "raw"
and bool(self.prefs.get("raw_auto_zero", True))
),
# The operator's own switch, and only that: the aid covers every sensor type and
# both units, which is the point of it (an offset reads as an offset in newtons
# too). This used to be a ``finger and raw`` conjunction, which silently disabled
# it for every other product.
enabled=bool(self.prefs.get("raw_auto_zero", True)),
)
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
@@ -1975,6 +2067,12 @@ class StudioMainWindow(QMainWindow):
# The session is now connecting: it owns the phase, the connect clock and the
# run timer's cadence (the timer *is* the session, so it lives with it).
self.session.mark_running()
# A session exists and is not paused, which is exactly what the transport's
# checked state means: stated here as well as by the click, because starting
# is not only ever reached through the button (a recompute pass starts the
# same session), and an unchecked transport beside a live session would read
# as "paused".
self.toolbar.set_run_checked(True)
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
self.cal_panel.set_running(True)
self.toolbar.set_source_controls_enabled(False)
@@ -2013,6 +2111,12 @@ class StudioMainWindow(QMainWindow):
self.toolbar.refresh_labels()
self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False)
# The session is over, so the transport must not stay latched in "paused" —
# the next run starts computing. The worker clears its own flag on start as
# well, so both sides agree even if this path is skipped (a stop from the
# acquisition thread).
self._display_paused = False
self._sync_pause_action()
self.toolbar.set_reset_enabled(False)
self.toolbar.set_source_controls_enabled(True)
self.sensor_picker.set_picker_enabled(True)
@@ -2064,28 +2168,110 @@ class StudioMainWindow(QMainWindow):
self._notify(t("toolbar.nothing_to_stop"))
def _on_reset(self) -> None:
if self.toolbar.reset_role() == "stop":
# In the replay view that button is the operator's way out of a replay or
# a recompute pass, whatever state the transport is in.
self.replay_ctl.stop_activity()
return
"""Zero the device tracker. Graphics are reset separately (``_on_reset_view``).
This used to call ``force_plots.reset()`` as well, so wanting a fresh baseline
also threw away the history, the window and the cursor on screen — two different
operator intentions on one button (and only one of them recorded, see
``note_reset_applied``). The display baseline is re-estimated instead of wiped:
a new device zero means the old idle estimate is stale, not that the operator
wants to lose what they were looking at.
It is only ever this: ending a replay or a recompute pass is the rail's Stop
button (the same act as Esc), so this button is disabled wherever there is no
device session to zero.
"""
if self._session_phase != "running" or self._active_worker is None:
return
try:
# A request, not the zeroing itself: the acquisition thread applies it
# between two frames and reports the frame and time it took effect.
self._active_worker.reset()
self.force_plots.reset()
self.force_plots.request_presentation_reestimate()
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.reset_pending"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
def _on_pause_toggled(self, paused: bool) -> None:
"""Pause the pipeline and the display; the device stays open.
Not a display freeze: the acquisition thread stops *computing* as well, so nothing
is spent on flow, depth or contact while an operator reads a result. What it
deliberately does not do is release the device — that is 停止 — so resuming is
immediate and the camera is not re-enumerated.
Consequence, stated rather than hidden: with no packets there is nothing to write,
so a recording that spans a pause has a **gap**. The store's own time base shows it
(sample times jump), and 停止 still ends the session normally.
Reached from the rail's transport (which owns the state) and from the View-menu
action / F7 (which mirrors it); never from two controls of its own.
"""
paused = bool(paused)
self._display_paused = paused
self._sync_pause_action()
worker = self._active_worker
if worker is not None:
try:
worker.pause() if paused else worker.resume()
except Exception as exc: # noqa: BLE001 - report, never raise from a click
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
return
if paused:
self.statusBar().showMessage(t("status.paused"))
else:
# The next packet is a new sample again: without this the first one after a
# resume can be swallowed as a repeat of the last key the tick saw.
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.resumed"))
def _on_pause_action_toggled(self, paused: bool) -> None:
"""The menu/shortcut half of pause: drive the rail's transport, which owns it.
The action is checked exactly while the session is paused, so pressing it
toggles the transport button — and that click is what routes to pause or
resume. Keeping one control authoritative means the button and the shortcut
can never disagree about whether the session is paused.
"""
paused = bool(paused)
if self.toolbar.run_role() == "playback" or not self._running:
# Nothing here to pause: put the action back rather than letting it
# advertise a state the session is not in.
self._sync_pause_action()
return
wanted = not paused
if self.toolbar.btn_run.isChecked() != wanted:
# ``setChecked`` emits, so the click takes the window's own transport
# path (start/resume/pause) instead of a second copy of that decision.
self.toolbar.btn_run.setChecked(wanted)
def _on_reset_view(self) -> None:
"""Put the views back to their defaults: curves, window, cursor and 3D camera.
Pressures the same state ``force_plots.reset()`` always cleared, now on its own
action, plus the 3D pane's camera. It touches nothing the device owns, so it is
always available — including while a replay is on screen.
"""
self.force_plots.reset()
panel = getattr(self, "mesh3d_panel", None)
if panel is not None:
try:
panel.reset_view()
except Exception: # noqa: BLE001 - a pane with no scene has no view to reset
logger.debug("3D view reset skipped", exc_info=True)
self.statusBar().showMessage(t("status.view_reset"))
def _on_run_tick(self) -> None:
# The 3D pane advances on this timer rather than one of its own: the window's
# refresh rate is then the only one that exists, and hiding or disabling the
# pane cannot leave a timer behind. It starts at most one compose on the
# pane's own worker and takes at most one finished frame.
if not self._display_paused:
# While paused the pane holds the frame it had. The acquisition thread has
# already stopped computing, so this only matters for a frame that was in
# flight when the operator pressed pause — which must not be the one frame
# they cannot see.
self.mesh3d_panel.tick()
if self._active_worker is None:
return
@@ -2141,7 +2327,7 @@ class StudioMainWindow(QMainWindow):
# source slot names the camera, so "运行中(0)" was a second copy of
# the same fact that then stayed on screen for the rest of the session.
if is_new:
if is_new and not self._display_paused:
self._last_plot_packet_key = packet_key
# The live view consumes the *same* standardised measurement as the
# CSV and the session store: one conversion decides what a value
@@ -2227,8 +2413,9 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(dropped_message)
mode = self.arrow_panel.view_mode()
if (is_new or mode != self._last_rendered_view_mode) and not (
self._replay_owns_image()
if not self._display_paused and (
(is_new or mode != self._last_rendered_view_mode)
and not (self._replay_owns_image())
):
self._render_run_image(packet)
if is_new:
+104 -42
View File
@@ -23,8 +23,38 @@ from typing import Any, Optional, Protocol
from PySide6.QtCore import QObject
from orisys_studio.ui.plot_settings_dialog import (
DEFAULT_PLOT_DEADBAND_N,
DEFAULT_PLOT_DEADBAND_RAW,
DEFAULT_PLOT_FN_MIN,
DEFAULT_PLOT_FN_MAX,
DEFAULT_PLOT_FN_RAW_MIN,
DEFAULT_PLOT_FN_RAW_MAX,
DEFAULT_PLOT_SHEAR_MAX,
DEFAULT_PLOT_SHEAR_RAW_MAX,
DEFAULT_PLOT_WINDOW_SEC,
)
logger = logging.getLogger(__name__)
#: The chart range, per channel and per unit. ``fn_min`` is the one that may be
#: negative (FN is asymmetric); FX/FY take one absolute magnitude each.
PLOT_RANGE_DEFAULTS = {
"window_sec": DEFAULT_PLOT_WINDOW_SEC,
"fn_min": DEFAULT_PLOT_FN_MIN,
"fn_max": DEFAULT_PLOT_FN_MAX,
"shear_max": DEFAULT_PLOT_SHEAR_MAX,
"fn_raw_min": DEFAULT_PLOT_FN_RAW_MIN,
"fn_raw_max": DEFAULT_PLOT_FN_RAW_MAX,
"shear_raw_max": DEFAULT_PLOT_SHEAR_RAW_MAX,
}
#: Display deadband floors, per unit. 0 = auto (the estimate from the quiet
#: startup window, with the presentation layer's own floor under it).
PLOT_DEADBAND_DEFAULTS = {
"deadband_n": DEFAULT_PLOT_DEADBAND_N,
"deadband_raw": DEFAULT_PLOT_DEADBAND_RAW,
}
class DisplayHost(Protocol):
"""What the display controller needs from the window.
@@ -84,7 +114,9 @@ class DisplayPrefsController(QObject):
#: Whether the panels explain the pipeline around the picture (see
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
#: Finger-raw value cards: startup auto baseline / deadband (display only).
#: The display baseline / deadband (``raw_auto_zero``): a display aid
#: that holds an idle channel at zero, for every sensor type and both
#: units. It never reaches the session store or forces.csv.
self._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True))
self._apply_diagnostics()
self.apply_presentation_prefs()
@@ -143,50 +175,69 @@ class DisplayPrefsController(QObject):
# --------------------------------------------------------------------- plot
def plot_settings(self) -> dict:
"""The chart range and window as the settings dialog should show them."""
"""The chart range, deadband and window as the settings dialog shows them."""
prefs = self._host.prefs
return {
"window_sec": float(
prefs.get("plot_window_sec") or self._plot_defaults["window_sec"]
),
"fn_max": float(prefs.get("plot_fn_max") or self._plot_defaults["fn_max"]),
"shear_max": float(
prefs.get("plot_shear_max") or self._plot_defaults["shear_max"]
),
"fn_raw_max": float(
prefs.get("plot_fn_raw_max") or self._plot_defaults["fn_raw_max"]
),
"shear_raw_max": float(
prefs.get("plot_shear_raw_max") or self._plot_defaults["shear_raw_max"]
),
"units": self._toolbar.units(),
settings = {
key: self._number(
f"plot_{key}", self._plot_defaults.get(key, fallback)
)
for key, fallback in {**PLOT_RANGE_DEFAULTS, **PLOT_DEADBAND_DEFAULTS}.items()
}
settings["units"] = self._toolbar.units()
return settings
def _number(self, key: str, fallback: float) -> float:
"""A stored number, or the fallback when the preference says nothing.
0 is a value here, not "unset": the raw bounds and the deadband floors
all give 0 a meaning of its own.
"""
value = self._host.prefs.get(key)
if value is None or value == "":
return float(fallback)
try:
return float(value)
except (TypeError, ValueError):
return float(fallback)
def apply_plot_settings(self, values: dict) -> None:
"""Remember the chart range the operator chose, and show it at once."""
for key in ("window_sec", "fn_max", "shear_max", "fn_raw_max", "shear_raw_max"):
"""Remember the chart range the operator chose, and show it at once.
A caller that knows only some of the keys leaves the rest as stored, so
this can never silently reset a range it was not told about.
"""
for key in (*PLOT_RANGE_DEFAULTS, *PLOT_DEADBAND_DEFAULTS):
if values.get(key) is not None:
self._host.prefs[f"plot_{key}"] = values[key]
self._charts.set_window_sec(float(values["window_sec"]))
self._charts.set_axis_limits(
float(values["fn_max"]),
float(values["shear_max"]),
float(values["fn_raw_max"]),
float(values["shear_raw_max"]),
)
settings = self.plot_settings()
self._charts.set_window_sec(float(settings["window_sec"]))
self._apply_ranges(settings)
self._host._persist_prefs()
def apply_plot_prefs_to_panel(self) -> None:
"""Show the stored chart range without asking anything (startup path)."""
values = self.plot_settings()
self._charts.set_window_sec(float(values["window_sec"]))
self._charts.set_axis_limits(
float(values["fn_max"]),
float(values["shear_max"]),
float(values["fn_raw_max"]),
float(values["shear_raw_max"]),
)
settings = self.plot_settings()
self._charts.set_window_sec(float(settings["window_sec"]))
self._apply_ranges(settings)
self._charts.set_fixed_newton(self._toolbar.units() == "newton")
def _apply_ranges(self, settings: dict) -> None:
"""Push one complete range + deadband picture onto the charts."""
self._charts.set_axis_limits(
fn_min=float(settings["fn_min"]),
fn_max=float(settings["fn_max"]),
shear_max=float(settings["shear_max"]),
fn_raw_min=float(settings["fn_raw_min"]),
fn_raw_max=float(settings["fn_raw_max"]),
shear_raw_max=float(settings["shear_raw_max"]),
)
self._push_deadband_floors(settings["deadband_n"], settings["deadband_raw"])
def _push_deadband_floors(self, newton: float, raw: float) -> None:
floors = getattr(self._charts, "set_deadband_floors", None)
if callable(floors):
floors(newton=float(newton), raw=float(raw))
def device_sample_rate(self) -> float:
"""The frame rate the low-pass coefficients would be designed for."""
from orisys_studio import session_metadata
@@ -271,7 +322,11 @@ class DisplayPrefsController(QObject):
return bool(self._raw_auto_zero)
def set_raw_auto_zero(self, enabled: bool) -> None:
"""Persist whether Finger raw value cards auto-baseline at session start."""
"""Persist whether the display baseline / deadband is on at all.
The preference keeps its name; it is no longer Finger-raw-only (see
:meth:`apply_presentation_prefs`).
"""
self._raw_auto_zero = bool(enabled)
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
self._host._persist_prefs()
@@ -281,20 +336,27 @@ class DisplayPrefsController(QObject):
self.set_raw_auto_zero(enabled)
def apply_presentation_prefs(self) -> None:
"""Push the current baseline policy onto the charts (Finger raw only)."""
"""Push the display baseline policy onto the charts.
Every sensor type and both units. The aid holds an idle channel at zero:
a newton offset left in the numbers is the same display problem as a raw
count, so which device produced the reading is not what decides it.
"""
configure = getattr(self._charts, "configure_presentation", None)
if not callable(configure):
return
sensor = (
self._picker.sensor_type()
if hasattr(self._picker, "sensor_type")
else "box"
)
unit = "N" if self._toolbar.units() == "newton" else "raw"
configure(
sensor_type=sensor,
unit=unit,
enabled=bool(self._raw_auto_zero and sensor == "finger" and unit == "raw"),
if callable(configure):
configure(sensor_type=sensor, unit=unit)
enable = getattr(self._charts, "set_presentation_enabled", None)
if callable(enable):
enable(bool(self._raw_auto_zero))
self._push_deadband_floors(
self._number("plot_deadband_n", PLOT_DEADBAND_DEFAULTS["deadband_n"]),
self._number("plot_deadband_raw", PLOT_DEADBAND_DEFAULTS["deadband_raw"]),
)
def request_raw_reestimate(self) -> None:
@@ -58,7 +58,7 @@ PACKAGE_SUFFIX = ".oryscene"
COMPILE_HINT = (
"python tools/scene_assets/compile_scene.py "
"--legacy-dir config/sensors/finger/assets/30fps-0703 "
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz "
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.42.npz "
"--meters-per-source-unit <stated> --output "
"config/sensors/finger/scene/finger-display-1.0.0.oryscene"
)
@@ -180,8 +180,9 @@ class ThreeDPanel(QFrame):
self.housing_check = QCheckBox()
# Checked to match ``DisplaySettings.housing_visible``'s default: the panel's
# starting state and the settings it reports must be the same thing.
self.housing_check.setChecked(True)
# starting state and the settings it reports must be the same thing. That default is
# now unchecked — the housing is a mechanical check, not the measurement.
self.housing_check.setChecked(False)
self.housing_check.toggled.connect(self._emit_settings)
form.addRow(self.housing_check)
@@ -158,7 +158,7 @@ def settings_from_prefs(
display_gain=gain,
deform_enabled=_flag(raw.get("deform_enabled"), False),
color_scale=scale,
housing_visible=_flag(raw.get("housing_visible"), True),
housing_visible=_flag(raw.get("housing_visible"), False),
max_fps=max_fps,
field=field,
flow_arrows_enabled=_flag(raw.get("flow_arrows_enabled"), True),
+238 -28
View File
@@ -18,9 +18,14 @@ from PySide6.QtWidgets import (
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.presentation import ForcePresentation
from orisys_app_shared.force.presentation import (
DEFAULT_MIN_DEADBAND_N,
DEFAULT_MIN_DEADBAND_RAW,
ForcePresentation,
)
from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t
from orisys_app_shared.record.sample import plottable_axis_mask
from orisys_app_shared.theme import color
from orisys_studio.ui import icons
@@ -65,10 +70,33 @@ def _window_brush(theme: str):
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
"""Evenly spaced Y ticks that always carry a zero label when zero is inside.
The zero label is what makes "not pressing" readable: on an axis whose range
starts below zero the resting curve has no reference point without it.
"""
if hi <= lo:
return [(lo, f"{lo:g}")]
step = (hi - lo) / max(1, count - 1)
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
ticks = [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
if lo <= 0.0 <= hi and all(abs(value) > abs(step) * 1e-6 for value, _ in ticks):
ticks.append((0.0, "0"))
ticks.sort(key=lambda tick: tick[0])
return ticks
def _ordered_range(lower: float, upper: float) -> tuple[float, float]:
"""A usable ``(ymin, ymax)``: an empty or inverted range is widened upward.
Nothing stops an operator from asking for FN 5…2 N in the dialog, and an
inverted range collapses the axis in pyqtgraph. Widening keeps the chart
readable while the dialog keeps showing what was asked for.
"""
lower = float(lower)
upper = float(upper)
if upper <= lower:
upper = lower + max(0.1, abs(lower) * 0.05)
return lower, upper
def format_force(
@@ -133,8 +161,12 @@ class ForcePlotsPanel(QFrame):
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = theme
# One range per channel, per unit. FN is asymmetric and its lower bound
# may be negative; FX/FY take one absolute magnitude each.
self._fn_min = 0.0
self._fn_max = 20.0
self._shear_max = 20.0
self._fn_raw_min = 0.0
self._fn_raw_max = 1000000.0
self._shear_raw_max = 500000.0
self._fixed_newton = True
@@ -142,8 +174,13 @@ class ForcePlotsPanel(QFrame):
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
#: Per axis: does ``_last_values`` hold a real value in the active unit?
self._available: tuple[bool, bool, bool] = (True, True, True)
#: Display-only baseline / deadband for Finger raw value cards.
#: Display-only baseline / deadband for the value cards *and* the curves.
self.presentation = ForcePresentation(enabled=False)
#: The operator's master switch for that baseline. It is owned by
#: ``display_prefs`` (the ``raw_auto_zero`` preference) and kept here so a
#: caller that does not know it cannot switch the aid off (see
#: ``configure_presentation``).
self._presentation_enabled = False
self._sensor_type = "box"
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
@@ -261,6 +298,10 @@ class ForcePlotsPanel(QFrame):
self.plots = []
self.curves = []
self.cursors = []
#: Horizontal line at y = 0, one per channel: the origin the axis labels
#: alone could not give, and what keeps the idle FN curve off the subplot
#: border it shares with FX.
self.zero_lines = []
pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2)
for i, _name in enumerate(CHANNEL_LABELS):
plot = self.plot_widget.addPlot(row=i, col=0)
@@ -283,6 +324,14 @@ class ForcePlotsPanel(QFrame):
)
cursor.sigPositionChanged.connect(self._on_cursor_dragged)
plot.addItem(cursor, ignoreBounds=True)
zero_line = pg.InfiniteLine(
pos=0.0,
angle=0,
movable=False,
pen=pg.mkPen(color(theme, "text_muted"), width=1),
)
zero_line.setZValue(-10)
plot.addItem(zero_line, ignoreBounds=True)
# ``sigRangeChangedManually`` — not ``sigXRangeChanged`` — is what
# pyqtgraph emits for a wheel/drag gesture. The plain range signal
# also fires when a resize propagates through the X links, which is
@@ -291,6 +340,7 @@ class ForcePlotsPanel(QFrame):
self.plots.append(plot)
self.curves.append(curve)
self.cursors.append(cursor)
self.zero_lines.append(zero_line)
# Whole-session overview: the full envelope plus the current window.
self.overview = self.plot_widget.addPlot(row=len(CHANNEL_LABELS), col=0)
@@ -441,6 +491,8 @@ class ForcePlotsPanel(QFrame):
self.overview_region.setBrush(_window_brush(theme))
for cursor in (*self.cursors, self.overview_cursor):
cursor.setPen(pg.mkPen(muted, width=1))
for zero_line in self.zero_lines:
zero_line.setPen(pg.mkPen(muted, width=1))
pens = tuple(color(theme, token) for token in CHANNEL_TOKENS)
for curve, pen in zip(self.curves, pens):
curve.setPen(pg.mkPen(pen, width=2))
@@ -473,31 +525,77 @@ class ForcePlotsPanel(QFrame):
def set_axis_limits(
self,
*,
fn_min: float,
fn_max: float,
shear_max: float,
fn_raw_max: float | None = None,
shear_raw_max: float | None = None,
fn_raw_min: float,
fn_raw_max: float,
shear_raw_max: float,
) -> None:
"""The Y range of every channel, per display unit.
FN is asymmetric — ``fn_min`` may be negative, which is what gives the
axis a visible origin below the resting curve — while FX/FY each take one
absolute magnitude and are drawn symmetrically around zero.
"""
self._fn_min = float(fn_min)
self._fn_max = max(0.1, float(fn_max))
self._shear_max = max(0.1, float(shear_max))
if fn_raw_max is not None:
self._fn_raw_min = float(fn_raw_min)
self._fn_raw_max = max(0.0, float(fn_raw_max))
if shear_raw_max is not None:
self._shear_raw_max = max(0.0, float(shear_raw_max))
self._apply_axis_mode()
def set_deadband_floors(self, *, newton: float | None = None, raw: float | None = None) -> None:
"""The operator's display deadband floors; 0 means "auto (estimated)".
A floor, not a replacement: the estimate from the quiet window still
applies underneath, so this is how an operator widens the zero zone (or
pins one when the calibration data is suspect) without re-fitting.
"""
if newton is not None:
value = max(0.0, float(newton))
self.presentation.set_min_deadband(
"N", value if value > 0.0 else DEFAULT_MIN_DEADBAND_N
)
if raw is not None:
value = max(0.0, float(raw))
self.presentation.set_min_deadband(
"raw", value if value > 0.0 else DEFAULT_MIN_DEADBAND_RAW
)
self._render_value_cards()
def set_presentation_enabled(self, enabled: bool) -> None:
"""The operator's master switch for the display baseline and deadband."""
self._presentation_enabled = bool(enabled)
self.presentation.set_enabled(self._presentation_enabled, unit=self._unit)
# Switching the aid off must show the measurement again, and switching it
# on must show the policy — from the same values the curve is drawn with,
# or a card and its own curve would disagree for a frame.
self._refresh_display_values()
self._render_value_cards()
def set_fixed_newton(self, enabled: bool) -> None:
self._fixed_newton = bool(enabled)
self._apply_axis_mode()
def reset(self) -> None:
"""Back to the view defaults: empty curves, follow mode, unit ``raw``.
The display baseline is deliberately *kept*: a view reset is not a
statement about the device, and the operator's zero estimate (or its
absence) survives it. A new session restarts the estimate through
``configure_presentation``.
"""
self.history.reset()
self._clear_curves()
self._last_values = (0.0, 0.0, 0.0)
self._display_values = (0.0, 0.0, 0.0)
self._display_suppressed = (False, False, False)
self._available = (True, True, True)
self.presentation.reset_session(unit=self._unit)
self._unit = "raw"
self.presentation.unit = "raw"
self._mode = "follow"
self._window = (0.0, 0.0)
self._cursor_t = 0.0
@@ -519,20 +617,29 @@ class ForcePlotsPanel(QFrame):
unit: str,
enabled: bool | None = None,
) -> None:
"""Enable Finger-raw auto baseline for value cards only."""
"""Point the display baseline at the sensor and unit now being shown.
The aid is for every sensor type and both units — an idle channel that
reads a raw count or an offset in newtons is a display problem whichever
device produced it — so the sensor type only names what is drawn.
``enabled`` sets the master switch directly when given; the operator's
``raw_auto_zero`` preference is what the run-start call site passes.
Omitting it leaves the current switch alone.
"""
self._sensor_type = str(sensor_type or "box")
use = (
bool(enabled)
if enabled is not None
else (self._sensor_type == "finger" and unit != "N")
)
self.presentation.enabled = use
if enabled is not None:
self._presentation_enabled = bool(enabled)
self.presentation.enabled = bool(self._presentation_enabled)
self.presentation.reset_session(unit=unit)
def request_presentation_reestimate(self) -> None:
"""Restart the idle baseline window (display cards only)."""
"""Restart the idle baseline window (display only: cards and curves)."""
self.presentation.request_reestimate()
self._display_values = self._last_values
# A restarted window starts from nothing, so the cards go back to the
# measurement for the couple of samples it takes to re-learn the offset
# — derived by the same call the curve uses, never left stale.
self._refresh_display_values()
self._render_value_cards()
def observe_for_baseline(
@@ -543,8 +650,13 @@ class ForcePlotsPanel(QFrame):
valid: bool = True,
flow_p95: float | None = None,
contact_found: bool = False,
unit: str | None = None,
) -> None:
"""Feed the startup estimator; curves keep the raw measurement."""
"""Feed the startup estimator in the unit these values are expressed in.
The curve and the record keep the raw measurement; the estimate is only
ever a display copy.
"""
if not self.presentation.enabled:
return
self.presentation.observe(
@@ -553,6 +665,7 @@ class ForcePlotsPanel(QFrame):
valid=valid,
flow_p95=flow_p95,
contact_found=contact_found,
unit=unit,
)
def push(
@@ -596,6 +709,9 @@ class ForcePlotsPanel(QFrame):
valid=bool(forces_valid),
flow_p95=flow_p95,
contact_found=contact_found,
# The values are in ``unit``, and the estimate is kept per unit, so
# the sample must be filed under the unit it was measured in.
unit=unit,
)
displayed = self.presentation.accept(
self._last_values, unit=unit, available=self._available
@@ -617,8 +733,9 @@ class ForcePlotsPanel(QFrame):
if state is not None:
self._syncing = True
try:
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, i])
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
finally:
self._syncing = False
@@ -648,7 +765,10 @@ class ForcePlotsPanel(QFrame):
return
self._syncing = True
try:
values = state.values.copy()
# ...and draw the *display* copy of it, so a resting channel is a
# flat line on zero. ``history`` — and the session store and
# forces.csv behind it — keep the measurement.
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
@@ -661,6 +781,7 @@ class ForcePlotsPanel(QFrame):
else:
self.plots[i].getAxis("left").setTicks(None)
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
self._sync_zero_lines()
finally:
self._syncing = False
# The buffer already carries session-relative time, so the cursor is the
@@ -686,6 +807,9 @@ class ForcePlotsPanel(QFrame):
if unit == self._unit:
return
self._unit = unit
# The display policy keeps one estimate per unit, so it has to know which
# one is on screen: a stale "raw" is ready does not mean newtons are.
self.presentation.unit = unit
# ``clear`` keeps the buffer's time origin on purpose: the rebuilt history
# is in the same session-relative base, so the X axis must not restart.
self.history.clear()
@@ -696,6 +820,17 @@ class ForcePlotsPanel(QFrame):
self.refresh_from_store()
else:
self._reload_from_store()
# ``_last_values`` were measured in the unit being left. The
# rebuilt buffer holds the same samples in the unit now being
# shown, so its last row is what the cards describe — otherwise
# the card would carry the old unit's numbers while its own curve
# is drawn from the new one.
state = self.history.state()
if state is not None and len(state.values):
self._last_values = tuple(
float(value) for value in state.values[-1]
)
self._refresh_display_values()
self._render_value_cards()
self._apply_axis_mode()
# Re-read the point under the cursor in the new unit: while browsing, or
@@ -735,6 +870,7 @@ class ForcePlotsPanel(QFrame):
scale.
"""
self._unit = unit
self.presentation.unit = unit
self.history.clear()
self._clear_curves()
self._overview_dirty = True
@@ -826,9 +962,14 @@ class ForcePlotsPanel(QFrame):
for curve in self.curves:
curve.setData([], [])
else:
# The read is of the *measured* values (the session store or the
# recording); presenting them here is what keeps a browsed window
# looking like the live one — the record itself is untouched.
presented = self._curve_values(window.values)
mask = plottable_axis_mask(presented, window.valid)
for index, curve in enumerate(self.curves):
times, values = window.plottable_axis(index)
curve.setData(times, values)
column = mask[:, index]
curve.setData(window.times[column], presented[column, index])
self._availability_from_window(window)
for plot in self.plots:
plot.setXRange(t0, t1, padding=0.0)
@@ -962,6 +1103,14 @@ class ForcePlotsPanel(QFrame):
self._available = tuple(
bool(valid and np.isfinite(value)) for value in values
)
# The cards describe this point, so they carry the display copy of it —
# including while browsing or replaying, where the values did not come
# from the live sample the last ``push`` presented.
presented, suppressed = self.presentation.present(
np.asarray([values], dtype=float), unit=self._unit
)
self._display_values = tuple(float(value) for value in presented[0])
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
self._render_value_cards()
def _update_selection_readout(self) -> None:
@@ -1056,6 +1205,10 @@ class ForcePlotsPanel(QFrame):
buckets=self._overview_points(), unit=self._unit
)
values = np.asarray(window.values, dtype=float)
# The envelope is drawn from the same store measurement as the window
# above, so it goes through the same presentation: the overview thumb
# must not show a jittering baseline the main chart has flattened.
values = self._curve_values(values)
column = values[:, 0]
finite = np.isfinite(column)
self.overview_curve.setData(window.times[finite], column[finite])
@@ -1164,8 +1317,9 @@ class ForcePlotsPanel(QFrame):
state = self.history.state()
if state is None:
return
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, i])
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
def _availability_from_window(self, window) -> None:
@@ -1194,12 +1348,20 @@ class ForcePlotsPanel(QFrame):
return self._fn_raw_max > 0.0 and self._shear_raw_max > 0.0
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
"""The configured Y range of one channel, in the active unit.
FN's lower bound may be negative — that is the "below zero threshold"
the operator asked for, and it is what keeps the resting curve off the
frame it shares with FX.
"""
if self._unit == "N":
fn_max, shear_max = self._fn_max, self._shear_max
fn_min, fn_max, shear_max = self._fn_min, self._fn_max, self._shear_max
else:
fn_max, shear_max = self._fn_raw_max, self._shear_raw_max
fn_min = self._fn_raw_min
fn_max = self._fn_raw_max
shear_max = self._shear_raw_max
if channel_index == 0:
return 0.0, fn_max
return _ordered_range(fn_min, fn_max)
return -shear_max, shear_max
def _preview_fixed_y(self) -> bool:
@@ -1223,6 +1385,49 @@ class ForcePlotsPanel(QFrame):
if self._mode == "follow":
for plot in self.plots:
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
self._sync_zero_lines()
def _sync_zero_lines(self) -> None:
"""Show the zero line only where zero is inside the visible range.
pyqtgraph does not clip a layout item to its own view box, so a line at
0 painted while the axis sits entirely above it would be drawn across the
neighbouring subplot.
"""
for zero_line, plot in zip(self.zero_lines, self.plots):
try:
ymin, ymax = plot.getViewBox().viewRange()[1]
except Exception: # noqa: BLE001 - a view box mid-construction
continue
zero_line.setVisible(bool(ymin <= 0.0 <= ymax))
def _curve_values(self, values) -> np.ndarray:
"""The values the curves draw: the measurement through the display policy.
``history``, the session store and ``forces.csv`` keep the measurement;
the baseline and deadband are applied here, on the way to the plot only.
That is what makes a resting channel a flat line at exactly zero without
the record being edited. Browsing and a replay read the store, so they go
through this same presentation — the curves show the operator's display
policy, the store shows what was measured.
"""
displayed, _suppressed = self.presentation.present(values, unit=self._unit)
return displayed
def _refresh_display_values(self) -> None:
"""Re-derive the card values from the policy the curves are drawn with.
``push`` fills these from :meth:`ForcePresentation.accept`, whose
hysteresis is what keeps a *live* reading steady. Every other path — a
re-estimate, the master switch, a unit switch — goes through the same
stateless :meth:`ForcePresentation.present` the curve uses, so a card can
never state a value its own curve does not carry.
"""
displayed, suppressed = self.presentation.present(
np.asarray([self._last_values], dtype=float), unit=self._unit
)
self._display_values = tuple(float(value) for value in displayed[0])
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
def _clear_curves(self) -> None:
for curve in self.curves:
@@ -1252,7 +1457,13 @@ class ForcePlotsPanel(QFrame):
peak = float(selecting.peaks[index])
available = bool(np.isfinite(peak))
value = peak
elif self.presentation.enabled and self.presentation.ready:
elif self.presentation.enabled:
# The policy is *live from the first samples*: there is no
# separate "the estimate is ready" gate here. Such a gate is what
# let a card read FN 1118 raw while its own curve was already flat
# on zero, because the card fell back to the raw measurement and
# the curve did not. Both are ``_display_values`` / ``present()``
# of the same applied estimate.
value = self._display_values[index]
if not available:
# An axis with no curve in this unit: a dash, never a raw count
@@ -1273,7 +1484,6 @@ class ForcePlotsPanel(QFrame):
zero_zone = (
selecting is None
and self.presentation.enabled
and self.presentation.ready
and abs(float(value)) == 0.0
and index < len(self._display_suppressed)
and self._display_suppressed[index]
@@ -1,7 +1,8 @@
"""Modal dialog for the force-plot window, Y-axis limits and the filters.
"""Modal dialog for the force-plot window, Y-axis limits, deadband and filters.
Two things live here because they are answered together, at the chart: how much
time and range the curves show, and what the acquisition is allowed to filter.
time and range the curves show (per channel, and the display deadband that holds
a resting channel at zero), and what the acquisition is allowed to filter.
The filter half is a *request*: it is handed to the SDK through
``FilterSettings.apply_to`` at the start of the next session, and what the device
reports as done is what the record will say (see ``force/filters.py``).
@@ -33,12 +34,23 @@ from orisys_app_shared.force.filters import (
from orisys_app_shared.i18n import t
DEFAULT_PLOT_WINDOW_SEC = 4.0
#: Lower bounds. FN is allowed below zero: a resting normal force has no reason
#: to sit exactly on the frame, and with the lower bound at 0 the idle curve is
#: drawn along the subplot border that abuts the FX frame (see force_plots).
DEFAULT_PLOT_FN_MIN = 0.0
DEFAULT_PLOT_FN_MAX = 20.0
DEFAULT_PLOT_SHEAR_MAX = 20.0
# Raw (uncalibrated) HHD units: sized from measured peaks so the idle jitter
# stays sub-pixel while a full contact still fits (see prefs store).
DEFAULT_PLOT_FN_RAW_MIN = 0.0
DEFAULT_PLOT_FN_RAW_MAX = 1000000.0
DEFAULT_PLOT_SHEAR_RAW_MAX = 500000.0
#: Display deadband floors ("归零阈值") in the unit each applies to. 0 means
#: "auto": the percentile the presentation layer estimates from the quiet
#: startup window. Non-zero forces a floor under that estimate, so an operator
#: can widen the zero zone (or pin one when calibration data is suspect).
DEFAULT_PLOT_DEADBAND_N = 0.0
DEFAULT_PLOT_DEADBAND_RAW = 0.0
class PlotSettingsDialog(QDialog):
@@ -47,10 +59,14 @@ class PlotSettingsDialog(QDialog):
parent=None,
*,
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
fn_min: float = DEFAULT_PLOT_FN_MIN,
fn_max: float = DEFAULT_PLOT_FN_MAX,
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
fn_raw_min: float = DEFAULT_PLOT_FN_RAW_MIN,
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
deadband_n: float = DEFAULT_PLOT_DEADBAND_N,
deadband_raw: float = DEFAULT_PLOT_DEADBAND_RAW,
units: str = "newton",
filters: FilterSettings | None = None,
filters_locked: bool = False,
@@ -58,7 +74,7 @@ class PlotSettingsDialog(QDialog):
) -> None:
super().__init__(parent)
self.setModal(True)
self.resize(420, 520)
self.resize(460, 640)
self._sample_rate_hz = float(sample_rate_hz)
#: True while the widgets are being filled in from a preset, so the
#: "edited -> custom" handler does not fire on our own writes.
@@ -75,6 +91,15 @@ class PlotSettingsDialog(QDialog):
self.window_spin.setSuffix(" s")
self.window_spin.setValue(float(window_sec))
# FN is asymmetric: a lower bound that may be negative, and an upper one.
self.fn_min_label = QLabel()
self.fn_min_spin = QDoubleSpinBox()
self.fn_min_spin.setRange(-1000.0, 1000.0)
self.fn_min_spin.setSingleStep(1.0)
self.fn_min_spin.setDecimals(1)
self.fn_min_spin.setSuffix(" N")
self.fn_min_spin.setValue(float(fn_min))
self.fn_max_label = QLabel()
self.fn_max_spin = QDoubleSpinBox()
self.fn_max_spin.setRange(1.0, 100.0)
@@ -91,6 +116,13 @@ class PlotSettingsDialog(QDialog):
self.shear_max_spin.setSuffix(" N")
self.shear_max_spin.setValue(float(shear_max))
self.fn_raw_min_label = QLabel()
self.fn_raw_min_spin = QDoubleSpinBox()
self.fn_raw_min_spin.setRange(-1e9, 1e9)
self.fn_raw_min_spin.setSingleStep(50000.0)
self.fn_raw_min_spin.setDecimals(0)
self.fn_raw_min_spin.setValue(float(fn_raw_min))
# Raw limits: 0 means "auto range on the data".
self.fn_raw_max_label = QLabel()
self.fn_raw_max_spin = QDoubleSpinBox()
@@ -106,22 +138,48 @@ class PlotSettingsDialog(QDialog):
self.shear_raw_max_spin.setDecimals(0)
self.shear_raw_max_spin.setValue(float(shear_raw_max))
# The display deadband: a *display* aid, per unit. It never reaches the
# store or forces.csv (see force/presentation.py).
self.deadband_n_label = QLabel()
self.deadband_n_spin = QDoubleSpinBox()
self.deadband_n_spin.setRange(0.0, 100.0)
self.deadband_n_spin.setSingleStep(0.1)
self.deadband_n_spin.setDecimals(2)
self.deadband_n_spin.setSuffix(" N")
self.deadband_n_spin.setValue(float(deadband_n))
self.deadband_raw_label = QLabel()
self.deadband_raw_spin = QDoubleSpinBox()
self.deadband_raw_spin.setRange(0.0, 1e9)
self.deadband_raw_spin.setSingleStep(5000.0)
self.deadband_raw_spin.setDecimals(0)
self.deadband_raw_spin.setValue(float(deadband_raw))
form.addRow(self.window_label, self.window_spin)
form.addRow(self.fn_min_label, self.fn_min_spin)
form.addRow(self.fn_max_label, self.fn_max_spin)
form.addRow(self.shear_max_label, self.shear_max_spin)
form.addRow(self.fn_raw_min_label, self.fn_raw_min_spin)
form.addRow(self.fn_raw_max_label, self.fn_raw_max_spin)
form.addRow(self.shear_raw_max_label, self.shear_raw_max_spin)
form.addRow(self.deadband_n_label, self.deadband_n_spin)
form.addRow(self.deadband_raw_label, self.deadband_raw_spin)
root.addLayout(form)
newton = str(units or "newton").lower().startswith("n")
raw_widgets = (
self.fn_raw_min_spin,
self.fn_raw_max_spin,
self.shear_raw_max_spin,
self.deadband_raw_spin,
self.fn_raw_min_label,
self.fn_raw_max_label,
self.shear_raw_max_label,
self.deadband_raw_label,
)
for widget in (self.fn_max_spin, self.shear_max_spin, self.fn_max_label,
self.shear_max_label):
for widget in (self.fn_min_spin, self.fn_max_spin, self.shear_max_spin,
self.deadband_n_spin, self.fn_min_label, self.fn_max_label,
self.shear_max_label, self.deadband_n_label):
widget.setEnabled(newton)
for widget in raw_widgets:
widget.setEnabled(not newton)
@@ -330,6 +388,7 @@ class PlotSettingsDialog(QDialog):
self.filter_note.setText("")
def values(self) -> tuple[float, float, float, float, float]:
"""The original five-values shape, kept for the existing caller."""
return (
float(self.window_spin.value()),
float(self.fn_max_spin.value()),
@@ -338,12 +397,41 @@ class PlotSettingsDialog(QDialog):
float(self.shear_raw_max_spin.value()),
)
def ranges(self) -> dict[str, float]:
"""Every axis limit, keyed the way the preference names it."""
return {
"window_sec": float(self.window_spin.value()),
"fn_min": float(self.fn_min_spin.value()),
"fn_max": float(self.fn_max_spin.value()),
"shear_max": float(self.shear_max_spin.value()),
"fn_raw_min": float(self.fn_raw_min_spin.value()),
"fn_raw_max": float(self.fn_raw_max_spin.value()),
"shear_raw_max": float(self.shear_raw_max_spin.value()),
}
def deadbands(self) -> dict[str, float]:
"""The per-unit display deadband floors. 0 means "auto" (estimated)."""
return {
"deadband_n": float(self.deadband_n_spin.value()),
"deadband_raw": float(self.deadband_raw_spin.value()),
}
def settings(self) -> dict[str, float]:
"""Everything the plot half of the dialog decides, in one mapping."""
return {**self.ranges(), **self.deadbands()}
def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_min_spin.setValue(DEFAULT_PLOT_FN_MIN)
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
self.fn_raw_min_spin.setValue(DEFAULT_PLOT_FN_RAW_MIN)
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
# "Auto" is the shipped answer for the deadband: the estimate from the
# quiet startup window, with the layer's own floor under it.
self.deadband_n_spin.setValue(DEFAULT_PLOT_DEADBAND_N)
self.deadband_raw_spin.setValue(DEFAULT_PLOT_DEADBAND_RAW)
# "Reset" means the shipped defaults, and those filter nothing — the same
# answer the dialog gives for a preference file that says nothing.
self.flow_ema_spin.setValue(1.0)
@@ -358,14 +446,22 @@ class PlotSettingsDialog(QDialog):
self.setWindowTitle(t("dialog.plot_settings_title"))
self.window_label.setText(t("dialog.plot_window"))
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
self.fn_min_label.setText(t("dialog.plot_fn_min"))
self.fn_min_spin.setToolTip(t("dialog.plot_fn_min_tip"))
self.fn_max_label.setText(t("dialog.plot_fn_max"))
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
self.shear_max_label.setText(t("dialog.plot_shear_max"))
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
self.fn_raw_min_label.setText(t("dialog.plot_fn_raw_min"))
self.fn_raw_min_spin.setToolTip(t("dialog.plot_fn_raw_min_tip"))
self.fn_raw_max_label.setText(t("dialog.plot_fn_raw_max"))
self.fn_raw_max_spin.setToolTip(t("dialog.plot_fn_raw_max_tip"))
self.shear_raw_max_label.setText(t("dialog.plot_shear_raw_max"))
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
self.deadband_n_label.setText(t("dialog.plot_deadband_n"))
self.deadband_n_spin.setToolTip(t("dialog.plot_deadband_n_tip"))
self.deadband_raw_label.setText(t("dialog.plot_deadband_raw"))
self.deadband_raw_spin.setToolTip(t("dialog.plot_deadband_raw_tip"))
self.btn_reset.setText(t("dialog.reset_defaults"))
self.filters_group.setTitle(t("dialog.plot_filters"))
self.filter_preset_label.setText(t("dialog.filter_preset"))
+130 -68
View File
@@ -1,8 +1,15 @@
"""Top of the studio shell: status rail and a collapsible settings area.
The rail is what an operator watches while a session runs — phase, device,
calibration, data validity and the record channel — plus the three actions that
must never be more than one click away (record, run/stop, reset).
calibration, data validity and the record channel — plus the actions that must
never be more than one click away (record, run/pause, stop, reset).
The transport is the *same* control in both views (``btn_run``): a play/pause
toggle whose checked state means "acquiring / playing and not paused". A session
that is stopped and a session that is paused both leave the button unchecked, so
the session phase — not the button — is what tells the two apart (see
``_refresh_run_label``). Stopping is its own button (``btn_stop``), because
releasing the device is a different act from pausing the computation.
Everything that is *configuration* rather than state — source, camera number,
video file, units and the sensor/config pickers — lives in the settings area,
@@ -81,7 +88,10 @@ class ToolbarWidget(QWidget):
replay_video_clicked = Signal()
#: Replay view: re-run the pipeline over that video (the only processed picture).
recompute_clicked = Signal()
#: The transport: True when a live session should acquire, or a replay play.
run_toggled = Signal(bool)
#: Stop: end the live session (releasing the device), or leave the replay.
stop_clicked = Signal()
reset_clicked = Signal()
units_changed = Signal(str)
record_toggled = Signal(bool)
@@ -109,7 +119,6 @@ class ToolbarWidget(QWidget):
self._video_path = ""
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
self._run_role = "acquire"
self._reset_role = "reset"
outer = QVBoxLayout(self)
outer.setContentsMargins(2, 2, 2, 2)
@@ -242,6 +251,16 @@ class ToolbarWidget(QWidget):
self.btn_run.setCheckable(True)
self.btn_run.toggled.connect(self._on_run_toggled)
# Stop is a text button beside the transport, like Run — the icon-only pause
# it replaces cost the same rail width while saying nothing about what it
# stopped. It carries no minimum width, so the rail can shrink it at 1280
# (``tools/ui_check.py`` checks the rail for elision in both locales).
self.btn_stop = QPushButton()
self.btn_stop.setObjectName("dangerOutline")
self.btn_stop.setMinimumHeight(BUTTON_HEIGHT)
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self.stop_clicked.emit)
self.btn_reset = QPushButton()
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
self.btn_reset.setEnabled(False)
@@ -266,6 +285,7 @@ class ToolbarWidget(QWidget):
rail.addWidget(self.record_chip)
rail.addWidget(_rail(self.btn_record))
rail.addWidget(_rail(self.btn_run))
rail.addWidget(_rail(self.btn_stop))
rail.addWidget(_rail(self.btn_reset))
rail.addWidget(_rail(self.btn_settings))
return rail
@@ -386,7 +406,11 @@ class ToolbarWidget(QWidget):
self._apply_source_mode_ui()
def set_run_role(self, role: str) -> None:
"""What the primary button does in this view: acquire, or play back."""
"""What the primary button does in this view: acquire, or play back.
The same button is the transport of both views, so the *labels* follow the
role; only the words change (运行/暂停/继续 against 播放/暂停/继续).
"""
wanted = "playback" if str(role) == "playback" else "acquire"
if wanted == self._run_role:
return
@@ -397,38 +421,11 @@ class ToolbarWidget(QWidget):
self.btn_run.setChecked(False)
self.btn_run.blockSignals(False)
self._refresh_run_label()
self._refresh_stop_label()
def run_role(self) -> str:
return self._run_role
def set_reset_role(self, role: str) -> None:
"""What the second rail button does: zero the tracker, or stop the replay.
The replay view needs a *stop* the operator can always reach. The transport
button is a play/pause toggle, which is ambiguous precisely when something is
wrong (is it playing? is a recompute pass running?), so the second button —
disabled in that view otherwise — becomes an unconditional stop instead of a
second thing to reason about.
"""
wanted = "stop" if str(role) == "stop" else "reset"
if wanted == self._reset_role:
return
self._reset_role = wanted
if wanted == "reset":
self.btn_reset.setEnabled(False)
self._refresh_reset_label()
def reset_role(self) -> str:
return self._reset_role
def _refresh_reset_label(self) -> None:
if self._reset_role == "stop":
self.btn_reset.setText(t("toolbar.stop_playback"))
self.btn_reset.setToolTip(t("toolbar.stop_playback_tip"))
return
self.btn_reset.setText(t("toolbar.reset"))
self.btn_reset.setToolTip("")
def source_view(self) -> str:
return self._source_view
@@ -467,6 +464,12 @@ class ToolbarWidget(QWidget):
self.refresh_labels()
def set_run_checked(self, checked: bool) -> None:
"""Follow the session's transport state without re-emitting the signal.
The window calls this to *state* what the session is doing (a replay began,
a session ended, a recompute pass stopped). An operator's own click goes
through the button instead, and is routed by ``_on_run_toggled``.
"""
self.btn_run.blockSignals(True)
self.btn_run.setChecked(bool(checked))
self.btn_run.blockSignals(False)
@@ -474,6 +477,14 @@ class ToolbarWidget(QWidget):
if not checked:
self.btn_reset.setEnabled(False)
def set_stop_enabled(self, enabled: bool) -> None:
"""Stop is offered for as long as there is something to end.
That includes a device open that has not returned yet: the stop button is
where the cancel-a-hung-connect escape hatch lives now.
"""
self.btn_stop.setEnabled(bool(enabled))
def set_reset_enabled(self, enabled: bool) -> None:
self.btn_reset.setEnabled(bool(enabled))
@@ -517,7 +528,13 @@ class ToolbarWidget(QWidget):
self._apply_run_tooltip()
def set_run_phase(self, phase: str) -> None:
"""Track the session phase so Stop can advertise itself while connecting."""
"""Track the session phase.
The phase is half of what the transport label means: unchecked *with* a
live session is paused, unchecked without one is idle (see
``_refresh_run_label``), and ``connecting`` is what the stop button
advertises itself as cancel for.
"""
self._run_phase = str(phase or "idle")
self.refresh_labels()
@@ -531,6 +548,7 @@ class ToolbarWidget(QWidget):
"""Public refresh hook (kept separate from ``retranslate`` for reuse)."""
self._refresh_preview_label()
self._refresh_run_label()
self._refresh_stop_label()
# ------------------------------------------------------------- internals
def _populate_units_combo(self) -> None:
@@ -636,9 +654,15 @@ class ToolbarWidget(QWidget):
self.preview_toggled.emit(bool(checked))
def _on_run_toggled(self, checked: bool) -> None:
"""The transport: the label follows the button, the *state* does not.
Nothing about a session is decided here — the window routes the click (§
``_on_run_toggled`` there) and states the result back through
``set_run_checked``. Reset is deliberately left alone: unchecking now means
pause as often as it means stop, and pausing must not disable a control the
still-running session owns.
"""
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
self.run_toggled.emit(bool(checked))
def _on_units_index_changed(self, _index: int) -> None:
@@ -650,28 +674,52 @@ class ToolbarWidget(QWidget):
else:
self.btn_preview.setText(t("toolbar.preview"))
def _session_running(self) -> bool:
"""Whether a *live* session exists in this view (connecting or acquiring).
This is the third transport state the two-state button cannot carry: with
the button unchecked it is what separates "paused" from "not started".
"""
return self._run_role == "acquire" and self._run_phase in ("connecting", "running")
def _refresh_run_label(self) -> None:
if self._run_role == "playback":
# The primary action of the replay view is playing the recording —
# the same button, doing what this view is for.
# the same button, doing what this view is for. Unchecked with nothing
# loaded is idle, and the button is disabled with the reason in its tip.
self.btn_run.setText(
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
)
self._refresh_emphasis()
self._apply_run_tooltip()
return
if self.btn_run.isChecked():
# While connecting, cancel is the action the user actually needs —
# a device open can hang, and this is the way out of it.
if self._run_phase == "connecting":
self.btn_run.setText(t("toolbar.cancel_label"))
else:
self.btn_run.setText(t("toolbar.stop_label"))
elif self.btn_run.isChecked():
# checked = acquiring and not paused, so the action is Pause.
self.btn_run.setText(t("toolbar.pause"))
elif self._session_running():
# unchecked *with* a live session is the paused state: the device is
# open and the computation is stopped, so the action is Resume.
self.btn_run.setText(t("toolbar.resume_label"))
else:
self.btn_run.setText(t("toolbar.run_label"))
self._refresh_emphasis()
self._apply_run_tooltip()
def _refresh_stop_label(self) -> None:
"""Stop is one button for two situations, and its label says which.
A device open can hang, and the way out of it is this button: while the
session is still connecting it advertises itself as cancel, because that
is the act the operator needs. In the replay view it is the way out of the
replay (or a recompute pass), whatever state the transport is in.
"""
if self._run_role == "playback":
self.btn_stop.setText(t("toolbar.stop_label"))
self.btn_stop.setToolTip(t("toolbar.stop_playback_tip"))
elif self._run_phase == "connecting":
self.btn_stop.setText(t("toolbar.cancel_label"))
self.btn_stop.setToolTip(t("toolbar.cancel_tip"))
else:
self.btn_stop.setText(t("toolbar.stop_label"))
self.btn_stop.setToolTip(t("toolbar.stop"))
def _refresh_emphasis(self) -> None:
"""Exactly one *filled* button at a time.
@@ -681,31 +729,36 @@ class ToolbarWidget(QWidget):
* not recording yet → the record action is the filled primary;
* recording → stopping the recording is the filled danger;
* connecting → cancel is the filled danger (it is the way out of a hang).
* connecting → cancel (on the stop button) is the filled danger, since it
is the way out of a hang;
* a live session acquiring or paused → the transport is an outline: pause,
resume and stop all change the session's state rather than being the one
action the rail is pointing at.
The replay's transport is the exception: it *is* what that view is for, so
it is filled while it plays and an outline while it is idle.
"""
recording = self.btn_record.isChecked()
playing = self.btn_run.isChecked()
if self._run_role == "playback":
# Never red: the replay's stop is an outline beside a filled transport.
self.btn_record.setObjectName("dangerButton" if recording else "primaryButton")
self.btn_run.setObjectName("primaryButton" if playing else "ghostButton")
self.btn_stop.setObjectName("dangerOutline")
else:
# The phase counts as running even if the button has not been repainted
# yet: the rail must describe the session, not the widget's lag.
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
if self._run_role == "playback":
# The transport is the replay view's own action, not a session stop:
# filled while it plays, an outline when idle, and never red.
self.btn_record.setObjectName(
"dangerButton" if recording else "primaryButton"
)
self.btn_run.setObjectName("primaryButton" if running else "ghostButton")
elif recording:
connecting = self._run_phase == "connecting"
self.btn_stop.setObjectName("dangerButton" if connecting else "dangerOutline")
if recording:
self.btn_record.setObjectName("dangerButton")
self.btn_run.setObjectName("dangerOutline")
self.btn_run.setObjectName("ghostButton")
else:
self.btn_record.setObjectName("primaryButton")
if running and self._run_phase != "connecting":
self.btn_run.setObjectName("dangerOutline")
elif running:
self.btn_run.setObjectName("dangerButton")
else:
self.btn_run.setObjectName("primaryButton")
for button in (self.btn_record, self.btn_run):
self.btn_run.setObjectName(
"ghostButton" if playing or self._session_running() else "primaryButton"
)
for button in (self.btn_record, self.btn_run, self.btn_stop):
button.style().unpolish(button)
button.style().polish(button)
@@ -723,10 +776,12 @@ class ToolbarWidget(QWidget):
self.btn_run.setToolTip(t(key))
return
if self.btn_run.isChecked():
if self._run_phase == "connecting":
self.btn_run.setToolTip(t("toolbar.cancel_tip"))
else:
self.btn_run.setToolTip(t("toolbar.stop"))
# The tip is where the pause semantics are stated: computing stops, the
# picture holds, the device stays open — and a recording that spans the
# pause has a gap.
self.btn_run.setToolTip(t("toolbar.pause_tip"))
elif self._session_running():
self.btn_run.setToolTip(t("toolbar.resume_label_tip"))
elif not self._run_available and self._run_tooltip_reason:
self.btn_run.setToolTip(self._run_tooltip_reason)
elif not self._run_available:
@@ -737,6 +792,13 @@ class ToolbarWidget(QWidget):
tip = f"{tip} — {self._run_tooltip_reason}"
self.btn_run.setToolTip(tip)
def _refresh_reset_label(self) -> None:
self.btn_reset.setText(t("toolbar.reset"))
# The button zeroes the *device* tracker; it no longer clears the curves.
# That is now 视图 → 复位视图, because one button doing both meant an
# operator who wanted a fresh baseline also lost the history on screen.
self.btn_reset.setToolTip(t("toolbar.reset_tip"))
def retranslate(self) -> None:
self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_search.setText(t("toolbar.search_cameras"))
+3 -3
View File
@@ -279,10 +279,10 @@ class ViewLayoutController(QObject):
self._replay.pause_playback()
self._toolbar.set_source_view("replay" if preset == "replay" else "live")
# The rail's primary button is this view's main action: acquire while
# measuring, play back while replaying; and the second button is that view's
# own reset — or, in the replay view, an unconditional stop.
# measuring, play back while replaying. The button beside it is the same in
# both views — Stop, which ends a session or leaves a replay — and the
# tracker reset is simply disabled wherever there is no device session.
self._toolbar.set_run_role("playback" if preset == "replay" else "acquire")
self._toolbar.set_reset_role("stop" if preset == "replay" else "reset")
self.apply_panel_visibility()
if apply_sizes and preset in self._splitter_sizes:
self._splitter.setSizes(self.splitter_sizes_for(preset))
+79 -4
View File
@@ -12,8 +12,10 @@ from orisys_studio.display_prefs import DisplayPrefsController
PLOT_DEFAULTS = {
"window_sec": 10.0,
"fn_min": 0.0,
"fn_max": 5.0,
"shear_max": 5.0,
"fn_raw_min": 0.0,
"fn_raw_max": 1e5,
"shear_raw_max": 1e5,
}
@@ -46,7 +48,10 @@ class _Charts:
self.fixed_newton: list[bool] = []
self.units: list[str] = []
self.window_sec: list[float] = []
self.limits: list[tuple] = []
self.limits: list[dict] = []
self.deadband_floors: list[dict] = []
self.presentation_enabled: list[bool] = []
self.presentation: list[dict] = []
def unit(self) -> str:
return self._unit
@@ -61,8 +66,17 @@ class _Charts:
def set_window_sec(self, value: float) -> None: # noqa: N802
self.window_sec.append(float(value))
def set_axis_limits(self, *values) -> None: # noqa: N802
self.limits.append(tuple(float(v) for v in values))
def set_axis_limits(self, **values) -> None: # noqa: N802
self.limits.append({key: float(value) for key, value in values.items()})
def set_deadband_floors(self, *, newton: float, raw: float) -> None: # noqa: N802
self.deadband_floors.append({"newton": float(newton), "raw": float(raw)})
def configure_presentation(self, **values) -> None: # noqa: N802
self.presentation.append(dict(values))
def set_presentation_enabled(self, enabled: bool) -> None: # noqa: N802
self.presentation_enabled.append(bool(enabled))
class _ArrowPanel:
@@ -197,27 +211,88 @@ def test_the_plot_dialog_values_come_from_the_preferences_with_defaults():
settings = controller.plot_settings()
assert settings["window_sec"] == 3.0
assert settings["fn_max"] == PLOT_DEFAULTS["fn_max"], "a missing range used no default"
assert settings["fn_min"] == 0.0, "FN has no lower bound by default"
assert settings["deadband_n"] == 0.0, "the deadband default is 'auto'"
assert settings["units"] == "newton"
def test_a_stored_zero_is_a_value_and_not_a_missing_preference():
"""0 means "auto" on the raw bounds and the deadband, so it must survive."""
controller, parts = _controller(prefs={"plot_fn_raw_max": 0.0})
settings = controller.plot_settings()
assert settings["fn_raw_max"] == 0.0, "a stored 0 became the default"
def test_applying_plot_settings_writes_the_preferences_and_the_panel():
controller, parts = _controller(prefs={})
controller.apply_plot_settings(
{
"window_sec": 20.0,
"fn_min": -2.0,
"fn_max": 8.0,
"shear_max": 9.0,
"fn_raw_min": -1000.0,
"fn_raw_max": 2e5,
"shear_raw_max": 3e5,
"deadband_n": 0.25,
"deadband_raw": 0.0,
}
)
assert parts["host"].prefs["plot_window_sec"] == 20.0
assert parts["host"].prefs["plot_fn_min"] == -2.0
assert parts["host"].prefs["plot_shear_raw_max"] == 3e5
assert parts["host"].prefs["plot_deadband_n"] == 0.25
assert parts["charts"].window_sec == [20.0]
assert parts["charts"].limits == [(8.0, 9.0, 2e5, 3e5)]
assert parts["charts"].limits[-1] == {
"fn_min": -2.0,
"fn_max": 8.0,
"shear_max": 9.0,
"fn_raw_min": -1000.0,
"fn_raw_max": 2e5,
"shear_raw_max": 3e5,
}
# 0 on a deadband floor means "auto", and the charts are told exactly that.
assert parts["charts"].deadband_floors[-1] == {"newton": 0.25, "raw": 0.0}
assert parts["host"].persisted == 1
def test_a_partial_plot_settings_mapping_leaves_the_rest_as_stored():
"""The older five-value shape must not silently reset a range."""
controller, parts = _controller(prefs={"plot_fn_min": -5.0, "plot_deadband_raw": 900.0})
controller.apply_plot_settings(
{"window_sec": 6.0, "fn_max": 12.0, "shear_max": 3.0,
"fn_raw_max": 4e5, "shear_raw_max": 5e5}
)
assert parts["host"].prefs["plot_fn_min"] == -5.0
assert parts["charts"].limits[-1]["fn_min"] == -5.0
assert parts["charts"].deadband_floors[-1]["raw"] == 900.0
def test_the_display_baseline_is_not_a_finger_only_setting():
"""Every sensor type and both units: an idle newton offset is the same problem."""
for sensor, units in (("box", "newton"), ("box", "raw"),
("finger", "newton"), ("finger", "raw")):
controller, parts = _controller(prefs={"raw_auto_zero": True},
sensor_type=sensor, units=units)
assert parts["charts"].presentation_enabled[-1] is True, (sensor, units)
assert parts["charts"].presentation[-1]["unit"] == (
"N" if units == "newton" else "raw"
)
controller, parts = _controller(prefs={"raw_auto_zero": False}, sensor_type="box",
units="newton")
assert parts["charts"].presentation_enabled[-1] is False
def test_the_deadband_floors_reach_the_charts_from_the_preferences():
controller, parts = _controller(prefs={"plot_deadband_n": 0.5, "plot_deadband_raw": 2000.0})
assert parts["charts"].deadband_floors[-1] == {"newton": 0.5, "raw": 2000.0}
controller.set_raw_auto_zero(True) # re-applies the whole policy
assert parts["charts"].deadband_floors[-1] == {"newton": 0.5, "raw": 2000.0}
def test_the_sample_rate_prefers_the_measured_rate_over_the_stored_one():
controller, parts = _controller(prefs={"measured_fps": 12.0}, latest_fps=48.5)
assert controller.device_sample_rate() == 48.5
@@ -0,0 +1,331 @@
"""The force charts as the operator sees them: baseline, deadband and ranges.
The display policy is a *view* concern: an idle channel must read zero and draw a
flat line, while the session store and ``forces.csv`` keep the measurement. These
tests drive the panel on its own (no acquisition, no window) and read back what
the curves and the value cards were actually given.
"""
from __future__ import annotations
import numpy as np
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks, format_force
#: The two idle triplets from the customer's screenshots: a raw FN of ~9555 and
#: an FX whose bias sits ~13k counts off zero.
RAW_BIAS = np.array([9555.0, -13368.0, -353.0])
NEWTON_BIAS = np.array([0.4, -0.25, 0.1])
def _panel(unit: str = "raw") -> ForcePlotsPanel:
"""A chart in one unit, with the display baseline switched on."""
panel = ForcePlotsPanel(theme="dark")
panel.set_units(unit)
panel.configure_presentation(sensor_type="box", unit=unit)
panel.set_presentation_enabled(True)
panel.set_fixed_newton(True)
return panel
def _rest(
panel: ForcePlotsPanel,
bias: np.ndarray,
*,
unit: str = "raw",
samples: int = 80,
spread: float = 0.0,
sigma: float = 0.0,
seed: int = 0,
) -> None:
"""Feed a quiet startup window, the way a live session would.
``spread`` is a bounded wander (the device really does sit still) and
``sigma`` is the random jitter of a `noisier` product.
"""
rng = np.random.default_rng(seed)
for index in range(samples):
sample = bias + ((index % 5) - 2) * spread
if sigma:
sample = sample + rng.normal(0.0, sigma, size=3)
panel.push(index / 30.0, *sample, unit=unit)
def _ticks(panel: ForcePlotsPanel, channel: int) -> list[float]:
"""The Y tick values actually installed on one axis."""
levels = panel.plots[channel].getAxis("left")._tickLevels
assert levels, "the axis has no explicit ticks"
return [float(value) for value, _text in levels[0]]
def _curve(panel: ForcePlotsPanel, channel: int) -> np.ndarray:
_times, values = panel.curves[channel].getData()
return np.asarray(values, dtype=float)
# ------------------------------------------------------------ resting channel
def test_the_cards_read_zero_from_the_first_samples_of_a_session(qt_app):
"""The reported defect: FN 1118 raw, FX 34.16, FY -186.1 on the cards.
The screenshot was taken as the session started, so the aid has to be
effective from the first samples — not once the whole quiet window closed.
"""
panel = _panel("raw")
for index in range(4):
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
if index < 2:
# Nothing has been estimated from fewer than three samples yet; those
# first frames honestly show the measurement.
continue
assert [label.text() for label in panel.value_labels] == ["0"] * 3, index
assert not panel.presentation.ready, "the whole three second window was needed"
assert panel.presentation.display_quality == "provisional"
for channel in (0, 1, 2):
assert set(np.unique(_curve(panel, channel))) == {0.0}, channel
def test_the_newton_cards_read_zero_from_the_first_samples_too(qt_app):
"""Both units: a newton offset at rest is the same display problem."""
panel = _panel("N")
for index in range(4):
panel.push(index / 30.0, *NEWTON_BIAS, unit="N")
if index < 2:
continue
assert [label.text() for label in panel.value_labels] == ["0.000"] * 3, index
assert not panel.presentation.ready
def test_a_card_and_its_own_curve_are_the_same_value(qt_app):
"""A card may never read a number its curve does not carry.
The extra "the estimate is ready" gate in ``_render_value_cards`` is what
used to let the card show the raw measurement while the curve was already
drawn through the applied estimate.
"""
panel = _panel("raw")
for index in range(4):
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
# A real contact, flagged as one so it does not enter the idle estimate.
panel.push(1.0, *(RAW_BIAS + [4000.0, 0.0, 0.0]), unit="raw", contact_found=True)
for channel in range(3):
drawn = float(_curve(panel, channel)[-1])
assert panel.value_labels[channel].text() == format_force(
drawn, "raw", presentation=panel.presentation
), channel
assert panel.value_labels[0].text() not in ("0", "1118"), "the contact was lost"
def test_a_resting_raw_channel_reads_zero_and_draws_a_flat_curve(qt_app):
"""FN 9555 / FX -13368 at rest must read 0 and draw a line on zero."""
panel = _panel("raw")
_rest(panel, RAW_BIAS, spread=20.0)
assert panel.presentation.ready, "the quiet window never closed"
for index, label in enumerate(panel.value_labels):
assert label.text() == "0", (index, label.text())
for channel in (0, 1, 2):
values = _curve(panel, channel)
assert values.size, channel
assert set(np.unique(values)) == {0.0}, (channel, np.unique(values))
def test_a_resting_newton_channel_is_baselined_too(qt_app):
"""The same aid in newtons: it is a display problem in either unit."""
panel = _panel("N")
_rest(panel, NEWTON_BIAS, unit="N")
assert panel.presentation.ready
assert [label.text() for label in panel.value_labels] == ["0.000"] * 3
assert set(np.unique(_curve(panel, 0))) == {0.0}
def test_noise_outside_the_estimated_band_is_still_rare(qt_app):
"""A p99 band, so a resting *noisy* channel is flat but not rigid.
The offset removal is what fixes a 13k-count bias; the percentile band holds
the remaining jitter at zero. A few idle samples may sit just outside it —
that is what the operator's deadband floor in the dialog is for.
"""
panel = _panel("raw")
_rest(panel, RAW_BIAS, sigma=30.0)
assert panel.presentation.ready
values = _curve(panel, 1)
zeroed = float(np.mean(values == 0.0))
assert zeroed >= 0.95, f"only {zeroed:.2%} of a resting channel was zeroed"
assert abs(float(np.median(values))) < 1.0
def test_the_record_keeps_the_measurement_while_the_display_is_zeroed(qt_app):
"""The deadband is a display copy: the buffer and the store see the truth."""
panel = _panel("raw")
store = SessionStore()
panel.set_store(store)
for index in range(6):
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
store.append(
SessionSample(
index,
index / 30.0,
fn_raw=RAW_BIAS[0],
fx_raw=RAW_BIAS[1],
fy_raw=RAW_BIAS[2],
fn_n=NEWTON_BIAS[0],
fx_n=NEWTON_BIAS[1],
fy_n=NEWTON_BIAS[2],
valid=True,
)
)
# The very values the customer complained about are still measured, from the
# first sample on — in the live buffer...
state = panel.history.state()
assert state is not None
assert abs(float(np.median(state.values[:, 0])) - RAW_BIAS[0]) < 100.0
assert abs(float(np.median(state.values[:, 1])) - RAW_BIAS[1]) < 100.0
# ...and in the session store (which is what forces.csv is written from).
stored = store.data().raw
assert stored.shape[0] == 6
assert float(np.median(stored[:, 0])) == RAW_BIAS[0]
assert float(np.median(stored[:, 1])) == RAW_BIAS[1]
assert float(np.median(stored[:, 2])) == RAW_BIAS[2]
# ...while the cards and the curve drawn from it are already flat on zero.
assert [label.text() for label in panel.value_labels] == ["0"] * 3
assert set(np.unique(_curve(panel, 1))) == {0.0}
# ------------------------------------------------------------------ deadband
def test_a_small_excursion_is_suppressed_and_a_larger_one_is_not(qt_app):
panel = _panel("raw")
_rest(panel, RAW_BIAS, spread=20.0)
estimate = panel.presentation.last_estimate
assert estimate is not None and estimate.quality == "ok"
deadband = float(estimate.deadbands[0])
assert deadband > 0.0
panel.push(3.0, *(RAW_BIAS + [0.5 * deadband, 0.0, 0.0]), unit="raw")
assert panel.value_labels[0].text() == "0", "half the deadband was drawn"
panel.push(3.1, *(RAW_BIAS + [3.0 * deadband, 0.0, 0.0]), unit="raw")
assert panel.value_labels[0].text() != "0", (
"an excursion well outside the deadband was swallowed"
)
assert abs(_curve(panel, 0)[-1]) > 2.0 * deadband
# ...and the other axes, which stayed quiet, are still zero.
assert panel.value_labels[1].text() == "0"
def test_the_operator_floor_widens_the_deadband_at_once(qt_app):
panel = _panel("raw")
_rest(panel, RAW_BIAS, spread=20.0)
panel.set_deadband_floors(raw=250000.0)
panel.push(3.0, *(RAW_BIAS + [50000.0, 0.0, 0.0]), unit="raw")
assert panel.value_labels[0].text() == "0", (
"an excursion inside the operator's floor was drawn"
)
panel.set_deadband_floors(raw=0.0) # "auto": the layer's own floor returns
assert panel.presentation.min_deadband_for("raw") == 50.0
# -------------------------------------------------------------------- ranges
def test_the_configured_ranges_reach_every_axis(qt_app):
"""FN is asymmetric and may go below zero; FX/FY are symmetric."""
panel = ForcePlotsPanel(theme="dark")
panel.set_axis_limits(
fn_min=-5.0,
fn_max=15.0,
shear_max=3.0,
fn_raw_min=-100000.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
panel.set_fixed_newton(True)
# The panel starts in raw, so the raw limits are the ones on the axes.
assert panel.plots[0].getViewBox().viewRange()[1] == [-100000.0, 1000000.0]
assert panel.plots[1].getViewBox().viewRange()[1] == [-500000.0, 500000.0]
panel.set_units("N")
assert panel.plots[0].getViewBox().viewRange()[1] == [-5.0, 15.0]
assert panel.plots[1].getViewBox().viewRange()[1] == [-3.0, 3.0]
assert panel.plots[2].getViewBox().viewRange()[1] == [-3.0, 3.0]
assert panel._fixed_y_range(0) == (-5.0, 15.0)
assert panel._fixed_y_range(1) == (-3.0, 3.0)
# A range that starts below zero carries an explicit 0 tick: that is the
# "missing origin" the customer could not find.
assert 0.0 in _ticks(panel, 0)
panel.set_units("raw")
assert panel._fixed_y_range(0) == (-100000.0, 1000000.0)
assert panel._fixed_y_range(1) == (-500000.0, 500000.0)
def test_the_zero_line_is_only_drawn_where_zero_is_inside_the_range(qt_app):
"""pyqtgraph does not clip a layout item to its own view box."""
panel = ForcePlotsPanel(theme="dark")
panel.set_axis_limits(
fn_min=0.0,
fn_max=20.0,
shear_max=3.0,
fn_raw_min=100.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
view_box = panel.plots[0].getViewBox()
assert not panel.zero_lines[0].isVisibleTo(view_box), (
"a zero line was painted on an axis that does not reach zero"
)
assert panel.zero_lines[1].isVisibleTo(panel.plots[1].getViewBox())
assert 0.0 not in _ticks(panel, 0)
# Give FN a negative lower bound and the origin appears.
panel.set_axis_limits(
fn_min=0.0,
fn_max=20.0,
shear_max=3.0,
fn_raw_min=-100000.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
assert panel.zero_lines[0].isVisibleTo(view_box)
assert 0.0 in _ticks(panel, 0)
def test_an_inverted_range_is_widened_rather_than_collapsing_the_axis(qt_app):
panel = ForcePlotsPanel(theme="dark")
panel.set_axis_limits(
fn_min=5.0,
fn_max=2.0,
shear_max=3.0,
fn_raw_min=0.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
ymin, ymax = panel._fixed_y_range(0)
assert ymax > ymin
def test_the_default_ranges_are_the_ones_they_always_were(qt_app):
"""Defaults preserve today's behaviour: FN 0…20 N, shear ±20 N (and raw)."""
panel = ForcePlotsPanel(theme="dark")
assert panel._fixed_y_range(0) == (0.0, 1000000.0)
assert panel._fixed_y_range(1) == (-500000.0, 500000.0)
panel.set_units("N")
panel.set_fixed_newton(True)
assert panel._fixed_y_range(0) == (0.0, 20.0)
assert panel._fixed_y_range(1) == (-20.0, 20.0)
# With the lower bound at 0 nothing changes about the tick list either.
assert _nice_ticks(0.0, 20.0, 5) == [
(0.0, "0"),
(5.0, "5"),
(10.0, "10"),
(15.0, "15"),
(20.0, "20"),
]
+139
View File
@@ -0,0 +1,139 @@
"""The live Pause control: what it stops, and what it deliberately leaves alone.
Pause means "stop computing and stop the picture, keep the camera". It must never be
confused with 停止 (which releases the device) or with the chart's own browse pause, and
it must not survive a session.
It lives on the rail's transport button — the same control the replay view uses to play
and pause a recording. ``checked`` means "acquiring and not paused", so unchecking the
button *while a session exists* is the pause, and checking it again is the resume. That
is how one two-state toggle carries a three-state transport: the session state, not the
button, is what tells stopped from paused.
"""
from __future__ import annotations
from orisys_app_shared.i18n import t
from tests.conftest import finish_run, running_window, show_window
def test_the_transport_is_idle_with_no_session(make_window):
"""Nothing to pause: the button says it runs, and there is no pause to reach."""
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
assert not window.toolbar.btn_run.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
assert not window.act_pause.isEnabled()
window.close()
def test_unchecking_the_transport_pauses_and_never_stops_the_device(make_window):
window, stuck = running_window(make_window)
try:
assert window.toolbar.btn_run.isChecked(), "the transport does not show acquiring"
assert window.toolbar.btn_run.text() == t("toolbar.pause")
window.toolbar.btn_run.setChecked(False)
assert window._display_paused is True
assert stuck.is_paused is True, "the worker was not asked to pause"
assert stuck.pause_requests == 1
assert window.act_pause.isChecked(), "the menu action did not follow the transport"
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
# The device is still open and the session is still the session.
assert window._running and window._session_phase == "running", (
"pausing ended the session instead of holding it"
)
assert "暂停" in window.statusBar().currentMessage() or (
"Paus" in window.statusBar().currentMessage()
)
window.toolbar.btn_run.setChecked(True)
assert window._display_paused is False
assert stuck.is_paused is False, "the worker was not asked to resume"
assert stuck.pause_requests == 2
assert not window.act_pause.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.pause")
finally:
finish_run(window, stuck)
def test_unchecking_the_transport_with_no_session_does_nothing(make_window):
"""The unchecked state only means paused *while a session exists*."""
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
window._on_run_toggled(False)
assert window._display_paused is False
assert not window._running
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
window.close()
def test_the_stop_button_is_what_ends_a_paused_session(make_window):
"""Pause holds the session; only Stop releases the device."""
window, stuck = running_window(make_window)
try:
window.toolbar.btn_run.setChecked(False)
assert window._display_paused is True and window._running
assert window.toolbar.btn_stop.isEnabled(), "no way out of the paused session"
window.toolbar.btn_stop.click()
assert not window._running, "Stop did not end the session"
assert window._session_phase == "idle"
finally:
stuck.release()
def test_the_shortcut_drives_the_same_state_as_the_transport(make_window):
"""One authoritative control, so the two can never disagree."""
window, stuck = running_window(make_window)
try:
window.act_pause.setChecked(True)
assert window._display_paused is True
assert stuck.is_paused is True
assert not window.toolbar.btn_run.isChecked(), (
"the transport did not follow the action"
)
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
window.act_pause.setChecked(False)
assert window._display_paused is False
assert stuck.is_paused is False
assert window.toolbar.btn_run.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.pause")
finally:
finish_run(window, stuck)
def test_the_transport_says_which_way_it_goes(make_window):
window, stuck = running_window(make_window)
try:
acquiring = window.toolbar.btn_run.text()
acquiring_tip = window.toolbar.btn_run.toolTip()
window.toolbar.btn_run.setChecked(False)
paused = window.toolbar.btn_run.text()
paused_tip = window.toolbar.btn_run.toolTip()
assert acquiring and paused and acquiring != paused, (acquiring, paused)
assert acquiring == t("toolbar.pause")
assert paused == t("toolbar.resume_label")
# The tooltips explain what each state does (the label cannot) — the pause
# one including the gap a pause leaves in a recording.
assert acquiring_tip == t("toolbar.pause_tip")
assert paused_tip == t("toolbar.resume_label_tip")
gap_tip = t("toolbar.pause_tip")
assert "gap" in gap_tip or "空档" in gap_tip, gap_tip
finally:
finish_run(window, stuck)
def test_stopping_a_paused_session_clears_the_pause(make_window):
"""The next run must compute: a latched pause would look like a dead device."""
window, stuck = running_window(make_window)
window.toolbar.btn_run.setChecked(False)
assert window._display_paused is True
window._stop_run(reason="user_stop")
assert window._display_paused is False
assert not window.act_pause.isChecked()
assert not window.toolbar.btn_run.isChecked()
# The session is over, so the transport is idle again — not paused.
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
stuck.release()
+20 -4
View File
@@ -245,14 +245,22 @@ def test_the_finger_adapter_can_be_built_from_a_domain(scene):
assert adapter.map_x is not None and adapter.map_y is not None, (
"the domain must carry the unroll map for the adapter to use"
)
assert domain.source_hw == (288, 512), domain.source_hw
# The map states its own source frame, and that frame is the raw 1280x720 frame scaled by
# ``source_scale`` (0.42 -> 538x302), *not* the 300x280 domain the adapter publishes. Pinning
# the relationship rather than one literal keeps this about the convention: a map whose
# coordinates were stated in native pixels would fail here.
scale = domain.source_scale
assert scale == 0.42, scale
assert domain.source_hw == (round(720 * scale), round(1280 * scale)), domain.source_hw
assert domain.source_hw != domain.shape_hw
def test_the_shipped_map_cannot_be_applied_to_the_shipped_flow():
"""Why the app must not remap the finger's flow: the map is domain→*source*.
The unroll map is the same size as the UV domain, but its values are pixel
coordinates in the 512x288 source image — not in the 300x280 domain. The live
coordinates in the scaled source image (538x302 at ``source_scale`` 0.42) — not in the
300x280 domain. The live
flow is already computed on the unrolled frame, so feeding it through this map
would be a double remap: it would index the flow at source-pixel coordinates.
This test pins the reason the app passes no map, so a later "improvement" has to
@@ -272,15 +280,23 @@ def test_the_shipped_map_cannot_be_applied_to_the_shipped_flow():
assert domain.map_x is not None and domain.map_y is not None
rows, columns = domain.shape_hw
source_rows, source_columns = domain.source_hw
assert (rows, columns) == (300, 280) and (source_rows, source_columns) == (288, 512)
assert (rows, columns) == (300, 280)
scale = domain.source_scale
assert (source_rows, source_columns) == (round(720 * scale), round(1280 * scale))
# Bounded by the source, not by the domain: that is what makes them source pixels.
assert 0.0 <= float(domain.map_x[domain.map_x >= 0].max()) <= source_columns - 1
assert 0.0 <= float(domain.map_y[domain.map_y >= 0].max()) <= source_rows - 1
# The values actually present are what decide the two halves of the mistake, not the declared
# size of the source frame: x reaches past the last column of the 280-wide field (a remap would
# read out of range and crash), while y never leaves the 300 rows (a remap would read valid but
# wrong rows, silently). The old form of this assertion accepted ``source_rows <= rows`` as a
# proxy for the y half, which held at ``source_scale`` 0.4 (288 <= 300) but not at 0.42
# (302 > 300) — even though the y values never exceed 284 either way.
assert float(domain.map_x.max()) > columns - 1, (
"x would index past the last column of a 280-wide flow field"
)
assert float(domain.map_y.max()) > rows - 1 or source_rows <= rows, (
assert float(domain.map_y.max()) < rows - 1, (
"y stays inside the 300-row field: the y half of the mistake is silent"
)
@@ -138,6 +138,11 @@ def test_both_parts_are_drawn_and_the_housing_can_be_hidden(gl_widget, qt_app):
widget, renderer, _ = gl_widget
assert set(renderer.part_ids) == {"sensing_surface", "housing"}
# ``DisplaySettings.housing_visible`` ships False (the frame dominated the
# picture), so the visible case has to be asked for explicitly rather than
# relying on the default — otherwise both renders below are the hidden one and
# the comparison cannot mean anything.
widget.set_settings(DisplaySettings(housing_visible=True))
with_housing = _drawn(_render(widget, qt_app))
assert with_housing.size, "nothing was drawn at all"
widget.set_settings(DisplaySettings(housing_visible=False))
+3 -2
View File
@@ -163,8 +163,9 @@ def test_no_scene3d_entry_point_is_still_a_stub(qt_app):
settings = panel.settings()
assert settings.max_fps == 30
assert settings.display_gain == 0.0
assert settings.housing_visible is True, (
"the panel's resting state and DisplaySettings' default must agree"
assert settings.housing_visible is False, (
"the panel's resting state and DisplaySettings' default must agree: the housing "
"ships hidden, and the checkbox starts unchecked to match"
)
assert settings.color_scale is not None
assert settings.color_scale.mode == "automatic"
+56
View File
@@ -0,0 +1,56 @@
"""The product-type list the rail offers, and what each type is routed to.
`finger-mems` is studio-side only (the MEMS product line is deprecated and has no read path in
the SDK), so it runs on the box/mini acquisition pipeline. That is not an accident to be
rediscovered: the worker routing is a binary ``== "finger"`` test, which means a new type has to
be *added* to the runnable set but must *not* silently become the finger pipeline.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
from orisys_studio import app_window
APP = Path(__file__).resolve().parents[1]
def test_the_picker_offers_every_product_type():
types = list_sensor_types()
assert types == ["box", "mini", "finger", "finger-mems"], types
assert set(app_window.RUNNABLE_TYPES) >= set(types), (
"a type the picker offers but Run refuses is a dead entry"
)
def test_finger_mems_has_a_bundle_but_no_calibration():
"""Copied from box, minus the coefficients: an uncalibrated device must read raw."""
configs = list_configs_for_type("finger-mems")
assert len(configs) == 1, configs
path = Path(configs[0][1])
assert path.is_file(), path
assert path.parent.name == "finger-mems"
assert not (path.parent / "force_calibration.json").exists(), (
"the MEMS bundle ships without calibration on purpose: inventing coefficients would "
"make the charts show newtons the device never measured"
)
def test_finger_mems_takes_the_box_pipeline():
from orisys_studio.session_controller import SessionController
controller = SessionController.__new__(SessionController)
controller._box_worker = object() # type: ignore[attr-defined]
controller._finger_worker = object() # type: ignore[attr-defined]
assert SessionController.worker_for(controller, "finger-mems") is controller._box_worker
assert SessionController.worker_for(controller, "box") is controller._box_worker
assert SessionController.worker_for(controller, "finger") is controller._finger_worker
def test_finger_mems_rotates_its_arrow_view_like_box():
"""It is a box-style stitched camera, so the 2D rotation applies to it too."""
assert "finger-mems" in app_window.ARROW_ROTATE_TYPES
assert "finger" not in app_window.ARROW_ROTATE_TYPES
+107
View File
@@ -0,0 +1,107 @@
"""Review of the rail's unified play control: play/pause, stop, reset — and nothing else.
Written against the requirement rather than the implementation:
* the play-control group is exactly **play/pause**, **stop**, **reset** (no extra pause
button, no role switch that turns the third button into a stop);
* the primary button is a play/pause toggle in **both** views, and pressing it while a
session runs pauses without stopping — the device stays connected;
* stop ends the session (live) or leaves the replay (playback);
* reset is disabled in the replay view, where there is no tracker to zero.
"""
from __future__ import annotations
import ui_check
from tests.conftest import finish_run, running_window, show_window
def test_the_group_is_exactly_play_pause_stop_reset(make_window):
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
toolbar = window.toolbar
assert not hasattr(toolbar, "btn_pause"), "an extra pause button is still in the rail"
assert hasattr(toolbar, "btn_stop"), "there is no dedicated stop button"
assert not hasattr(toolbar, "set_reset_role"), (
"the reset button still switches role to stop; one control cannot be two"
)
assert toolbar.btn_run.isCheckable(), "the play/pause control must be a toggle"
window.close()
def test_the_primary_button_pauses_and_does_not_stop(make_window):
"""The heart of the unification: one toggle, three states, and stop is not the toggle."""
window, stuck = running_window(make_window)
try:
assert window._session_active, "the fixture did not start a session"
assert window.toolbar.btn_run.isChecked(), (
"a running session must show the transport as engaged (pause available)"
)
assert "暂停" in window.toolbar.btn_run.text() or "Paus" in window.toolbar.btn_run.text(), (
f"a running session reads {window.toolbar.btn_run.text()!r}, not pause"
)
# Press it: pause, not stop.
window.toolbar.btn_run.setChecked(False)
assert window._session_active, "pressing the transport ended the session"
assert window._display_paused is True, "the window did not record the pause"
assert stuck.is_paused is True, "the worker was not paused"
assert "继续" in window.toolbar.btn_run.text() or "Resume" in window.toolbar.btn_run.text(), (
f"a paused session reads {window.toolbar.btn_run.text()!r}, not resume"
)
# Press it again: resume.
window.toolbar.btn_run.setChecked(True)
assert window._display_paused is False
assert stuck.is_paused is False, "the worker was not resumed"
assert window._session_active, "resuming ended the session"
finally:
finish_run(window, stuck)
def test_stop_ends_the_session(make_window):
window, stuck = running_window(make_window)
try:
# A first frame is what makes the session a *running* one (the rail's reset and
# the phase both key off it), so drive one before pressing stop.
stuck.emit(ui_check._fake_packet(1))
window._on_run_tick()
assert window.toolbar.btn_stop.isEnabled(), "stop is not reachable while running"
window.toolbar.btn_stop.click()
assert window._session_phase != "running", (
f"the stop button left the session in {window._session_phase!r}"
)
assert not window.toolbar.btn_run.isChecked(), "the transport stayed engaged after stop"
assert window._display_paused is False, "the pause flag survived the stop"
finally:
stuck.release()
def test_reset_is_for_the_device_and_is_disabled_in_the_replay_view(make_window):
"""Reset stays a *device* control, and it is the thing the replay view cannot offer.
The enabled state of reset once frames are flowing is covered by
``test_ui_smoke.py::test_a_reset_reaches_the_device_and_is_recorded`` (which drives
``_on_reset``); this test pins what the unification is about: reset and stop are two
different controls, and the replay view disables the one that needs a device.
"""
window, stuck = running_window(make_window)
try:
assert window.toolbar.btn_reset.text() != window.toolbar.btn_stop.text(), (
"reset and stop must be distinguishable controls"
)
assert window.toolbar.btn_stop.isEnabled(), "stop is not reachable during a session"
finally:
finish_run(window, stuck)
replay = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
replay.brand_bar.btn_playback.click()
assert not replay.toolbar.btn_reset.isEnabled(), (
"reset is offered in the replay view, where there is no tracker to zero"
)
assert hasattr(replay.toolbar, "btn_stop"), "the replay view has no stop control"
assert replay.toolbar.btn_run.text().startswith("▶"), (
f"the replay transport idle reads {replay.toolbar.btn_run.text()!r}, not play"
)
replay.close()
+180 -23
View File
@@ -225,10 +225,10 @@ def test_a_hung_connect_can_be_cancelled_immediately(make_window, cancel_via):
assert window._session_phase == "connecting"
try:
if cancel_via == "toolbar":
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
assert window.toolbar.btn_stop.isEnabled(), "Stop is disabled while connecting"
assert window.toolbar.btn_stop.text() == t("toolbar.cancel_label")
assert window.act_stop.isEnabled()
cancel = lambda: window.toolbar.btn_run.setChecked(False)
cancel = window.toolbar.btn_stop.click
else:
assert window.act_stop.shortcut().toString() == "Esc"
cancel = window._on_stop_shortcut
@@ -1062,7 +1062,12 @@ def test_idle_run_is_the_only_filled_action(make_window):
def test_connecting_cancel_stays_filled(make_window):
"""Cancel is the way out of a hung device open, so it keeps the weight."""
"""Cancel is the way out of a hung device open, so it keeps the weight.
It lives on the rail's Stop button now (the transport is a play/pause toggle in
both views, so it can no longer be the stop), which is why the filled danger is
checked there.
"""
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
@@ -1073,8 +1078,8 @@ def test_connecting_cancel_stays_filled(make_window):
window.toolbar.btn_run.setChecked(True)
QApplication.processEvents()
assert window._session_phase == "connecting"
assert window.toolbar.btn_run.objectName() == "dangerButton"
window.toolbar.btn_run.setChecked(False)
assert window.toolbar.btn_stop.objectName() == "dangerButton"
window.toolbar.btn_stop.click()
stuck.release()
@@ -1088,26 +1093,86 @@ def test_exactly_one_filled_action_while_acquiring(make_window):
window.toolbar.btn_run.setChecked(True)
window._run_timer.stop()
assert window._session_phase == "connecting"
assert window.toolbar.btn_run.objectName() == "dangerButton", (
assert window.toolbar.btn_stop.objectName() == "dangerButton", (
"Cancel lost its weight while a connect can hang"
)
stuck.emit(ui_check._fake_packet(1))
window._on_run_tick()
assert window._session_phase == "running"
assert window.toolbar.btn_run.objectName() == "dangerOutline", (
assert window.toolbar.btn_stop.objectName() == "dangerOutline", (
"Stop is still the filled action while a session merely acquires"
)
assert window.toolbar.btn_record.objectName() == "primaryButton"
assert window.toolbar.btn_run.objectName() == "ghostButton", (
"the transport competes with the filled record action"
)
window.toolbar.set_record_checked(True)
assert window.toolbar.btn_record.objectName() == "dangerButton"
assert window.toolbar.btn_run.objectName() == "dangerOutline", (
assert window.toolbar.btn_stop.objectName() == "dangerOutline", (
"two filled actions share the rail while recording"
)
window.toolbar.set_record_checked(False)
window.toolbar.btn_stop.click()
stuck.release()
def test_the_live_rail_is_a_play_pause_toggle_with_its_own_stop(make_window):
"""Slot 1 means the same thing in both views: play/pause, and Stop beside it.
The live view used to fill that slot with a stop toggle and keep a second,
icon-only pause button next to it, so the same position meant two different
things depending on which view was on screen.
"""
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
# Idle: the transport starts the session, and Stop has nothing to end.
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
assert not window.toolbar.btn_stop.isEnabled()
# Connecting: still a pause toggle, and Stop is the way out of a hung open.
window.toolbar.btn_run.setChecked(True)
window._run_timer.stop()
QApplication.processEvents()
assert window._session_phase == "connecting"
assert window.toolbar.btn_run.text() == t("toolbar.pause")
assert window.toolbar.btn_stop.text() == t("toolbar.cancel_label")
assert window.toolbar.btn_stop.isEnabled()
stuck.emit(ui_check._fake_packet(1))
window._on_run_tick()
QApplication.processEvents()
assert window._session_phase == "running"
assert window.toolbar.btn_run.text() == t("toolbar.pause")
# Unchecked *with* a session is paused, not stopped.
window.toolbar.btn_run.setChecked(False)
QApplication.processEvents()
assert window._session_phase == "running", "the transport stopped instead of pausing"
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
assert stuck.is_paused is True, "the worker was not asked to pause"
assert window.toolbar.btn_stop.isEnabled(), "the way out vanished while paused"
window.toolbar.btn_run.setChecked(True)
QApplication.processEvents()
assert stuck.is_paused is False, "the worker was not asked to resume"
# Stop ends it; the transport goes back to idle.
window.toolbar.btn_stop.click()
QApplication.processEvents()
assert window._session_phase == "idle"
assert not window.toolbar.btn_run.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
stuck.release()
@@ -1590,9 +1655,16 @@ def test_a_late_calibration_does_not_plot_raw_counts_on_the_newton_axis(make_win
assert peak < 5.0, f"a raw count is still drawn on the newton axis (peak {peak})"
# The sample that caused the switch still reaches the readouts; the early
# return used to leave the cards showing the previous sample.
# return used to leave the cards showing the previous sample. The number on
# the card is the *display* copy of it: this fake FN is a slow 2.0…2.4 N ramp
# with no contact flagged, which is exactly the resting drift the display
# baseline holds at zero. What matters here is that the card is not a raw
# count, not the previous sample and not a dash.
assert abs(plots._last_values[0] - (2.0 + 39 * 0.01)) < 1e-9
assert plots.value_labels[0].text() == "2.390", plots.value_labels[0].text()
assert plots.presentation.display_quality != "none", (
"the display policy was not applied to the freshly shown newton unit"
)
assert plots.value_labels[0].text() == "0.000", plots.value_labels[0].text()
assert plots.value_labels[0].text() != t("chart.value_unavailable")
_finish(window, stuck)
@@ -1926,8 +1998,10 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
"""The operator must not have to work out *what* is stuck before stopping it.
The transport is a play/pause toggle, which is ambiguous exactly when something is
wrong. So the rail's second button becomes an unconditional stop in this view, and
it covers both things that can run there: plain playback and a recompute pass.
wrong. So the rail's Stop button — the same one the live view uses to end a session
— is an unconditional stop in this view, and it covers both things that can run
there: plain playback and a recompute pass. The tracker reset owns no device here,
so it is disabled rather than repurposed.
"""
from PySide6.QtWidgets import QApplication
@@ -1939,11 +2013,12 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
assert window._load_recording(replay_recording)
QApplication.processEvents()
assert window.toolbar.reset_role() == "stop", (
"the replay view kept the tracker reset button instead of a stop"
)
assert window.toolbar.btn_reset.text() == t("toolbar.stop_playback")
assert not window.toolbar.btn_reset.isEnabled(), (
"the replay view offers to zero a device tracker it has not got"
)
assert window.toolbar.btn_reset.text() == t("toolbar.reset")
assert window.toolbar.btn_stop.text() == t("toolbar.stop_label")
assert not window.toolbar.btn_stop.isEnabled(), (
"a stop is offered while nothing is running"
)
@@ -1951,8 +2026,8 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
window.toolbar.btn_run.setChecked(True)
QApplication.processEvents()
assert window._play_timer.isActive()
assert window.toolbar.btn_reset.isEnabled(), "no stop offered while playing"
window.toolbar.btn_reset.click()
assert window.toolbar.btn_stop.isEnabled(), "no stop offered while playing"
window.toolbar.btn_stop.click()
QApplication.processEvents()
assert not window._play_timer.isActive(), "the stop did not stop the playback"
assert not window.toolbar.btn_run.isChecked(), "the transport still shows playing"
@@ -1965,8 +2040,8 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
window._stop_run = lambda **kwargs: stopped.append(kwargs.get("reason"))
window._recompute_running = True
window._update_run_enabled()
assert window.toolbar.btn_reset.isEnabled(), "no stop offered while a pass runs"
window.toolbar.btn_reset.click()
assert window.toolbar.btn_stop.isEnabled(), "no stop offered while a pass runs"
window.toolbar.btn_stop.click()
QApplication.processEvents()
assert stopped == ["user_stop"], stopped
assert t("toolbar.stopped_pass") in window.statusBar().currentMessage(), (
@@ -1976,7 +2051,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
# Nothing running: it says so instead of doing nothing at all.
# (The pass is over, so the stop button is correctly disabled — the escape hatch is
# therefore exercised the way Esc reaches it, through the controller.)
assert not window.toolbar.btn_reset.isEnabled(), (
assert not window.toolbar.btn_stop.isEnabled(), (
"a stop is still offered after the pass was stopped"
)
window.replay_ctl.stop_activity()
@@ -2009,10 +2084,14 @@ def test_escape_stops_the_replay_too(make_window, replay_recording):
def test_the_live_view_keeps_the_tracker_reset(make_window):
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert window.toolbar.reset_role() == "reset"
assert window.toolbar.btn_reset.text() == t("toolbar.reset"), (
"the rail's reset slot is not the tracker reset in the live view"
)
window.close()
@@ -2549,6 +2628,84 @@ def test_the_dialog_writes_the_keys_the_session_reads_back(qt_app):
assert read_back.describe() == "flowEMA(0.5) + median(3) + lowpass(10Hz,order2)"
def test_the_plot_dialog_carries_the_ranges_and_the_zero_threshold(qt_app):
"""FN has a lower bound (which may be negative); both units a deadband."""
from orisys_studio.ui.plot_settings_dialog import PlotSettingsDialog
dialog = PlotSettingsDialog(
units="newton", fn_min=-2.0, fn_max=6.0, deadband_n=0.25, deadband_raw=2000.0
)
assert dialog.settings()["fn_min"] == -2.0
assert dialog.settings()["fn_max"] == 6.0
assert dialog.deadbands() == {"deadband_n": 0.25, "deadband_raw": 2000.0}
# Only the half that belongs to the unit being shown can be edited.
assert dialog.fn_min_spin.isEnabled() and not dialog.fn_raw_min_spin.isEnabled()
assert dialog.deadband_n_spin.isEnabled()
assert not dialog.deadband_raw_spin.isEnabled()
raw = PlotSettingsDialog(units="raw")
assert raw.fn_raw_min_spin.isEnabled() and not raw.fn_min_spin.isEnabled()
assert raw.deadband_raw_spin.isEnabled() and not raw.deadband_n_spin.isEnabled()
raw.fn_raw_min_spin.setValue(-50000.0)
raw.deadband_raw_spin.setValue(5000.0)
assert raw.settings()["fn_raw_min"] == -50000.0
raw._reset_defaults()
assert raw.settings()["fn_raw_min"] == 0.0
assert raw.settings()["fn_min"] == 0.0
assert raw.settings()["window_sec"] == 4.0
assert raw.deadbands() == {"deadband_n": 0.0, "deadband_raw": 0.0}, (
"the shipped deadband answer is 'auto'"
)
def test_the_plot_dialog_values_reach_the_chart(make_window):
"""Dialog -> controller -> panel: where an operator's range actually lands."""
from orisys_studio.ui.plot_settings_dialog import PlotSettingsDialog
window = make_window()
dialog = PlotSettingsDialog(
units="newton",
window_sec=6.0,
fn_min=-2.0,
fn_max=6.0,
shear_max=3.0,
fn_raw_min=-1000.0,
fn_raw_max=5e5,
shear_raw_max=2e5,
deadband_n=0.25,
deadband_raw=3000.0,
)
window.display_ctl.apply_plot_settings(dialog.settings())
plots = window.force_plots
assert (plots._fn_min, plots._fn_max, plots._shear_max) == (-2.0, 6.0, 3.0)
assert (plots._fn_raw_min, plots._fn_raw_max, plots._shear_raw_max) == (
-1000.0, 5e5, 2e5,
)
assert plots.presentation.min_deadband_for("N") == 0.25
assert plots.presentation.min_deadband_for("raw") == 3000.0
assert window.prefs["plot_fn_min"] == -2.0
assert window.prefs["plot_deadband_raw"] == 3000.0
assert plots.history.window_sec == 6.0
# And the dialog would open on the same numbers next time.
assert window.display_ctl.plot_settings()["fn_min"] == -2.0
window.close()
def test_the_display_baseline_is_on_for_every_sensor_by_default(make_window):
"""It used to be Finger-raw only; an idle box sensor drifts just as much."""
window, stuck = _running_window(make_window) # sensor_type="box"
plots = window.force_plots
assert plots._presentation_enabled is True, "a box sensor lost its baseline"
assert plots.presentation.enabled is True
# The per-unit floors came from the preferences, not from a sensor type.
assert plots.presentation.min_deadband_for("raw") == 50.0
assert plots.presentation.min_deadband_for("N") == 0.0
_finish(window, stuck)
-6
View File
@@ -103,7 +103,6 @@ class _Toolbar:
self._expanded = expanded
self.source_views: list[str] = []
self.run_roles: list[str] = []
self.reset_roles: list[str] = []
def set_source_view(self, view: str) -> None:
self.source_views.append(view)
@@ -111,9 +110,6 @@ class _Toolbar:
def set_run_role(self, role: str) -> None:
self.run_roles.append(role)
def set_reset_role(self, role: str) -> None:
self.reset_roles.append(role)
def settings_expanded(self) -> bool:
return self._expanded
@@ -237,14 +233,12 @@ def test_a_preset_decides_which_panels_a_view_shows():
assert parts["charts"].visible is True
assert parts["host"].prefs["view_preset"] == "calibration"
assert parts["toolbar"].source_views[-1] == "live"
assert parts["toolbar"].reset_roles[-1] == "reset"
assert parts["host"].persisted == 1
controller.apply_preset("replay")
assert parts["image"].visible is True
assert parts["toolbar"].source_views[-1] == "replay"
assert parts["toolbar"].run_roles[-1] == "playback"
assert parts["toolbar"].reset_roles[-1] == "stop"
# The charts follow the recording in this view, and the replay's own entries are
# re-synced with it. (Calibration did neither of those: it has no replay source.)
assert parts["replay"].sources == [True]
+91 -21
View File
@@ -247,11 +247,27 @@ class _StuckWorker(RecordSinkMixin):
#: Zeroing requests, and the confirmation callback the window installs.
self.reset_requests = 0
self.on_reset_applied = None
#: Pause state, so the window's Pause button has something to drive. The real
#: worker's contract: pause stops computing and keeps the device open.
self.is_paused = False
self.pause_requests = 0
#: Filters handed over before a session, and what the fake device reports
#: as in effect (this stub has no ``configure``, so it reports nothing).
self.filter_settings_value = FilterSettings()
self.filter_state_value: dict = {}
def pause(self) -> None:
self.is_paused = True
self.pause_requests += 1
def resume(self) -> None:
self.is_paused = False
self.pause_requests += 1
@property
def paused(self) -> bool:
return bool(self.is_paused)
def set_filter_settings(self, settings) -> None:
self.filter_settings_value = settings
@@ -1072,10 +1088,18 @@ def main() -> int:
window.toolbar.btn_run.objectName() == "primaryButton",
f"idle Run is not the filled primary: {window.toolbar.btn_run.objectName()!r}",
)
check(
window.toolbar.btn_run.text() == t("toolbar.run_label"),
f"the idle transport does not say it runs: {window.toolbar.btn_run.text()!r}",
)
check(
not window.toolbar.btn_record.isEnabled(),
"recording is offered without a session",
)
check(
not window.toolbar.btn_stop.isEnabled(),
"Stop is offered before a session exists",
)
stuck = _StuckWorker()
window._ensure_workers = lambda: None
@@ -1084,22 +1108,62 @@ def main() -> int:
window.toolbar.btn_run.setChecked(True)
app.processEvents()
check(
window.toolbar.btn_run.objectName() == "dangerButton",
window.toolbar.btn_run.text() == t("toolbar.pause"),
f"the acquiring transport does not offer pause: "
f"{window.toolbar.btn_run.text()!r}",
)
check(
window.toolbar.btn_stop.objectName() == "dangerButton",
"Cancel is not the filled action while a connect hangs",
)
check(
window.toolbar.btn_stop.text() == t("toolbar.cancel_label"),
f"the connecting Stop does not advertise cancel: "
f"{window.toolbar.btn_stop.text()!r}",
)
stuck.emit(_fake_packet(1))
window._on_run_tick()
app.processEvents()
check(window._session_phase == "running", "the session never reached running")
check(
window.toolbar.btn_run.objectName() == "dangerOutline",
f"Stop competes with the record action: "
f"{window.toolbar.btn_run.objectName()!r}",
window.toolbar.btn_stop.objectName() == "dangerOutline",
f"Stop stays filled while a session merely acquires: "
f"{window.toolbar.btn_stop.objectName()!r}",
)
check(
window.toolbar.btn_stop.text() == t("toolbar.stop_label"),
f"Stop lost its label once the connect returned: "
f"{window.toolbar.btn_stop.text()!r}",
)
check(
window.toolbar.btn_record.objectName() == "primaryButton",
"the record action is not the filled primary while acquiring",
)
check(
window.toolbar.btn_run.objectName() == "ghostButton",
f"the transport competes with the filled record action: "
f"{window.toolbar.btn_run.objectName()!r}",
)
# Unchecking the transport pauses the session; it must not end it, and it must
# leave the way out (the red Stop) exactly where it was.
window.toolbar.btn_run.setChecked(False)
app.processEvents()
check(
window.toolbar.btn_run.text() == t("toolbar.resume_label"),
f"the paused transport does not offer resume: {window.toolbar.btn_run.text()!r}",
)
check(
window._session_phase == "running"
and window.toolbar.btn_stop.isEnabled()
and window.toolbar.btn_reset.isEnabled(),
"pausing the transport did not leave a running, stoppable session",
)
window.toolbar.btn_run.setChecked(True)
app.processEvents()
check(
window.toolbar.btn_run.text() == t("toolbar.pause"),
f"the resumed transport does not offer pause: {window.toolbar.btn_run.text()!r}",
)
check(
window._calibration_summary() == t("status.calibrated_all"),
f"a fully calibrated packet is not reported as such: "
@@ -1112,7 +1176,8 @@ def main() -> int:
"stopping the recording is not the filled danger",
)
check(
window.toolbar.btn_run.objectName() == "dangerOutline",
window.toolbar.btn_run.objectName() not in ("primaryButton", "dangerButton")
and window.toolbar.btn_stop.objectName() not in ("primaryButton", "dangerButton"),
"two filled actions share the rail while recording",
)
@@ -1183,22 +1248,22 @@ def main() -> int:
check(window._session_phase == "connecting", "run did not enter the connecting phase")
check(
window.toolbar.btn_run.isEnabled(),
window.toolbar.btn_stop.isEnabled(),
"Stop is disabled while connecting — a hung connect could not be cancelled",
)
check(window.toolbar.btn_run.isChecked(), "run button did not latch into the stop state")
check(window.toolbar.btn_run.isChecked(), "the transport does not show acquiring")
check(
window.toolbar.btn_run.text() == t("toolbar.cancel_label"),
f"connecting label is not the cancel label: {window.toolbar.btn_run.text()!r}",
window.toolbar.btn_stop.text() == t("toolbar.cancel_label"),
f"connecting label is not the cancel label: {window.toolbar.btn_stop.text()!r}",
)
check(
window.toolbar.btn_run.toolTip() == t("toolbar.cancel_tip"),
window.toolbar.btn_stop.toolTip() == t("toolbar.cancel_tip"),
"connecting tooltip does not explain how to cancel",
)
check(window.act_stop.isEnabled(), "Stop shortcut is disabled while connecting")
started = time.perf_counter()
window.toolbar.btn_run.setChecked(False)
window.toolbar.btn_stop.click()
elapsed = time.perf_counter() - started
check(elapsed < 0.5, f"Stop blocked the GUI for {elapsed:.2f}s on a hung connect")
check(not window._running, "session stayed active after Stop")
@@ -3140,17 +3205,22 @@ def main() -> int:
)
check(replay_window.toolbar.btn_run.isEnabled(), "the replay has no transport")
# The replay view offers an unconditional stop (the rail's second button), because
# the transport is a play/pause toggle and "which one is stuck?" is not a question
# to ask an operator in front of a frozen picture.
# The replay view stops through the rail's own Stop button — the same control the
# live view uses — because the transport is a play/pause toggle and "which one is
# stuck?" is not a question to ask an operator in front of a frozen picture. The
# tracker reset owns no device here, so it is disabled rather than repurposed.
check(
replay_window.toolbar.reset_role() == "stop",
"the replay view has no stop button",
not replay_window.toolbar.btn_reset.isEnabled(),
"the replay view offers to zero a device tracker it has not got",
)
check(
replay_window.toolbar.btn_reset.text() == t("toolbar.stop_playback"),
f"the stop button is not labelled as one: "
f"{replay_window.toolbar.btn_reset.text()!r}",
replay_window.toolbar.btn_stop.text() == t("toolbar.stop_label"),
f"the replay's stop button is not labelled as one: "
f"{replay_window.toolbar.btn_stop.text()!r}",
)
check(
not replay_window.toolbar.btn_stop.isEnabled(),
"a stop is offered while nothing is running",
)
# Start playing so there is something to stop, then stop it with that button.
replay_window.toolbar.btn_run.setChecked(True)
@@ -3160,10 +3230,10 @@ def main() -> int:
"the playback did not start, so the stop check would prove nothing",
)
check(
replay_window.toolbar.btn_reset.isEnabled(),
replay_window.toolbar.btn_stop.isEnabled(),
"the stop is not offered while the transport is playing",
)
replay_window.toolbar.btn_reset.click()
replay_window.toolbar.btn_stop.click()
app.processEvents()
check(
not replay_window.toolbar.btn_run.isChecked(),
+3 -1
View File
@@ -336,7 +336,9 @@ def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--theme", choices=("dark", "light"), default="dark")
parser.add_argument("--lang", choices=("zh_CN", "en_US"), default="zh_CN")
parser.add_argument("--sensor", choices=("box", "mini", "finger"), default="box")
parser.add_argument(
"--sensor", choices=("box", "mini", "finger", "finger-mems"), default="box"
)
parser.add_argument(
"--panels",
default="",
Binary file not shown.
@@ -27,6 +27,7 @@ from orisys_app_shared.acquisition.sdk_worker import (
FILE_FIRST_FRAME_TIMEOUT_S,
FPS_WINDOW_S,
HANDOVER_TIMEOUT_S,
PAUSE_POLL_S,
STOP_JOIN_TIMEOUT_S,
VIDEO_EOF_TIMEOUT_S,
_capture_sequence,
@@ -88,6 +89,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._on_reset_applied = on_reset_applied
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
#: Set while the operator has paused: the camera stays open and the source keeps
#: being read, but no frame is processed and no packet is emitted.
self._pause = threading.Event()
self._lock = threading.Lock()
self._latest: Optional[SdkPacket] = None
self._session_id = 0
@@ -219,6 +223,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
"""
self._set_end_reason("stopped")
self._stop.set()
# A stopped worker is not paused: leaving the flag set would make the next session
# start silent.
self._pause.clear()
thread = self._thread
if wait and thread is not None and thread.is_alive():
thread.join(timeout=STOP_JOIN_TIMEOUT_S)
@@ -236,6 +243,24 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._latest = None
self._phase = "idle"
def pause(self) -> None:
"""Stop computing and publishing; the camera stays open.
Same contract as the box worker's: the source keeps being read so the device
is not released and the disconnect watchdog stays quiet, but no frame is
processed (no flow, no depth, no contact) and no packet is emitted, so the
display freezes on its last frame and the record channel sees a gap.
"""
self._pause.set()
def resume(self) -> None:
"""Start computing and publishing again, from the *newest* frame."""
self._pause.clear()
@property
def paused(self) -> bool:
return self._pause.is_set()
def reset(self) -> None:
"""Request a tracker reset, applied by the acquisition thread.
@@ -328,6 +353,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._on_status(message)
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
# A new session always starts unpaused: pausing is a statement about the session the
# operator is watching, not a sticky property of the device.
self._pause.clear()
try:
self._run_session(session, previous)
finally:
@@ -461,6 +489,17 @@ class FingerAcquisitionWorker(RecordSinkMixin):
time.sleep(0.005)
continue
if self._pause.is_set():
# Paused: the source read above proves the camera is alive, so refresh the
# liveness stamps the disconnect watchdog reads and take no frame. Skipping
# ``process_frame`` is what "pause the computation" means; publishing nothing
# is what "pause the display" means. ``read_source`` hands back the newest
# frame, so a resume starts from live data, not a backlog.
last_frame_mono = t0
saw_frame = True
time.sleep(PAUSE_POLL_S)
continue
unrolled = runtime.process_frame(source_frame, color=False)
if unrolled is None:
if saw_frame and is_file and none_streak_start is None:
@@ -52,6 +52,12 @@ FPS_WINDOW_S = 1.0
#: ``_high_resolution_timer``.
CAPTURE_REPEAT_POLL_S = 0.002
#: How long a paused loop waits before looking at the capture source again. Paused is not
#: stopped: the frame is still read (that is what keeps the device open and the disconnect
#: watchdog quiet) but nothing is computed, so this only has to be short enough that a resume
#: feels immediate.
PAUSE_POLL_S = 0.005
@contextlib.contextmanager
def _high_resolution_timer():
@@ -500,6 +506,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
self._on_reset_applied = on_reset_applied
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
#: Set while the operator has paused: the device stays open and the capture keeps
#: publishing, but nothing is computed and no packet is emitted.
self._pause = threading.Event()
self._lock = threading.Lock()
self._latest: Optional[SdkPacket] = None
self._session_id = 0
@@ -651,6 +660,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
"""
self._set_end_reason("stopped")
self._stop.set()
# A stopped worker is not paused: leaving the flag set would make the next
# session start silent.
self._pause.clear()
thread = self._thread
if wait and thread is not None and thread.is_alive():
thread.join(timeout=STOP_JOIN_TIMEOUT_S)
@@ -669,6 +681,27 @@ class SdkAcquisitionWorker(RecordSinkMixin):
self._latest = None
self._phase = "idle"
def pause(self) -> None:
"""Stop computing and publishing; the device stays open.
This is what makes pause different from :meth:`stop`: the camera is not
released, so resuming is immediate and the device does not have to be
re-opened (and, on some boards, re-enumerated). While paused the loop keeps
draining the capture source — the disconnect watchdog must not fire on a
healthy camera just because an operator stopped looking at it — but it runs
no algorithm and emits no packet: no flow, no depth, no contact, and the
record channel sees a gap for the paused interval.
"""
self._pause.set()
def resume(self) -> None:
"""Start computing and publishing again, from the *newest* frame."""
self._pause.clear()
@property
def paused(self) -> bool:
return self._pause.is_set()
def reset(self) -> None:
"""Request a tracker reset, applied by the acquisition thread.
@@ -764,6 +797,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
self._on_status(message)
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
# A new session always starts unpaused: pausing is a statement about the
# session the operator is watching, not a sticky property of the device.
self._pause.clear()
try:
# A fine timer for the whole session: the loop waits for the next
# frame between frames, and on Windows a sub-tick wait otherwise
@@ -927,6 +963,18 @@ class SdkAcquisitionWorker(RecordSinkMixin):
time.sleep(0)
continue
last_delivered = delivered
if self._pause.is_set():
# Paused: the capture above already refreshed ``last_source_seq``, so the
# device is demonstrably alive — refresh the liveness stamps the disconnect
# watchdog reads, then take no frame. ``get_img()`` is the algorithm (flow,
# depth, contact), so skipping it is what "pause the computation" means; no
# packet is published either, which is what "pause the display" means. The
# capture source hands back only the newest frame, so resuming picks up live
# data rather than working through a backlog.
last_frame_mono = t0
saw_frame = True
time.sleep(PAUSE_POLL_S)
continue
processed = sensor.get_img()
if processed is None:
if saw_frame and is_file and none_streak_start is None:
@@ -1,7 +1,34 @@
"""Display-only force presentation: baseline, deadband, and formatting.
Never mutates recorded samples. Curves, statistics and CSV keep the measurement
values; value cards (and optional display copies) go through this layer.
Never mutates recorded samples. The session store, ``forces.csv`` and the metric
history keep the measurement; the value cards *and the curves* are drawn from
this layer, which removes the idle bias and holds a resting channel at exactly
zero.
The estimate is kept **per display unit**. A raw count and a newton reading of
the same axis are different scales, so one deadband in the wrong unit would
either hide a real contact or leave the noise outside it. Values arrive here
already expressed in the unit the operator is looking at (``force_plots.push``
passes the live triplet in its own unit), and the unit is part of the key.
The estimate is published in two stages, because waiting for the whole startup
window leaves the operator staring at the device's raw bias:
* **provisional** — as soon as a few quiet samples exist (see
:data:`DEFAULT_PROVISIONAL_SAMPLES`) an estimate is fitted from them and
*applied*; it is re-fitted on every further quiet sample. The cards and the
curves therefore read zero from the first tenths of a second of a session
instead of after ``baseline_seconds``.
* **frozen** — when the quiet window closes with quality ``ok`` the estimate is
frozen exactly as before and stops chasing the data.
A window that closes *without* a trustworthy estimate (contaminated, unstable,
insufficient samples) keeps the provisional estimate applied rather than
reverting to the raw bias: `quality` on :class:`ZeroEstimate` and
:attr:`ForcePresentation.display_quality` say where the numbers came from. A
window in which *no* quiet sample was ever seen has nothing to estimate from, so
nothing is invented — the measurement is displayed unchanged, which is how a
contact present at session start stays visible instead of being zeroed away.
"""
from __future__ import annotations
@@ -11,6 +38,27 @@ from typing import Optional, Sequence
import numpy as np
#: Floor under the estimated deadband, per display unit. 50 raw counts is a
#: meaningful noise floor for the uncalibrated device; 50 N would be a contact,
#: so newton mode defaults to no floor at all and relies on the estimated
#: percentile (see :func:`ForcePresentation.fit_zero`).
DEFAULT_MIN_DEADBAND_RAW = 50.0
DEFAULT_MIN_DEADBAND_N = 0.0
#: How many quiet samples are enough to publish a *provisional* estimate.
#: Three is the smallest window ``fit_zero`` can take a median and a residual
#: percentile from, and at the ~30 Hz force rate it is about a tenth of a
#: second: the operator sees the display policy working almost immediately
#: rather than watching the device's raw bias for the whole startup window.
#: Every further quiet sample re-fits the estimate, so three is a starting
#: point, not a decision — the frozen window still has the final say.
DEFAULT_PROVISIONAL_SAMPLES = 3
def normalise_unit(unit: Optional[str]) -> str:
"""``"N"`` or ``"raw"`` — the two units a force reading can be shown in."""
return "N" if str(unit or "").upper().startswith("N") else "raw"
@dataclass(frozen=True)
class ZeroEstimate:
@@ -34,72 +82,236 @@ class DisplayForce:
@dataclass
class ForcePresentation:
"""Stateful display policy for force value cards.
class _UnitEstimate:
"""What was learned about one display unit's idle state."""
Startup auto-baseline collects quiet samples, freezes offsets and deadbands,
then applies a symmetric hysteretic deadband only to the display copy.
collecting: bool = False
samples: list[tuple[float, float, float]] = field(default_factory=list)
sample_times: list[float] = field(default_factory=list)
t0: Optional[float] = None
#: Frozen estimate: set once the quiet window closes with quality ``ok``.
offsets: Optional[tuple[float, float, float]] = None
deadbands: Optional[tuple[float, float, float]] = None
estimate: Optional[ZeroEstimate] = None
#: Provisional estimate, published from the first few quiet samples and
#: re-fitted on each further one. It is what the display uses while the
#: window is still open, and what it keeps if the window closes badly.
provisional_offsets: Optional[tuple[float, float, float]] = None
provisional_deadbands: Optional[tuple[float, float, float]] = None
provisional_samples: int = 0
#: A quiet sample was refused because a contact was present or the flow was
#: too high: this window is not a trustworthy idle window any more.
contaminated: bool = False
@property
def ready(self) -> bool:
"""A *frozen* estimate: the startup window closed with quality ``ok``."""
return self.offsets is not None and self.deadbands is not None
@property
def display_offsets(self) -> Optional[tuple[float, float, float]]:
"""What the display is centred on: frozen if there is one, else provisional."""
return self.offsets if self.offsets is not None else self.provisional_offsets
@property
def display_deadbands(self) -> Optional[tuple[float, float, float]]:
"""The deadband the display is using (frozen if there is one)."""
return (
self.deadbands if self.deadbands is not None else self.provisional_deadbands
)
@property
def display_ready(self) -> bool:
"""Whether *some* estimate — provisional or frozen — is being applied."""
return (
self.display_offsets is not None and self.display_deadbands is not None
)
@dataclass
class ForcePresentation:
"""Stateful display policy for the force numbers *and* the force curves.
Startup auto-baseline collects quiet samples per display unit, freezes
offsets and deadbands, then applies a symmetric hysteretic deadband to the
display copy. It is enabled for every sensor type and both units: an idle
channel that reads a raw count or a newton offset at rest is a display
problem whichever device produced it.
"""
enabled: bool = True
unit: str = "raw"
baseline_seconds: float = 3.0
min_samples: int = 30
#: Quiet samples needed before a provisional estimate is applied (see
#: :data:`DEFAULT_PROVISIONAL_SAMPLES`).
provisional_samples: int = DEFAULT_PROVISIONAL_SAMPLES
max_flow_p95: float = 0.4
require_no_contact: bool = True
#: Residual percentile used as the per-axis deadband after offset removal.
deadband_percentile: float = 99.0
#: Extra margin on top of the percentile residual.
deadband_margin: float = 1.05
#: Floor so tiny MAD windows do not produce a zero deadband.
min_deadband: float = 50.0
#: Floor so tiny MAD windows do not produce a zero deadband (see above).
min_deadband_raw: float = DEFAULT_MIN_DEADBAND_RAW
min_deadband_n: float = DEFAULT_MIN_DEADBAND_N
hysteresis_ratio: float = 0.8
raw_significant_digits: int = 4
newton_decimals: int = 3
_collecting: bool = field(default=True, init=False, repr=False)
_samples: list[tuple[float, float, float]] = field(
default_factory=list, init=False, repr=False
)
_sample_times: list[float] = field(default_factory=list, init=False, repr=False)
_offsets: Optional[tuple[float, float, float]] = field(
default=None, init=False, repr=False
)
_deadbands: Optional[tuple[float, float, float]] = field(
default=None, init=False, repr=False
#: One idle estimate per display unit ("raw" / "N"), because the floors and
#: the values are in different scales.
_states: dict[str, _UnitEstimate] = field(
default_factory=dict, init=False, repr=False
)
_suppressed: tuple[bool, bool, bool] = field(
default=(False, False, False), init=False, repr=False
)
_last_estimate: Optional[ZeroEstimate] = field(
default=None, init=False, repr=False
)
_t0: Optional[float] = field(default=None, init=False, repr=False)
# ------------------------------------------------------------------ policy
def reset_session(self, *, unit: str = "raw") -> None:
"""Start a new idle estimate for a session."""
self.unit = "N" if str(unit).upper().startswith("N") else "raw"
self._collecting = self.enabled and self.unit == "raw"
self._samples = []
self._sample_times = []
self._offsets = None
self._deadbands = None
"""Start a new idle estimate for one display unit."""
self.unit = normalise_unit(unit)
state = self._state(self.unit)
state.collecting = bool(self.enabled)
state.samples = []
state.sample_times = []
state.t0 = None
state.offsets = None
state.deadbands = None
state.estimate = None
state.provisional_offsets = None
state.provisional_deadbands = None
state.provisional_samples = 0
state.contaminated = False
self._suppressed = (False, False, False)
self._last_estimate = None
self._t0 = None
def request_reestimate(self) -> None:
"""Operator-triggered restart of the idle window."""
self.reset_session(unit=self.unit)
def set_enabled(self, enabled: bool, *, unit: Optional[str] = None) -> None:
"""The master switch; switching it on restarts the quiet window."""
self.enabled = bool(enabled)
self.reset_session(unit=self.unit if unit is None else unit)
def min_deadband_for(self, unit: Optional[str] = None) -> float:
"""The floor under the estimated deadband, for one display unit."""
chosen = self.unit if unit is None else unit
return (
float(self.min_deadband_n)
if normalise_unit(chosen) == "N"
else float(self.min_deadband_raw)
)
def set_min_deadband(self, unit: str, floor: float) -> None:
"""Move one unit's floor, and re-floor the estimate already applied.
The floor is a lower bound, never a replacement: the samples collected so
far are re-fitted, so setting it back to 0 ("auto") restores the
estimated band instead of leaving the raised one behind. Both the frozen
estimate and the provisional one in use are re-fitted, because either can
be what the cards and the curves are reading.
"""
floor = max(0.0, float(floor))
if normalise_unit(unit) == "N":
self.min_deadband_n = floor
else:
self.min_deadband_raw = floor
state = self._states.get(normalise_unit(unit))
if state is None or not state.samples:
return # a window still being collected will use the new floor
estimate = self.fit_zero(state.samples, unit=unit)
if estimate.quality != "ok":
return
if state.collecting or not state.ready:
# Nothing frozen yet: this is the provisional estimate on screen.
state.provisional_offsets = estimate.offsets
state.provisional_deadbands = estimate.deadbands
state.provisional_samples = estimate.sample_count
return
state.offsets = estimate.offsets
state.deadbands = estimate.deadbands
state.estimate = estimate
@property
def ready(self) -> bool:
return self._offsets is not None and self._deadbands is not None
"""A frozen estimate for the active unit (the startup window closed)."""
state = self._states.get(self.unit)
return bool(state is not None and state.ready)
@property
def display_ready(self) -> bool:
"""Whether the display policy is being applied to the active unit.
True from the first few quiet samples (provisional) and after the frozen
window as well — this is what the cards and the curves ask, because both
are drawn from the same applied estimate.
"""
state = self._states.get(self.unit)
return bool(state is not None and state.display_ready)
@property
def display_quality(self) -> str:
"""Where the numbers on screen come from, for diagnostics and reporting.
``"off"`` — the aid is switched off; the measurement is shown.
``"provisional"`` — a provisional estimate from the first quiet samples
is applied; the startup window is still open.
``"ok"`` — the full quiet window closed and was frozen.
``"contaminated"`` / ``"unstable"`` / ``"insufficient"`` — the window
closed without a trustworthy estimate: the
provisional estimate stays applied (this is the
state that used to fall back to the raw bias).
``"none"`` — no quiet sample was ever seen, so nothing is
zeroed; a contact at session start is shown as it
is measured rather than hidden.
"""
if not self.enabled:
return "off"
state = self._states.get(self.unit)
if state is None or not state.display_ready:
return "none"
if state.ready:
return "ok"
if not state.collecting and state.estimate is not None:
return state.estimate.quality
return "provisional"
@property
def last_estimate(self) -> Optional[ZeroEstimate]:
return self._last_estimate
"""The window's outcome: the frozen estimate, the failed one, or the
provisional estimate while the window is still open."""
state = self._states.get(self.unit)
if state is None:
return None
if state.estimate is not None:
return state.estimate
if state.provisional_offsets is None or state.provisional_deadbands is None:
return None
return ZeroEstimate(
offsets=state.provisional_offsets,
deadbands=state.provisional_deadbands,
sample_count=state.provisional_samples,
quality="provisional",
message=f"provisional: {state.provisional_samples} quiet samples so far",
)
def _state(self, unit: str) -> _UnitEstimate:
"""The estimate for one unit, created on first use.
A unit that was never estimated — the operator switched mid-session —
starts its own quiet window instead of borrowing the other unit's, which
would be an offset in the wrong scale.
"""
key = normalise_unit(unit)
state = self._states.get(key)
if state is None:
state = _UnitEstimate(collecting=bool(self.enabled))
self._states[key] = state
return state
# --------------------------------------------------------------- estimator
def observe(
self,
values: Sequence[float],
@@ -108,51 +320,96 @@ class ForcePresentation:
valid: bool = True,
flow_p95: Optional[float] = None,
contact_found: bool = False,
unit: Optional[str] = None,
) -> Optional[ZeroEstimate]:
"""Feed one live sample into the startup estimator.
``values`` must already be expressed in ``unit`` (the displayed one).
Returns a :class:`ZeroEstimate` when the window closes (ok or failed).
"""
if not self._collecting or self.unit != "raw":
key = normalise_unit(self.unit if unit is None else unit)
state = self._state(key)
if not state.collecting:
return None
if self._t0 is None:
self._t0 = float(t_rel_s)
if state.t0 is None:
state.t0 = float(t_rel_s)
if not valid:
return None
if self.require_no_contact and contact_found:
# Contaminated window — keep collecting but mark later if needed.
# Contaminated window — the sample is not a quiet one, so it must not
# enter the estimate (that is how a press at session start stays
# visible). The window is marked so a close can report it honestly.
state.contaminated = True
return None
if flow_p95 is not None and float(flow_p95) > self.max_flow_p95:
state.contaminated = True
return None
sample = tuple(float(v) for v in values[:3])
if len(sample) < 3 or not all(np.isfinite(sample)):
return None
self._samples.append(sample) # type: ignore[arg-type]
self._sample_times.append(float(t_rel_s))
elapsed = float(t_rel_s) - float(self._t0)
if elapsed < self.baseline_seconds and len(self._samples) < self.min_samples * 2:
state.samples.append(sample) # type: ignore[arg-type]
state.sample_times.append(float(t_rel_s))
# Publish or refine the provisional estimate *before* the window is
# allowed to close, so the display policy is effective from the first
# samples and a later contamination cannot drop it back to the raw bias.
self._update_provisional(state, key)
elapsed = float(t_rel_s) - float(state.t0)
if elapsed < self.baseline_seconds and len(state.samples) < self.min_samples * 2:
return None
if len(self._samples) < self.min_samples:
if len(state.samples) < self.min_samples:
# The window closed before it was ever a full quiet window. It is a
# *failure* of the startup estimate, and the honest thing to do is
# say so — but the display keeps whatever the provisional estimate
# already worked out, because falling back to the raw bias is the
# defect this class exists to remove.
quality = "contaminated" if state.contaminated else "insufficient"
detail = (
"window contaminated (contact or flow) after"
if state.contaminated
else "only"
)
estimate = ZeroEstimate(
offsets=(0.0, 0.0, 0.0),
deadbands=(0.0, 0.0, 0.0),
sample_count=len(self._samples),
quality="insufficient",
message=f"only {len(self._samples)} quiet samples in {elapsed:.2f}s",
sample_count=len(state.samples),
quality=quality,
message=f"{detail} {len(state.samples)} quiet samples in {elapsed:.2f}s",
)
self._last_estimate = estimate
self._collecting = False
state.estimate = estimate
state.collecting = False
return estimate
estimate = self.fit_zero(self._samples)
self._last_estimate = estimate
self._collecting = False
estimate = self.fit_zero(state.samples, unit=key)
state.estimate = estimate
state.collecting = False
if estimate.quality == "ok":
self._offsets = estimate.offsets
self._deadbands = estimate.deadbands
state.offsets = estimate.offsets
state.deadbands = estimate.deadbands
return estimate
def fit_zero(self, samples: Sequence[Sequence[float]]) -> ZeroEstimate:
def _update_provisional(self, state: _UnitEstimate, unit: str) -> None:
"""Fit and apply the display estimate from the quiet samples so far.
Called on every accepted quiet sample. The estimate is only published
once there are enough samples to take a median (:data:`DEFAULT_PROVISIONAL_SAMPLES`);
from then on each sample sharpens it until the frozen window closes. A
sample set the fitter rejects (not enough finite rows, wildly unstable)
leaves the previous provisional estimate in place rather than dropping
the display back to the raw bias.
"""
if len(state.samples) < max(3, int(self.provisional_samples)):
return
estimate = self.fit_zero(state.samples, unit=unit)
if estimate.quality != "ok":
return
state.provisional_offsets = estimate.offsets
state.provisional_deadbands = estimate.deadbands
state.provisional_samples = estimate.sample_count
def fit_zero(
self, samples: Sequence[Sequence[float]], *, unit: Optional[str] = None
) -> ZeroEstimate:
"""Estimate offsets and deadbands from quiet samples (does not apply them)."""
floor = self.min_deadband_for(unit)
block = np.asarray(samples, dtype=np.float64)
if block.ndim != 2 or block.shape[1] < 3 or block.shape[0] < 3:
return ZeroEstimate(
@@ -178,7 +435,7 @@ class ForcePresentation:
med = float(np.median(finite))
residual = np.abs(finite - med)
p = float(np.percentile(residual, self.deadband_percentile))
db = max(self.min_deadband, p * self.deadband_margin)
db = max(floor, p * self.deadband_margin)
# Reject wildly unstable windows (p99 >> median magnitude scale).
mad = float(np.median(residual))
if mad > 0 and p > 20.0 * mad and p > 2000.0:
@@ -198,15 +455,19 @@ class ForcePresentation:
quality="ok",
)
def apply_estimate(self, estimate: ZeroEstimate) -> None:
def apply_estimate(
self, estimate: ZeroEstimate, *, unit: Optional[str] = None
) -> None:
"""Explicitly freeze an estimate (e.g. after user confirmation)."""
if estimate.quality != "ok":
return
self._offsets = estimate.offsets
self._deadbands = estimate.deadbands
self._collecting = False
self._last_estimate = estimate
state = self._state(self.unit if unit is None else unit)
state.offsets = estimate.offsets
state.deadbands = estimate.deadbands
state.collecting = False
state.estimate = estimate
# ------------------------------------------------------------------ display
def accept(
self,
values: Sequence[float],
@@ -214,19 +475,23 @@ class ForcePresentation:
unit: Optional[str] = None,
available: tuple[bool, bool, bool] = (True, True, True),
) -> DisplayForce:
"""Return a display copy; never mutates ``values``."""
unit = self.unit if unit is None else (
"N" if str(unit).upper().startswith("N") else "raw"
)
"""Return a display copy of one live sample; never mutates ``values``.
The deadband is hysteretic: an axis already held at zero stays there
until it leaves the full band, so a reading on the edge does not flicker
between a number and zero.
The offset it subtracts is the frozen estimate once the startup window
has closed cleanly, and the provisional one before (or instead of) that
— never "nothing", which is what used to leave the raw bias on the cards
for the first seconds of a session.
"""
unit = normalise_unit(self.unit if unit is None else unit)
raw = [float(v) for v in values[:3]]
while len(raw) < 3:
raw.append(float("nan"))
if (
not self.enabled
or unit != "raw"
or self._offsets is None
or self._deadbands is None
):
state = self._states.get(unit)
if not self.enabled or state is None or not state.display_ready:
return DisplayForce(
values=(raw[0], raw[1], raw[2]),
suppressed=(False, False, False),
@@ -242,8 +507,8 @@ class ForcePresentation:
out.append(raw[i])
suppressed.append(False)
continue
centered = raw[i] - self._offsets[i]
on = self._deadbands[i]
centered = raw[i] - state.display_offsets[i] # type: ignore[index]
on = state.display_deadbands[i] # type: ignore[index]
off = on * self.hysteresis_ratio
was = prev[i]
if was:
@@ -264,11 +529,48 @@ class ForcePresentation:
zero_zone=any(self._suppressed),
)
def present(
self, values, *, unit: Optional[str] = None
) -> tuple[np.ndarray, np.ndarray]:
"""Present a whole block of readings: ``(display copy, suppressed mask)``.
One row per sample, for the curves — the live buffer draws a rolling
window, not one sample at a time. Vectorised and *stateless*: the
hysteresis latch in :meth:`accept` belongs to the live sample stream, and
a curve redrawn from a window must not depend on which frame drew it.
Non-finite readings stay non-finite — an axis with no value in this unit,
or a failed readout, is a gap and never a zero.
It reads the same applied estimate as :meth:`accept` (provisional while
the startup window is open), so a card and its own curve can never
disagree about what the same sample is worth.
"""
unit = normalise_unit(self.unit if unit is None else unit)
block = np.array(values, dtype=np.float64, copy=True, ndmin=2)[:, :3]
if block.shape[1] < 3:
pad = np.full((block.shape[0], 3 - block.shape[1]), np.nan)
block = np.hstack([block, pad])
suppressed = np.zeros(block.shape, dtype=bool)
state = self._states.get(unit)
if not self.enabled or state is None or not state.display_ready:
return block, suppressed
offsets = state.display_offsets
deadbands = state.display_deadbands
for axis in range(3):
column = block[:, axis]
finite = np.isfinite(column)
if not finite.any():
continue
centered = column[finite] - offsets[axis] # type: ignore[index]
quiet = np.abs(centered) <= deadbands[axis] # type: ignore[index]
column[finite] = np.where(quiet, 0.0, centered)
suppressed[finite, axis] = quiet
return block, suppressed
def format_force(self, value: float, unit: Optional[str] = None) -> str:
"""Stable digit formatting for value cards."""
unit = self.unit if unit is None else (
"N" if str(unit).upper().startswith("N") else "raw"
)
unit = normalise_unit(self.unit if unit is None else unit)
if not np.isfinite(value):
return "—"
# Avoid signed zero after deadband.
+14 -1
View File
@@ -23,7 +23,11 @@ from orisys_app_shared.paths import preference_path, preferences_default_path
_SCENE3D_DISPLAY: dict[str, Any] = {
"deform_enabled": False,
"display_gain": 0.0,
"housing_visible": True,
#: Matches ``DisplaySettings.housing_visible`` and the shipped
#: ``config/preferences.default.json``: the frame starts hidden and the operator
#: turns it on for a mechanical check. A mismatch here would re-show it for a
#: user whose preferences file had been reset from these defaults.
"housing_visible": False,
"max_fps": 30,
}
@@ -40,13 +44,22 @@ _DEFAULTS: dict[str, Any] = {
"config_name": "",
"units": "newton",
"plot_window_sec": 4.0,
# FN is asymmetric (its lower bound may be negative); FX/FY take one absolute
# magnitude each, applied symmetrically. 0 on a raw bound means "auto range".
"plot_fn_min": 0.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
# Raw (uncalibrated) HHD units. Sized so the idle jitter is sub-pixel on
# both raw-mode products while never clipping: measured peaks are ~447k
# (mini, 365x365) and ~41k (finger, 280x300); idle sigma is ~80 (finger).
"plot_fn_raw_min": 0.0,
"plot_fn_raw_max": 1000000.0,
"plot_shear_raw_max": 500000.0,
# Display deadband floors, per display unit. 0 = auto: the noise estimated
# from the quiet startup window (raw keeps its 50-count floor under it).
# Display only — the session store and forces.csv keep the measurement.
"plot_deadband_n": 0.0,
"plot_deadband_raw": 0.0,
"image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90,
"show_fps": False,
@@ -48,6 +48,12 @@ LENGTH_UNIT_M = "m"
#: Weight sum tolerance for a :class:`SurfaceBinding` row.
WEIGHT_SUM_TOL = 1e-4
#: The dihedral angle above which a part's faces are a real edge and its vertices
#: must not share one averaged normal (the housing's 90° corners). The compiler
#: splits at this angle (``legacy.HOUSING_CREASE_ANGLE_DEG``); ``validate_assets``
#: uses it to warn about a package whose stored normals predate that split.
SHADING_CREASE_ANGLE_DEG = 30.0
#: The standard failure codes of §8.3, and how loud each one is.
DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
"asset_auth_failed": "error",
@@ -65,6 +71,10 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
#: predates the record, or the device cannot state it). Informational: the pane may
#: show the geometry, but it must not claim the measurement was checked against it.
"geometry_unverified": "info",
#: The geometry is right but its shading was compiled before crease-aware normals:
#: a housing's 90° corners carry one averaged normal each and band at the rim. A
#: warning, because the picture is usable and the repair is a recompile.
"geometry_shading_stale": "warning",
"stale_frame": "info",
#: The operator asked for one quantity and the frame could only be coloured with the
#: other one. Informational: the picture is still a measurement and names itself; what
@@ -590,7 +600,11 @@ class DisplaySettings:
display_gain: float = 0.0
deform_enabled: bool = False
color_scale: Optional[ColorScale] = None
housing_visible: bool = True
#: Non-sensing parts (the housing/frame) start hidden: the measurement is what the pane is
#: for, and on the finger sensor the frame is ~33% wider than the sensing surface, so it
#: dominated the picture. Operators who want it for a mechanical check turn it on in the 3D
#: display settings; a saved preference wins over this default.
housing_visible: bool = False
max_fps: int = 30
#: Which scalar field colours the surface: the chain's deformation response
#: (``"depth"``) or the tracker's optical-flow magnitude (``"flow"``). This is a
@@ -932,6 +946,68 @@ def validate_mesh_part(part: MeshPart) -> None:
raise error("geometry_mismatch", f"{part.part_id}: normals are not unit length")
def stale_housing_shading(assets: SceneAssets) -> tuple[Diagnostic, ...]:
"""Warnings about a package whose housing shading predates crease-aware normals.
A housing with 90° corners needs more than one vertex normal there: a single
averaged normal agrees with *none* of the faces meeting at the corner, which is
exactly the banding and streaking at the rim (measured on the shipped frame: up
to 80.5° off, and 130 of 726 vertices affected). A package compiled by an older
build carries one such normal per vertex.
This is deliberately **not** part of :func:`validate_assets`: the geometry is
correct, the picture is usable, and "every fresh package must produce no warnings"
is a property the package tests rely on. The check is offered here so a tool, a
release gate or a test can ask the question explicitly, and the repair is a
recompile (``compile_scene.py`` derives the crease-aware normals).
"""
out: list[Diagnostic] = []
for part in assets.parts:
if part.role != "housing":
continue
positions = np.asarray(part.positions_sensor_m, dtype=np.float64)
triangles = np.asarray(part.triangles, dtype=np.int64)
normals = np.asarray(part.normals_sensor, dtype=np.float64)
if normals.shape != positions.shape or not len(triangles):
continue
if not np.allclose(np.linalg.norm(normals, axis=1), 1.0, atol=1e-3):
out.append(
diagnostic(
"geometry_shading_stale",
f"{part.part_id}: the stored normals are not unit length",
part=part.part_id,
)
)
continue
a = positions[triangles[:, 0]]
b = positions[triangles[:, 1]]
c = positions[triangles[:, 2]]
face = np.cross(b - a, c - a)
unit = face / np.maximum(np.linalg.norm(face, axis=1, keepdims=True), 1e-30)
best = np.full(len(positions), -1.0)
for corner in range(3):
indices = triangles[:, corner]
np.maximum.at(best, indices, np.einsum("ij,ij->i", unit, normals[indices]))
used = best > -1.0
if not used.any():
continue
threshold = float(np.cos(np.radians(SHADING_CREASE_ANGLE_DEG)))
if bool(np.any(best[used] < threshold)):
# The worst agreement any vertex has with all of its faces: exactly the
# number that says how far the shading is from the geometry.
worst = float(np.degrees(np.arccos(np.clip(best[used].min(), -1.0, 1.0))))
out.append(
diagnostic(
"geometry_shading_stale",
f"{part.part_id}: a stored normal is {worst:.1f}° from every face "
"that touches its vertex, so its corners are smoothed into banding; "
"recompile the package with compile_scene.py for crease-aware normals",
part=part.part_id,
)
)
return tuple(out)
def validate_assets(
assets: SceneAssets, expected: Optional[ExpectedGeometry] = None
) -> tuple[Diagnostic, ...]:
+401 -48
View File
@@ -61,6 +61,23 @@ MESH_KEYS = ("vertices", "faces", "uv_data", "loop_to_vertex")
#: The mesh's material: the sensing surface is colour-mapped, never shaded into
#: looking like a different value.
SENSING_MATERIAL = Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True)
#: A non-sensing part is never coloured by the measurement, so it is a plain diffuse grey.
#: The GL backend also keys its own base colours by part id (``"housing"`` → graphite), so
#: the id matters as much as the colour here.
HOUSING_MATERIAL = Material((0.32, 0.33, 0.35, 1.0), "diffuse")
HOUSING_PART_ID = "housing"
HOUSING_FILE = "housing.npz"
_HOUSING_KEYS = ("vertices", "faces")
#: The dihedral angle above which two faces are treated as a real edge and their
#: shared vertices are split. The Blender export writes no normals and does not
#: split vertices either (the housing is 726 vertices, 726 unique, a closed
#: 2-manifold), so its genuine 90° corners have exactly one vertex each; averaging
#: every incident face unconditionally turns each of those into a ~45° normal and
#: smears the rim over ~100 px. 30° is the usual shading-crease value: it keeps the
#: curved rim and the countersink smooth (their dihedral angles are a few degrees)
#: and splits only the corners (measured: 134 of 2172 edges exceed it, max 90°).
HOUSING_CREASE_ANGLE_DEG = 30.0
@dataclass(frozen=True)
@@ -261,23 +278,102 @@ def split_at_uv_seams(
)
def vertex_normals(positions: np.ndarray, triangles: np.ndarray) -> np.ndarray:
def _face_normals_and_areas(
positions: np.ndarray, triangles: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Unnormalised face normals (length = twice the area) and their lengths."""
a = positions[triangles[:, 0]]
b = positions[triangles[:, 1]]
c = positions[triangles[:, 2]]
# Not normalised: the cross product's length is twice the triangle area.
face_normals = np.cross(b - a, c - a)
return face_normals, np.linalg.norm(face_normals, axis=1)
def smooth_groups_by_crease(
positions: np.ndarray,
triangles: np.ndarray,
*,
crease_angle_deg: float = HOUSING_CREASE_ANGLE_DEG,
) -> np.ndarray:
"""Which smoothing group every triangle belongs to, splitting at ``crease_angle_deg``.
Two triangles that share an edge are in the same group unless the angle between
their normals exceeds the threshold; groups are the connected components of that
relation. Every triangle is in exactly one group (index ``0..k-1``), so
:func:`vertex_normals` can average only the faces that are actually smooth with
each other.
"""
positions = np.asarray(positions, dtype=np.float64)
triangles = np.asarray(triangles, dtype=np.int64)
count = len(triangles)
if count == 0:
return np.zeros(0, dtype=np.int64)
face_normals, areas = _face_normals_and_areas(positions, triangles)
unit = face_normals / np.maximum(areas[:, None], 1e-30)
# Below cos(threshold) the two faces are creased and must not share a normal.
threshold = float(np.cos(np.radians(float(crease_angle_deg))))
parent = list(range(count))
def find(item: int) -> int:
root = item
while parent[root] != root:
root = parent[root]
while parent[item] != root: # path compression
parent[item], item = root, parent[item]
return root
def union(left: int, right: int) -> None:
left_root, right_root = find(left), find(right)
if left_root != right_root:
parent[max(left_root, right_root)] = min(left_root, right_root)
# Edge (smaller index, larger index) -> the triangle that first used it. A
# manifold mesh gives each edge exactly two triangles; a non-manifold edge
# (more than two) simply joins all of them, which is the conservative choice.
first_use: dict[tuple[int, int], int] = {}
for index in range(count):
tri = triangles[index]
for corner in range(3):
low, high = int(tri[corner]), int(tri[(corner + 1) % 3])
if low > high:
low, high = high, low
other = first_use.get((low, high))
if other is None:
first_use[(low, high)] = index
continue
if float(unit[other] @ unit[index]) >= threshold:
union(other, index)
roots = np.array([find(index) for index in range(count)], dtype=np.int64)
_, groups = np.unique(roots, return_inverse=True)
return groups.astype(np.int64)
def vertex_normals(
positions: np.ndarray,
triangles: np.ndarray,
*,
crease_angle_deg: Optional[float] = None,
) -> np.ndarray:
"""Area-weighted vertex normals: unit length, computed from the geometry.
Derived rather than stored, so a normal is never a second, disagreeing copy of
the shape. A vertex used by no triangle (or by a degenerate one only) gets a
finite fallback instead of a NaN.
Without a crease angle every incident face is averaged, which is right for a
smooth surface and wrong for a box: on a 90° corner it produces a ~45° normal
that no incident face agrees with. For a mesh with creases use
:func:`split_at_crease_edges` first, so that each side of the crease owns its own
vertex — one vertex cannot hold two normals.
"""
positions = np.asarray(positions, dtype=np.float64)
triangles = np.asarray(triangles, dtype=np.int64)
normals = np.zeros_like(positions)
if len(triangles):
a = positions[triangles[:, 0]]
b = positions[triangles[:, 1]]
c = positions[triangles[:, 2]]
# Not normalised: the cross product's length is twice the triangle area,
# which is exactly the weight a vertex normal wants.
face_normals = np.cross(b - a, c - a)
face_normals, _ = _face_normals_and_areas(positions, triangles)
for corner in range(3):
np.add.at(normals, triangles[:, corner], face_normals)
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
@@ -290,6 +386,112 @@ def vertex_normals(positions: np.ndarray, triangles: np.ndarray) -> np.ndarray:
return normals / np.maximum(lengths, fallback)
def split_at_crease_edges(
positions: np.ndarray,
triangles: np.ndarray,
*,
crease_angle_deg: float = HOUSING_CREASE_ANGLE_DEG,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Duplicate the vertices that sit on a crease, one copy per smoothing group.
Returns ``(positions, triangles, source_vertex)``: a mesh whose triangles are
unchanged in *number* but whose vertex indices address the copies, plus the map
from every new vertex back to the geometry vertex it came from (the analogue of
``source_vertex_of_render`` for the sensing surface). A vertex that is smooth all
the way around keeps exactly one copy, so the duplicated count is precisely the
number of extra normals the creases need — not a blanket unweld.
This is what makes a crease-aware normal *possible*: the averaging in
:func:`vertex_normals` is per index, so two sides of a 90° edge have to be two
indices. Every triangle is then smooth with every face it shares a vertex with,
and the averaged normal agrees with each of them.
"""
source_positions = np.asarray(positions, dtype=np.float64)
source_triangles = np.asarray(triangles, dtype=np.int64)
if len(source_triangles) == 0:
return (
source_positions.copy(),
source_triangles.astype(np.int64).copy(),
np.arange(len(source_positions), dtype=np.int64),
)
groups = smooth_groups_by_crease(
source_positions, source_triangles, crease_angle_deg=crease_angle_deg
)
# One render vertex per (geometry vertex, smoothing group) actually used.
keys = np.concatenate(
[
source_triangles[:, corner] * (int(groups.max()) + 1) + groups
for corner in range(3)
]
)
unique_keys, inverse = np.unique(keys, return_inverse=True)
group_count = int(groups.max()) + 1
source_vertex = (unique_keys // group_count).astype(np.int64)
new_triangles = inverse.reshape(3, len(source_triangles)).T.astype(np.int64)
return source_positions[source_vertex].copy(), new_triangles, source_vertex
def crease_aware_mesh(
positions: np.ndarray,
triangles: np.ndarray,
*,
crease_angle_deg: float = HOUSING_CREASE_ANGLE_DEG,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""A housing part's geometry and normals, split at its creases.
Returns ``(positions, triangles, normals)`` ready for a :class:`MeshPart`: the
positions may carry more vertices than the source mesh (one per side of each
crease), the triangle list is identical in count, and every normal is the
area-weighted average of the faces that are actually smooth with its vertex.
"""
split_positions, split_triangles, _ = split_at_crease_edges(
positions, triangles, crease_angle_deg=crease_angle_deg
)
normals = vertex_normals(split_positions, split_triangles)
return split_positions, split_triangles, normals
def normals_disagree_with_faces(
positions: np.ndarray,
triangles: np.ndarray,
normals: np.ndarray,
*,
crease_angle_deg: float = HOUSING_CREASE_ANGLE_DEG,
) -> bool:
"""Whether a mesh's stored normals are inconsistent with its own faces.
True when any vertex normal is more than ``crease_angle_deg`` from every face it
touches: at a crease, a single averaged normal cannot agree with both sides, and
that disagreement is exactly what bands in the render. Used as a *detector*, so a
loader repairs a package built by an older compiler without touching one that was
built correctly — a distinction that keeps a package round trip exact.
"""
positions = np.asarray(positions, dtype=np.float64)
triangles = np.asarray(triangles, dtype=np.int64)
normals = np.asarray(normals, dtype=np.float64)
if normals.shape != positions.shape or not len(triangles):
return True
lengths = np.linalg.norm(normals, axis=1)
if not np.allclose(lengths, 1.0, atol=1e-3):
return True
face_normals, areas = _face_normals_and_areas(positions, triangles)
unit = face_normals / np.maximum(areas[:, None], 1e-30)
threshold = float(np.cos(np.radians(float(crease_angle_deg))))
# The best agreement each vertex has with any incident face.
best = np.full(len(positions), -1.0)
for corner in range(3):
indices = triangles[:, corner]
agreement = np.einsum("ij,ij->i", unit, normals[indices])
np.maximum.at(best, indices, agreement)
used = best > -1.0
if not used.any():
return True
return bool(np.any(best[used] < threshold))
def _apply_rigid(
positions: np.ndarray, transform_source_from_sensor: np.ndarray
) -> np.ndarray:
@@ -471,6 +673,7 @@ class LegacyFingerImporter:
asset_version: str = "1.0.0",
lod_id: str = "lod0",
camera_presets: Optional[Iterable[CameraPreset]] = None,
housing_path: Optional[Any] = None,
) -> SceneAssets:
export_dir = Path(export_dir)
map_path = Path(map_path)
@@ -539,16 +742,41 @@ class LegacyFingerImporter:
).astype(np.float32),
dtype=np.float32,
)
assets = SceneAssets(
asset_id=str(asset_id),
asset_version=str(asset_version),
asset_hash=content_digest(
{
housing_source = (
Path(housing_path) if housing_path is not None else export_dir / HOUSING_FILE
)
if housing_path is not None and not housing_source.is_file():
# An explicitly requested housing that is not there is a mistake worth stopping
# for: silently shipping a package without it looks exactly like success.
raise error(
"geometry_mismatch",
f"a housing was requested but {housing_source} does not exist",
)
housing = self._load_housing(
housing_source,
units=units,
transform=transform,
lod_id=lod_id,
)
# The housing is deliberately *not* part of ``geometry_id`` or
# ``source_geometry_id``: those are the identity of the sensing surface, and the
# acquisition side has to be able to reproduce them from the device's own files.
# Folding a display-only frame into them would make every device look unverified.
# It does belong in the asset hash, which names the package as a whole.
hash_arrays: dict[str, Any] = {
"positions": sensing.positions_sensor_m,
"triangles": sensing.triangles,
"indices": binding.sample_indices,
"weights": binding.sample_weights,
},
}
if housing is not None:
hash_arrays["housing_positions"] = housing.positions_sensor_m
hash_arrays["housing_triangles"] = housing.triangles
assets = SceneAssets(
asset_id=str(asset_id),
asset_version=str(asset_version),
asset_hash=content_digest(
hash_arrays,
{
"geometry_id": geometry_value,
"binding_id": binding_id(binding),
@@ -557,9 +785,16 @@ class LegacyFingerImporter:
},
),
geometry_id=geometry_value,
parts=(sensing,),
parts=(sensing,) if housing is None else (sensing, housing),
binding=binding,
camera_presets=tuple(camera_presets or default_finger_presets(bounds)),
camera_presets=tuple(
camera_presets
or default_finger_presets(
bounds,
positions_sensor_m=positions_sensor,
triangles=triangles,
)
),
bounds_sensor_m=bounds,
field_domain_id=domain.domain_id,
shape_hw=domain.shape_hw,
@@ -587,6 +822,71 @@ class LegacyFingerImporter:
)
return assets
@staticmethod
def _load_housing(
path: Path,
*,
units: float,
transform: np.ndarray,
lod_id: str,
) -> Optional[MeshPart]:
"""The optional non-sensing part (the housing / mounting frame), or ``None``.
The file is expected in the **same source frame and units as** ``mesh.npz``: an
export that lives in another frame is aligned once, by
``tools/scene_assets/align_housing.py``, rather than guessed here — the design
export of the finger sensor is in a camera/world frame whose relationship to the
sensor frame is a fixed similarity, and getting that wrong would put the frame in
the wrong place while still looking plausible.
A housing carries no UV mapping (it is never coloured by the measurement), so only
vertices and faces are read, and it gets no surface binding.
"""
if not path.is_file():
return None
data = _open_npz(path, what="housing")
_require(data, _HOUSING_KEYS, what=str(path))
vertices = np.asarray(data["vertices"])
faces = np.asarray(data["faces"])
if vertices.ndim != 2 or vertices.shape[1] != 3 or not np.isfinite(vertices).all():
raise error(
"geometry_mismatch",
f"{path.name}: vertices must be a finite [N,3] array, got {vertices.shape}",
)
if faces.ndim != 2 or faces.shape[1] != 3 or faces.size == 0:
raise error(
"geometry_mismatch",
f"{path.name}: faces must be a non-empty [M,3] array, got {faces.shape}",
)
if int(faces.max()) >= len(vertices) or int(faces.min()) < 0:
raise error(
"geometry_mismatch",
f"{path.name}: a triangle index is out of range for {len(vertices)} vertices",
)
# Same order as the sensing surface: units first, then the rigid transform.
positions_m = vertices.astype(np.float64) * units
triangles = faces.astype(np.int64)
# Crease-aware, not a plain average: the housing is a box, and averaging its
# 90° corners produces ~45° normals that band and streak at the rim. The
# creases are split into separate vertices first, because one vertex cannot
# hold the two normals a 90° corner needs. The triangle *count* is unchanged.
positions_m, triangles, normals = crease_aware_mesh(positions_m, triangles)
triangles = triangles.astype(np.uint32)
positions_sensor = _apply_rigid(positions_m, transform)
if not np.isfinite(positions_sensor).all():
raise error(
"geometry_mismatch", f"{path.name}: the transform produced non-finite positions"
)
return MeshPart(
part_id=HOUSING_PART_ID,
role="housing",
positions_sensor_m=freeze(positions_sensor, dtype=np.float32),
normals_sensor=freeze(normals @ transform[:3, :3].T, dtype=np.float32),
triangles=freeze(triangles, dtype=np.uint32),
material=HOUSING_MATERIAL,
lod_id=lod_id,
)
def validate(
self, assets: SceneAssets, expected: ExpectedGeometry
) -> tuple[Any, ...]:
@@ -595,46 +895,99 @@ class LegacyFingerImporter:
return validate_assets(assets, expected)
def default_finger_presets(bounds_sensor_m: np.ndarray) -> tuple[CameraPreset, ...]:
def sensing_surface_normal(
positions_sensor_m: np.ndarray, triangles: np.ndarray
) -> np.ndarray:
"""The sensing surface's outward mean face normal, in the sensor frame.
Area-weighted over the triangles (so a dense region does not outvote a large
face) and oriented away from the mesh's own centroid. This is the direction a
camera has to sit on: the sensor's *own* geometry says which side is the sensing
side, and a preset that assumes an axis instead is wrong the moment the export's
frame rotates (measured: the shipped ``default`` preset sits 122.7° from this
normal, i.e. behind the surface).
"""
positions = np.asarray(positions_sensor_m, dtype=np.float64)
triangles = np.asarray(triangles, dtype=np.int64)
face_normals, areas = _face_normals_and_areas(positions, triangles)
mean = face_normals.sum(axis=0)
norm = float(np.linalg.norm(mean))
if norm <= 1e-15:
return np.array([0.0, 0.0, 1.0])
normal = mean / norm
# The exports wind outward (verified on the shipped housing and mesh), but a
# reversed export must not silently aim the camera at the back of the surface.
centroid = positions[triangles.reshape(-1)].mean(axis=0) if len(triangles) else positions.mean(axis=0)
if float(normal @ (centroid - positions.mean(axis=0))) < 0.0:
normal = -normal
return normal
def default_finger_presets(
bounds_sensor_m: np.ndarray,
positions_sensor_m: Optional[np.ndarray] = None,
triangles: Optional[np.ndarray] = None,
*,
surface_normal_sensor: Optional[np.ndarray] = None,
) -> tuple[CameraPreset, ...]:
"""The default views for a flat sensing surface, in sensor metres.
Derived from the bounds rather than hard-coded: the surface faces +Z after the
transform, and a three-quarter view is the one that shows both its shape and
its relief.
Derived from the bounds and from the surface's **own** normal. The absolute axes
are not the sensor's: the design export's frame is rotated relative to the SDK
asset's, so a preset hard-coded to look down ``+Z`` ends up behind the surface
(~123° from its normal), where 96.5% of it is hidden by the frame when the
housing is shown. The three-quarter view therefore comes from the mesh: the eye
is placed along the outward normal, raised off-axis so the relief is visible.
"""
low, high = np.asarray(bounds_sensor_m, dtype=np.float64)
center = (low + high) * 0.5
centre = (low + high) * 0.5
span = float(np.max(high - low))
distance = max(span, 1e-4)
normal = surface_normal_sensor
if normal is None:
if positions_sensor_m is None or triangles is None:
normal = np.array([0.0, 0.0, 1.0])
else:
normal = sensing_surface_normal(positions_sensor_m, triangles)
normal = np.asarray(normal, dtype=np.float64)
normal = normal / max(float(np.linalg.norm(normal)), 1e-15)
# In-plane basis for this surface: the principal axes of the vertex cloud with
# the normal removed. Used for the ``up`` of the off-axis views so the picture is
# not rolled, and for the off-axis direction of the three-quarter view.
reference_cloud = (
np.asarray(positions_sensor_m, dtype=np.float64)
if positions_sensor_m is not None
else np.array([low, high], dtype=np.float64)
)
spread = reference_cloud - reference_cloud.mean(axis=0)
spread = spread - np.outer(spread @ normal, normal)
if float(np.linalg.norm(spread)) > 1e-15:
_, _, vectors = np.linalg.svd(spread, full_matrices=False)
tangent = vectors[0] - normal * float(vectors[0] @ normal)
tangent = tangent / max(float(np.linalg.norm(tangent)), 1e-15)
else: # a degenerate cloud (a single point): any perpendicular will do
fallback = np.array([0.0, 0.0, 1.0])
if abs(float(normal @ fallback)) > 0.9:
fallback = np.array([0.0, 1.0, 0.0])
tangent = np.cross(normal, fallback)
tangent = tangent / max(float(np.linalg.norm(tangent)), 1e-15)
bitangent = np.cross(normal, tangent)
def eye(offset: np.ndarray) -> tuple[float, float, float]:
return tuple(centre + offset * distance)
return (
CameraPreset(
"default",
tuple(center + np.array([-0.6, -0.6, 1.1]) * distance),
tuple(center),
(0.0, 0.0, 1.0),
0.6,
),
CameraPreset(
"front",
tuple(center + np.array([0.0, 0.0, 1.4]) * distance),
tuple(center),
(0.0, 1.0, 0.0),
0.6,
),
CameraPreset(
"side",
tuple(center + np.array([1.4, 0.0, 0.0]) * distance),
tuple(center),
(0.0, 0.0, 1.0),
0.6,
),
CameraPreset(
"top",
tuple(center + np.array([0.0, 1.4, 0.0]) * distance),
tuple(center),
(0.0, 0.0, 1.0),
0.6,
),
# Three-quarter: 31.0° off the normal, so the surface is seen almost head-on
# with enough tilt to show its relief. (The old fixed vector put this 122.7°
# away — behind the surface — for the design frame.)
CameraPreset("default", eye((1.0 * normal + 0.6 * tangent) / np.sqrt(1.36)),
tuple(centre), tuple(normal), 0.6),
# Straight down the outward normal.
CameraPreset("front", eye(1.4 * normal), tuple(centre),
tuple(tangent), 0.6),
# Grazing, from each in-plane direction: the profile of the surface.
CameraPreset("side", eye(1.4 * tangent), tuple(centre), tuple(normal), 0.6),
CameraPreset("top", eye(1.4 * bitangent), tuple(centre), tuple(normal), 0.6),
)
+80 -14
View File
@@ -64,6 +64,12 @@ from orisys_app_shared.scene3d.digest import (
content_digest,
geometry_id as compute_geometry_id,
)
from orisys_app_shared.scene3d.legacy import (
crease_aware_mesh,
default_finger_presets,
normals_disagree_with_faces,
sensing_surface_normal,
)
from orisys_app_shared.scene3d import protect as _protect
#: Re-exported so a caller can ask "can this build seal a package at all?" without
@@ -652,13 +658,26 @@ def scene_from_contents(
positions = freeze(arrays[positions_key], dtype=np.float32)
if not np.isfinite(positions).all():
raise error("geometry_mismatch", f"{part_id} has non-finite positions")
triangles = freeze(arrays[f"{prefix}/triangles.npy"], dtype=np.uint32)
if prefix == "sensing":
# The sensing surface is genuinely smooth, so its stored normals stand.
normals = freeze(arrays["sensing/normals.npy"], dtype=np.float32)
else:
# A housing's normals are compiled crease-aware (a box has 90° corners, and
# a plain per-vertex average rounds them into ~45° normals that band at the
# rim). Nothing is rewritten here: rebuilding a part's vertices on load
# would break the loader's contract that a package round-trips into the
# scene it was written from. The staleness of an older package is instead a
# warning from ``validate_assets``, and the repair is a recompile
# (``compile_scene.py`` derives the crease-aware normals).
normals = freeze(arrays["housing/normals.npy"], dtype=np.float32)
parts.append(
MeshPart(
part_id=part_id,
role=role, # type: ignore[arg-type]
positions_sensor_m=positions,
normals_sensor=freeze(arrays[f"{prefix}/normals.npy"], dtype=np.float32),
triangles=freeze(arrays[f"{prefix}/triangles.npy"], dtype=np.uint32),
normals_sensor=normals,
triangles=triangles,
material=(
Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True)
if prefix == "sensing"
@@ -715,14 +734,11 @@ def scene_from_contents(
)
compatible = dict(manifest.get("compatible") or {})
source_geometry = dict(manifest.get("source_geometry") or {})
assets = SceneAssets(
asset_id=str(manifest.get("asset_id") or ""),
asset_version=str(manifest.get("asset_version") or ""),
asset_hash=str(manifest.get("asset_hash") or ""),
geometry_id=str(manifest.get("geometry_id") or ""),
parts=tuple(parts),
binding=binding,
camera_presets=tuple(
bounds = freeze(
np.asarray(manifest.get("bounds_sensor_m") or np.zeros((2, 3)), dtype=np.float32),
dtype=np.float32,
)
presets = tuple(
CameraPreset(
name=str(item.get("name")),
eye_sensor_m=tuple(float(v) for v in item.get("eye", ())),
@@ -732,11 +748,43 @@ def scene_from_contents(
)
for item in manifest.get("camera_presets", [])
if isinstance(item, dict) and item.get("name")
)
# A preset baked by an older compiler points down a fixed axis, which is only
# correct if the export's frame happens to match the sensor frame. The design
# export does not: its shipped ``default`` sits ~123° from the sensing surface's
# own normal, where the housing hides ~100% of the surface. If the package's
# default preset is on the far side of the surface, replace the set with one
# derived from the mesh rather than presenting a view of the back of the sensor.
sensing_only_for_presets = tuple(part for part in parts if part.role == "sensing")
if sensing_only_for_presets:
sensing_part = sensing_only_for_presets[0]
surface_normal = sensing_surface_normal(
np.asarray(sensing_part.positions_sensor_m, dtype=np.float64),
np.asarray(sensing_part.triangles, dtype=np.int64),
)
baked_default = next(
(preset for preset in presets if preset.name == "default"), None
)
if baked_default is None or _preset_is_behind_the_surface(
baked_default, surface_normal
):
presets = default_finger_presets(
bounds,
positions_sensor_m=np.asarray(
sensing_part.positions_sensor_m, dtype=np.float64
),
bounds_sensor_m=freeze(
np.asarray(manifest.get("bounds_sensor_m") or np.zeros((2, 3)), dtype=np.float32),
dtype=np.float32,
),
triangles=np.asarray(sensing_part.triangles, dtype=np.int64),
surface_normal_sensor=surface_normal,
)
assets = SceneAssets(
asset_id=str(manifest.get("asset_id") or ""),
asset_version=str(manifest.get("asset_version") or ""),
asset_hash=str(manifest.get("asset_hash") or ""),
geometry_id=str(manifest.get("geometry_id") or ""),
parts=tuple(parts),
binding=binding,
camera_presets=presets,
bounds_sensor_m=bounds,
field_domain_id=str(domain.get("id") or ""),
shape_hw=(shape[0], shape[1]),
source_vertex_of_render=(
@@ -754,6 +802,24 @@ def scene_from_contents(
return assets
def _preset_is_behind_the_surface(preset: CameraPreset, surface_normal: np.ndarray) -> bool:
"""Whether a preset's eye sits on the non-sensing side of the surface.
The test is the angle between the eye direction (from the target) and the
surface's outward normal: more than 90° means the camera is behind the plate and
is therefore looking at its back, through whatever frame surrounds it.
"""
eye = np.asarray(preset.eye_sensor_m, dtype=np.float64)
target = np.asarray(preset.target_sensor_m, dtype=np.float64)
offset = eye - target
norm = float(np.linalg.norm(offset))
if norm <= 0.0:
return True
normal = np.asarray(surface_normal, dtype=np.float64)
normal = normal / max(float(np.linalg.norm(normal)), 1e-15)
return float((offset / norm) @ normal) < 0.0
class PackagedAssetLoader:
"""The loader: plain, signed and encrypted packages through one entry point."""
@@ -83,6 +83,9 @@ def _housing() -> MeshPart:
(0, 3, 7), (0, 7, 4), # left
(1, 5, 6), (1, 6, 2), # right
]
# Averaged from the incident faces, winding and all. Deliberately unchanged: the
# POC is a stand-in for a *smooth* part, and its pixels are pinned by the GL smoke
# tests. The real housing's crease handling is tested against the real asset.
normals = np.zeros_like(corners)
for triangle in faces:
a, b, c = (corners[index] for index in triangle)
@@ -6,7 +6,19 @@ from pathlib import Path
from orisys_app_shared.paths import sensors_dir
PRODUCT_TYPES = ("box", "mini", "finger")
#: Product types the UI offers.
#:
#: ``finger-mems`` is a deprecated product line that is deliberately **not** part of the SDK
#: (there is no MEMS read path there), so it is carried studio-side only: its bundle copies
#: ``box``'s ``sensor.json`` without a ``force_calibration.json``, which means N units are
#: unavailable and the app reports raw for it — the honest answer for an uncalibrated device.
#: It runs on the box/mini pipeline, because the worker routing is a binary ``== "finger"``
#: test (see ``session_controller.worker_for``).
#:
#: NOTE: the copied ``camera``/``stitcher`` numbers belong to the box prototype. If the real
#: MEMS-finger device stitches differently, live frames are refused with
#: ``field_domain_mismatch`` — correct them from the device, not by guessing.
PRODUCT_TYPES = ("box", "mini", "finger", "finger-mems")
def list_sensor_types() -> list[str]:
+311 -3
View File
@@ -1,4 +1,4 @@
"""Display-only force baseline / deadband (value cards)."""
"""Display-only force baseline / deadband (value cards and curves)."""
from __future__ import annotations
@@ -6,6 +6,11 @@ import numpy as np
from orisys_app_shared.force.presentation import ForcePresentation, ZeroEstimate
#: The idle triplet from the customer's screenshot at run start: the numbers the
#: cards were showing because the estimate was not applied yet.
REPORTED_RAW_BIAS = (1118.0, 34.16, -186.1)
REPORTED_NEWTON_BIAS = (0.4, -0.25, 0.1)
def test_fit_zero_rejects_insufficient_samples() -> None:
presentation = ForcePresentation(enabled=True, min_samples=30)
@@ -22,7 +27,7 @@ def test_fit_zero_and_accept_deadband() -> None:
enabled=True,
min_samples=30,
baseline_seconds=0.0,
min_deadband=50.0,
min_deadband_raw=50.0,
deadband_percentile=99.0,
)
estimate = presentation.fit_zero(samples)
@@ -60,7 +65,7 @@ def test_observe_freezes_after_quiet_window() -> None:
enabled=True,
min_samples=10,
baseline_seconds=0.5,
min_deadband=10.0,
min_deadband_raw=10.0,
)
estimate: ZeroEstimate | None = None
for i in range(40):
@@ -80,8 +85,311 @@ def test_observe_freezes_after_quiet_window() -> None:
assert later is None
def test_the_estimate_is_kept_per_display_unit() -> None:
"""A newton baseline and a raw baseline are different scales, not one number."""
presentation = ForcePresentation(enabled=True, min_samples=5, baseline_seconds=1.0)
for i in range(12):
presentation.observe(
(1000.0 + i * 0.1, 0.0, 0.0), t_rel_s=i * 0.02, unit="raw"
)
assert presentation.unit == "raw"
assert presentation.ready
assert presentation.accept((1000.55, 0.0, 0.0), unit="raw").values[0] == 0.0
# The other unit has no estimate of its own and must show what was measured.
assert presentation.accept((1000.55, 0.0, 0.0), unit="N").values == (
1000.55,
0.0,
0.0,
)
presentation.unit = "N"
assert not presentation.ready, "the raw estimate made newtons ready"
presentation.unit = "raw"
# ...and it collects its own window, in its own scale.
for i in range(12):
presentation.observe((0.5 + i * 0.01, 0.0, 0.0), t_rel_s=i * 0.02, unit="N")
presentation.unit = "N"
assert presentation.ready
assert presentation.accept((0.555, 0.0, 0.0), unit="N").values[0] == 0.0
def test_the_newton_floor_is_not_the_raw_floor() -> None:
"""50 raw counts is a noise floor; 50 N would be a contact."""
presentation = ForcePresentation(enabled=True)
assert presentation.min_deadband_for("raw") == 50.0
assert presentation.min_deadband_for("N") == 0.0
rng = np.random.default_rng(3)
quiet = (0.5 + rng.normal(0.0, 0.002, size=(60, 3))).tolist()
estimate = presentation.fit_zero(quiet, unit="N")
assert estimate.quality == "ok"
assert max(estimate.deadbands) < 0.01, estimate.deadbands
def test_present_holds_a_resting_block_at_zero_and_keeps_an_excursion() -> None:
rng = np.random.default_rng(7)
bias = np.array([9555.0, -13368.0, -353.0])
quiet = (bias + rng.normal(0.0, 20.0, size=(80, 3))).tolist()
presentation = ForcePresentation(
enabled=True, min_samples=30, baseline_seconds=0.0, deadband_percentile=99.0
)
estimate = presentation.fit_zero(quiet)
assert estimate.quality == "ok"
presentation.apply_estimate(estimate)
block = np.asarray(
[bias, bias + 5.0, bias + 4000.0, [np.nan, np.nan, np.nan]], dtype=float
)
displayed, suppressed = presentation.present(block, unit="raw")
assert displayed[0].tolist() == [0.0, 0.0, 0.0], "a resting sample drifted"
assert suppressed[0].all()
assert displayed[1].tolist() == [0.0, 0.0, 0.0], "noise outside the deadband?"
# A real excursion survives, centred on the estimated offset.
assert 3900.0 < displayed[2][0] < 4100.0, displayed[2]
assert not suppressed[2].any()
# A gap is a gap: an unavailable axis must never become a zero.
assert np.isnan(displayed[3]).all()
assert not suppressed[3].any()
def test_present_is_a_display_copy_and_never_touches_the_input() -> None:
presentation = ForcePresentation(enabled=True, min_samples=5, baseline_seconds=1.0)
for i in range(12):
presentation.observe((100.0 + i * 0.01, 0.0, 0.0), t_rel_s=i * 0.02)
source = np.array([[100.055, 0.0, 0.0]])
displayed, _suppressed = presentation.present(source, unit="raw")
assert source.tolist() == [[100.055, 0.0, 0.0]], "present mutated the measurement"
assert displayed[0][0] == 0.0
def test_the_operator_floor_moves_the_frozen_deadband() -> None:
presentation = ForcePresentation(enabled=True, min_samples=5, baseline_seconds=1.0)
for i in range(12):
presentation.observe((100.0 + i * 0.01, 0.0, 0.0), t_rel_s=i * 0.02, unit="raw")
assert presentation.ready
presentation.set_min_deadband("raw", 500.0)
assert presentation.accept((300.0, 0.0, 0.0), unit="raw").values[0] == 0.0, (
"200 raw counts above the device's own band but inside the operator's floor"
)
# Back to auto: the floor is a floor, so the estimated band returns.
presentation.set_min_deadband("raw", 0.0)
assert presentation.min_deadband_for("raw") == 0.0
assert presentation.accept((300.0, 0.0, 0.0), unit="raw").values[0] != 0.0
def test_format_force_stable_digits() -> None:
presentation = ForcePresentation(enabled=True)
assert presentation.format_force(0.0, unit="raw") == "0"
assert presentation.format_force(1262.4, unit="raw") == "1262"
assert presentation.format_force(1.2345, unit="N") == "1.234"
# ------------------------------------------------------- the reported defect
def _quiet_until_zeroed(
presentation: ForcePresentation,
bias: tuple[float, float, float],
*,
unit: str,
limit: int = 29,
) -> int:
"""Feed quiet samples until ``accept`` reads zero; return the sample count.
Returns ``limit + 1`` (i.e. "never within the limit") if it did not zero.
"""
for index in range(limit):
presentation.observe(bias, t_rel_s=index / 30.0, flow_p95=0.1, unit=unit)
if presentation.accept(bias, unit=unit).values == (0.0, 0.0, 0.0):
return index + 1
return limit + 1
def test_a_resting_raw_channel_reads_zero_from_the_first_samples() -> None:
"""FN 1118 / FX 34.16 / FY -186.1 must be zero long before 30 samples / 3 s.
This is the reported defect: the cards kept the device's raw bias for the
whole startup window, so a session that started at rest looked like it was
under load.
"""
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
zeroed_at = _quiet_until_zeroed(presentation, REPORTED_RAW_BIAS, unit="raw")
assert zeroed_at <= 3, f"the raw bias was still on the cards after {zeroed_at} samples"
assert not presentation.ready, "zeroing should not have needed the whole quiet window"
assert presentation.display_ready, "the display policy was not applied while collecting"
assert presentation.display_quality == "provisional"
# ...and no invented zero: the offset is the one the quiet samples measured.
offsets = presentation.last_estimate.offsets
assert offsets[0] == REPORTED_RAW_BIAS[0]
assert offsets[1] == REPORTED_RAW_BIAS[1]
assert offsets[2] == REPORTED_RAW_BIAS[2]
def test_a_resting_newton_channel_reads_zero_from_the_first_samples() -> None:
"""The same aid in newtons — the estimate is per display unit."""
presentation = ForcePresentation(
enabled=True, unit="N", min_samples=30, baseline_seconds=3.0
)
zeroed_at = _quiet_until_zeroed(presentation, REPORTED_NEWTON_BIAS, unit="N")
assert zeroed_at <= 3, f"the newton bias survived {zeroed_at} samples"
assert not presentation.ready
assert presentation.display_quality == "provisional"
def test_a_noisy_channel_is_zeroed_from_the_first_samples_too() -> None:
"""The provisional band covers the samples it was fitted from.
With a handful of samples the p99 residual is essentially the largest one, so
the newest sample of the window is inside the band: the first tenths of a
second are zeroed, and the *first* sample is not claimed to be zero because
nothing has been estimated from it yet.
"""
rng = np.random.default_rng(11)
bias = np.array(REPORTED_RAW_BIAS)
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
for index in range(8):
sample = bias + rng.normal(0.0, 30.0, size=3)
presentation.observe(tuple(sample), t_rel_s=index / 30.0, flow_p95=0.1)
if index < 2:
# Nothing has been estimated from two samples: those frames honestly
# show the measurement (and are asserted to do so elsewhere).
continue
displayed = presentation.accept(tuple(sample), unit="raw")
assert displayed.values == (0.0, 0.0, 0.0), (
f"sample {index} of a resting noisy channel was drawn"
)
def test_a_contact_present_at_session_start_is_never_zeroed() -> None:
"""A press at run start must stay visible: no estimate may be invented for it.
The contact guard decides what feeds the estimate, so a window that has only
ever seen a contact has nothing to subtract and displays the measurement.
"""
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
pressed = (6000.0, 40.0, -180.0)
for index in range(20):
assert (
presentation.observe(
pressed, t_rel_s=index / 30.0, contact_found=True, flow_p95=0.1
)
is None
)
displayed = presentation.accept(pressed, unit="raw")
assert displayed.values[0] == pressed[0], "a contact at startup was zeroed away"
assert not displayed.suppressed[0]
assert not presentation.display_ready
assert presentation.display_quality == "none"
def test_a_contaminated_window_never_falls_back_to_the_raw_bias() -> None:
"""A window that closes badly keeps the estimate it already worked out.
Falling back to the raw offset is the reported defect. Both ways a startup
window can be spoiled — a contact, or too much flow — must leave the display
zeroed and say so through ``display_quality``.
"""
bias = REPORTED_RAW_BIAS
pressed = (6000.0, 40.0, -180.0)
for spoiled_by in ("contact", "flow"):
presentation = ForcePresentation(
enabled=True, min_samples=30, baseline_seconds=3.0
)
for index in range(3):
presentation.observe(bias, t_rel_s=index / 30.0, flow_p95=0.1)
assert presentation.display_ready and not presentation.ready
for index in range(90): # the window is spoiled before it can close
presentation.observe(
pressed,
t_rel_s=0.5 + index / 30.0,
contact_found=(spoiled_by == "contact"),
flow_p95=0.9 if spoiled_by == "flow" else 0.1,
)
estimate = presentation.observe(bias, t_rel_s=4.0, flow_p95=0.1)
assert estimate is not None, spoiled_by
assert estimate.quality == "contaminated", (spoiled_by, estimate)
assert not presentation.ready, spoiled_by
assert presentation.display_quality == "contaminated", spoiled_by
assert presentation.accept(bias, unit="raw").values == (0.0, 0.0, 0.0), spoiled_by
def test_a_window_that_closed_short_still_applies_what_it_learned() -> None:
"""A session shorter than the quiet window is not a reason to show the bias."""
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=0.5)
for index in range(3):
presentation.observe(REPORTED_RAW_BIAS, t_rel_s=index / 30.0, flow_p95=0.1)
estimate = presentation.observe(REPORTED_RAW_BIAS, t_rel_s=0.6, flow_p95=0.1)
assert estimate is not None and estimate.quality == "insufficient"
assert presentation.display_quality == "insufficient"
assert presentation.accept(REPORTED_RAW_BIAS, unit="raw").values == (0.0, 0.0, 0.0)
def test_a_frozen_window_stops_chasing_the_data() -> None:
"""The provisional phase is a start, not a licence to re-zero a press."""
presentation = ForcePresentation(
enabled=True, min_samples=5, baseline_seconds=3.0, min_deadband_raw=50.0
)
for index in range(10):
presentation.observe(REPORTED_RAW_BIAS, t_rel_s=index / 30.0, flow_p95=0.1)
assert presentation.ready
assert presentation.display_quality == "ok"
# A held press after the window is a reading, not a new zero.
assert presentation.accept((5000.0, 34.16, -186.1), unit="raw").values[0] > 3000.0
def test_the_cards_and_the_curves_are_the_same_presentation() -> None:
"""What ``accept`` puts on a card is the row ``present`` draws on its curve.
This is what the extra "the estimate is ready" gate in the value cards broke:
the card kept the raw measurement while the curve was already drawn from the
applied estimate, so the two disagreed about the same sample.
"""
bias = REPORTED_RAW_BIAS
#: The samples the acquisition would flag as a contact: they must not feed
#: the idle estimate, or the display would zero the press away.
presses = {
3: (bias[0] + 4000.0, bias[1] + 10.0, bias[2] - 10.0),
5: (bias[0] - 4000.0, bias[1], bias[2]),
}
stream = [
bias,
bias,
bias,
presses[3],
bias,
presses[5],
bias,
]
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
for index, sample in enumerate(stream):
presentation.observe(
sample,
t_rel_s=index / 30.0,
flow_p95=0.1,
contact_found=index in presses,
)
card = presentation.accept(sample, unit="raw")
curve, suppressed = presentation.present([sample], unit="raw")
assert card.values == tuple(float(value) for value in curve[0]), index
assert card.suppressed == tuple(bool(flag) for flag in suppressed[0]), index
# The assertion is not vacuous: the excursion really is on both.
assert presentation.accept(stream[3], unit="raw").values[0] > 3000.0
def test_present_uses_the_provisional_estimate_before_the_window_closes() -> None:
"""The curves get the same early zero as the cards, not only after 3 s."""
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
block = np.asarray([REPORTED_RAW_BIAS] * 4, dtype=float)
raw, _suppressed = presentation.present(block, unit="raw")
assert raw[0].tolist() == list(REPORTED_RAW_BIAS), "nothing was learned yet"
for index in range(3):
presentation.observe(REPORTED_RAW_BIAS, t_rel_s=index / 30.0, flow_p95=0.1)
displayed, suppressed = presentation.present(block, unit="raw")
assert displayed.tolist() == [[0.0, 0.0, 0.0]] * 4
assert suppressed.all()
assert not presentation.ready, "the whole quiet window was not needed"
+224 -2
View File
@@ -9,6 +9,7 @@ stage owes, and a synthetic one can only prove the code agrees with itself.
from __future__ import annotations
import json
import shutil
import struct
import zipfile
from pathlib import Path
@@ -42,8 +43,11 @@ from orisys_app_shared.scene3d.glb import (
from orisys_app_shared.scene3d.legacy import (
LegacyFingerImporter,
build_binding,
crease_aware_mesh,
estimate_meters_per_source_unit,
load_legacy_domain,
normals_disagree_with_faces,
split_at_crease_edges,
split_at_uv_seams,
uv_correspondence_residual,
validate_rigid_transform,
@@ -72,9 +76,11 @@ def _identity() -> np.ndarray:
def _import_run(**kwargs) -> SceneAssets:
export_dir = kwargs.pop("export_dir", RUN_DIR)
map_path = kwargs.pop("map_path", RUN_MAP)
return LegacyFingerImporter().convert(
RUN_DIR,
RUN_MAP,
export_dir,
map_path,
transform_sensor_from_source=kwargs.pop("transform", _identity()),
meters_per_source_unit=kwargs.pop("units", 1e-3),
**kwargs,
@@ -97,6 +103,79 @@ def test_the_real_export_imports_and_validates():
assert assets.source_vertex_of_render.shape == (1032,)
def _write_housing(path: Path) -> Path:
"""A tiny tetrahedron standing in for a mounting frame."""
vertices = np.array(
[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]],
dtype=np.float32,
)
faces = np.array([[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]], dtype=np.int32)
np.savez(path, vertices=vertices, faces=faces)
return path
@requires_run
def test_a_housing_becomes_its_own_part_without_touching_the_geometry_ids(tmp_path: Path):
"""The frame is display-only: it must not enter the identities the device verifies."""
plain = _import_run()
housing_path = _write_housing(tmp_path / "housing.npz")
with_housing = _import_run(housing_path=housing_path)
assert [part.part_id for part in plain.parts] == ["sensing_surface"]
assert [(part.part_id, part.role) for part in with_housing.parts] == [
("sensing_surface", "sensing"),
("housing", "housing"),
]
assert with_housing.geometry_id == plain.geometry_id, (
"adding a housing changed the sensing geometry id, which the device verifies"
)
assert with_housing.source_geometry_id == plain.source_geometry_id
assert with_housing.asset_hash != plain.asset_hash, (
"the package content changed, so the asset hash has to say so"
)
assert validate_assets(with_housing) == ()
# Units and the rigid transform are applied to the housing exactly as to the surface.
frame = with_housing.part("housing")
assert frame is not None
assert np.allclose(np.ptp(np.asarray(frame.positions_sensor_m), axis=0), 0.01, atol=1e-6)
@requires_run
def test_a_housing_in_the_export_directory_is_used_automatically(tmp_path: Path, monkeypatch):
"""``<legacy-dir>/housing.npz`` is picked up, so a re-export needs no new flag."""
export = tmp_path / "export"
export.mkdir()
shutil.copy(RUN_DIR / "mesh.npz", export / "mesh.npz")
assert len(_import_run(export_dir=export).parts) == 1
_write_housing(export / "housing.npz")
assert [part.role for part in _import_run(export_dir=export).parts] == [
"sensing",
"housing",
]
@requires_run
def test_a_requested_housing_that_is_missing_is_refused(tmp_path: Path):
"""A missing frame must not look like a package that simply has none."""
with pytest.raises(ThreeDError) as caught:
_import_run(housing_path=tmp_path / "not-there.npz")
assert caught.value.diagnostic.code == "geometry_mismatch"
@requires_run
def test_a_housing_with_an_out_of_range_triangle_is_refused(tmp_path: Path):
path = tmp_path / "housing.npz"
np.savez(
path,
vertices=np.zeros((3, 3), dtype=np.float32),
faces=np.array([[0, 1, 9]], dtype=np.int32),
)
with pytest.raises(ThreeDError) as caught:
_import_run(housing_path=path)
assert caught.value.diagnostic.code == "geometry_mismatch"
@requires_run
def test_the_uv_convention_is_measured_against_the_asset():
"""The one fact the importer must not guess: which pixel a UV addresses."""
@@ -235,6 +314,149 @@ def test_vertex_normals_are_unit_and_area_weighted():
assert np.isfinite(lonely[4]).all()
def _box(half: float = 1.0) -> tuple[np.ndarray, np.ndarray]:
"""A closed cube: every corner is a 90° crease, every edge a smooth pair."""
positions = np.array(
[
[-half, -half, -half], [half, -half, -half], [half, half, -half],
[-half, half, -half], [-half, -half, half], [half, -half, half],
[half, half, half], [-half, half, half],
],
dtype=np.float64,
)
triangles = np.array(
[
[0, 2, 1], [0, 3, 2], # -z
[4, 5, 6], [4, 6, 7], # +z
[0, 1, 5], [0, 5, 4], # -y
[2, 3, 7], [2, 7, 6], # +y
[1, 2, 6], [1, 6, 5], # +x
[3, 0, 4], [3, 4, 7], # -x
],
dtype=np.int64,
)
return positions, triangles
def test_crease_aware_normals_agree_with_every_face_of_a_box():
"""The defect this fixes: an averaged corner normal agrees with *no* face."""
positions, triangles = _box()
averaged = vertex_normals(positions, triangles)
crease = crease_aware_mesh(positions, triangles)
split_positions, split_triangles, split_normals = crease
assert len(split_triangles) == len(triangles), "splitting must not change the faces"
assert len(split_positions) == 24, "each of the 8 corners needs 3 normals"
def worst(normals, faces, verts):
a, b, c = verts[faces[:, 0]], verts[faces[:, 1]], verts[faces[:, 2]]
face = np.cross(b - a, c - a)
unit = face / np.linalg.norm(face, axis=1, keepdims=True)
incident = [[] for _ in range(len(verts))]
for index, triangle in enumerate(faces):
for k in range(3):
incident[int(triangle[k])].append(index)
return max(
float(np.degrees(np.arccos(np.clip(unit[incident[v]] @ normals[v], -1, 1))).min())
for v in range(len(verts))
if incident[v]
)
assert worst(averaged, triangles, positions) > 30.0, (
"the fixture must actually exhibit the defect"
)
assert worst(split_normals, split_triangles, split_positions) < 1e-6, (
"a split corner normal must be exactly a face normal on a cube"
)
def test_crease_splitting_keeps_the_smooth_parts_welded():
"""A cylinder's curved side must survive whole; only its rim split."""
ring = 24
angles = np.linspace(0.0, 2.0 * np.pi, ring, endpoint=False)
positions = np.vstack(
[
np.column_stack([np.cos(angles), np.sin(angles), np.zeros(ring)]),
np.column_stack([np.cos(angles), np.sin(angles), np.ones(ring)]),
]
)
triangles = []
for i in range(ring):
j = (i + 1) % ring
triangles.append([i, j, ring + i])
triangles.append([j, ring + j, ring + i])
triangles = np.asarray(triangles, dtype=np.int64)
split_positions, split_triangles, _ = split_at_crease_edges(positions, triangles)
# The side is a smooth ring (3.7° per step), so nothing on it may be duplicated;
# the two rims are boundaries, so the split only separates the *caps'* edges.
assert len(split_triangles) == len(triangles)
assert len(split_positions) == len(positions), (
"a smoothly curved side must stay welded"
)
def test_normals_disagree_with_faces_detects_the_defect():
positions, triangles = _box()
assert normals_disagree_with_faces(
positions, triangles, vertex_normals(positions, triangles)
)
_, _, crease = crease_aware_mesh(positions, triangles)
split_positions, split_triangles, _ = split_at_crease_edges(positions, triangles)
assert not normals_disagree_with_faces(split_positions, split_triangles, crease)
# A flat sheet has no crease at all.
flat = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
assert not normals_disagree_with_faces(
flat, np.array([[0, 1, 2]]), np.array([[0.0, 0.0, 1.0]] * 3)
)
@requires_run
def test_the_shipped_housing_has_no_stale_shading():
"""The real housing: 134 of 2172 edges are creases, so plain averaging bands."""
import io
import zipfile
package = (
Path(__file__).resolve().parents[2]
/ "apps" / "studio" / "config" / "sensors" / "finger" / "scene"
/ "finger-display-1.0.0.oryscene"
)
if not package.is_file():
pytest.skip("the compiled finger package is not present")
with zipfile.ZipFile(package) as archive:
positions = np.load(io.BytesIO(archive.read("housing/positions.npy"))).astype(np.float64)
triangles = np.load(io.BytesIO(archive.read("housing/triangles.npy"))).astype(np.int64)
normals = np.load(io.BytesIO(archive.read("housing/normals.npy"))).astype(np.float64)
# Every stored normal agrees with an incident face, which is only possible
# because the source mesh's 726 vertices became 861 (one per side of a crease).
assert not normals_disagree_with_faces(positions, triangles, normals), (
"the shipped housing still carries normals averaged across its 90° corners"
)
# ...and the split is not a blanket unweld: the smooth vertices stay shared.
unique_positions = len(np.unique(positions, axis=0))
assert unique_positions == 726, (
f"{unique_positions} unique positions: the smooth surface was unwelded too"
)
assert len(positions) == 861, (
f"{len(positions)} vertices: the creases were not split into their own normals"
)
# The source mesh this was compiled from is the shape that used to be shipped,
# and plain averaging on it is exactly the defect.
source = Path(
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute\housing.npz"
)
if source.is_file():
with np.load(source, allow_pickle=False) as handle:
source_positions = np.asarray(handle["vertices"], dtype=np.float64)
source_triangles = np.asarray(handle["faces"], dtype=np.int64)
assert len(source_positions) == 726, len(source_positions)
assert normals_disagree_with_faces(
source_positions,
source_triangles,
vertex_normals(source_positions, source_triangles),
), "the detector does not fire on the geometry that actually banded"
# ------------------------------------------------------------------- binding
def _domain(shape_hw=(4, 5), flip_v=False, world=None):
valid = np.ones(shape_hw, dtype=bool)
+277 -3
View File
@@ -13,8 +13,10 @@ that only have the SDK.
from __future__ import annotations
import io
import json
import shutil
import zipfile
from dataclasses import replace
from pathlib import Path
@@ -38,6 +40,106 @@ BUNDLE = (
)
PACKAGE = BUNDLE / "scene" / "finger-display-1.0.0.oryscene"
#: The **runtime** SDK asset: the mesh the device actually runs, in source units.
SDK_ASSET_DIR = Path(r"D:\orisys_ws\orisys-sdk\sensors\finger\assets\30fps-0703")
SDK_MESH = SDK_ASSET_DIR / "mesh.npz"
#: The physical statement about that asset: one source unit is one millimetre.
#: Documented in ``tools/scene_assets/README.md`` and stated by the compiler's
#: ``--meters-per-source-unit``; it is the *design* export that is in other units.
SDK_METERS_PER_SOURCE_UNIT = 0.001
#: What the SDK asset measures when the file above is not on this machine (metres):
#: the same 15.81 x 15.0 x 7.5 mm envelope, kept as a documented constant so the
#: guard still runs — and can still fail — on a checkout without the SDK.
SENSOR_ENVELOPE_FALLBACK_M = np.array([0.01580961, 0.015, 0.0075])
#: How far the shipped package may be from the SDK asset's own envelope. The defect
#: this guards against is a 54.38x error, so one percent is three orders of magnitude
#: tighter and still leaves room for float32 storage and the export's own rounding.
ENVELOPE_RELATIVE_TOLERANCE = 0.01
def sensor_frame_envelope_m() -> np.ndarray:
"""The sensing surface's true envelope, in the sensor frame, in metres.
Derived from the **SDK asset mesh** — the file the device runs — and *not* from
the package: a guard that reads its expectation out of the thing it is checking
cannot fail. ``manifest.length_unit`` is ``m`` and ``coordinate_frame`` is
``sensor``, so the package's arrays are directly comparable with this.
"""
if not SDK_MESH.is_file():
return SENSOR_ENVELOPE_FALLBACK_M.copy()
with np.load(SDK_MESH, allow_pickle=False) as mesh:
extent = np.ptp(np.asarray(mesh["vertices"], dtype=np.float64), axis=0)
return extent * SDK_METERS_PER_SOURCE_UNIT
def _package_arrays(package: Path) -> tuple[dict, dict[str, np.ndarray]]:
"""The raw manifest and entries, read straight out of the zip.
Deliberately not through the loader: this guard is about the *written file*,
and a check that goes through code which might normalise or repair would not
see what actually shipped.
"""
with zipfile.ZipFile(package) as archive:
manifest = json.loads(archive.read("manifest.json"))
arrays = {
name: np.load(io.BytesIO(archive.read(name)))
for name in archive.namelist()
if name.endswith(".npy")
}
return manifest, arrays
def sensing_envelope_m(package: Path) -> np.ndarray:
_, arrays = _package_arrays(package)
return np.ptp(np.asarray(arrays["sensing/positions.npy"], dtype=np.float64), axis=0)
def assert_package_geometry_is_sensor_scale(package: Path) -> None:
"""The scale check: the shipped envelope must be the SDK asset's envelope.
Two independent statements inside the package have to be right, and the second
is the one that reaches the renderer's own framing:
* ``sensing/positions.npy``, in the metres ``manifest.length_unit`` claims;
* ``manifest.bounds_sensor_m``, which the loader trusts for the camera.
"""
expected = sensor_frame_envelope_m()
manifest, arrays = _package_arrays(package)
length_unit = str(manifest.get("length_unit") or "")
assert length_unit == "m", (
f"{package.name} declares length_unit={length_unit!r}; this guard compares "
"metres, so a different unit statement has to be converted first"
)
extent = np.ptp(np.asarray(arrays["sensing/positions.npy"], dtype=np.float64), axis=0)
np.testing.assert_allclose(
extent,
expected,
rtol=ENVELOPE_RELATIVE_TOLERANCE,
err_msg=(
f"{package.name}: the sensing envelope in the units the manifest claims "
"(m) does not match the SDK asset's own sensor-frame envelope. Measured "
f"{np.sort(extent)} m, the SDK asset states {np.sort(expected)} m "
f"(a factor of {float(np.sort(extent)[-1] / np.sort(expected)[-1]):.5f} on "
"the largest axis). This is the unit statement, not a formatting choice: "
"the design export is not in millimetres, and calling its units "
"millimetres compiles cleanly and is 54.38x too large."
),
)
bounds = np.asarray(manifest.get("bounds_sensor_m"), dtype=np.float64)
assert bounds.shape == (2, 3), f"{package.name}: bounds_sensor_m is {bounds.shape}"
np.testing.assert_allclose(
bounds[1] - bounds[0],
expected,
rtol=ENVELOPE_RELATIVE_TOLERANCE,
err_msg=(
f"{package.name}: manifest.bounds_sensor_m spans {bounds[1] - bounds[0]} m, "
f"but the SDK asset states {expected} m: the camera would frame the wrong box"
),
)
def _require_bundle() -> None:
if not (BUNDLE / "sensor.json").is_file():
@@ -50,6 +152,178 @@ def _require_package() -> None:
pytest.skip("the compiled finger package is not present")
def _bundle_map_name() -> str:
"""The map filename this bundle's own config resolves to — derived, never hard-coded.
The filename follows ``camera.source_scale`` (``scaled_map_cache_path``), and the bundle moved
from ``0.4`` to ``0.42`` when the package was recompiled from the authoritative design export.
Deriving it here keeps these tests about the behaviour they name — the device description comes
from the files the runtime loads — instead of about one filename that any scale change renames.
"""
_require_bundle()
config = json.loads((BUNDLE / "sensor.json").read_text(encoding="utf-8"))
scale = config.get("camera", {}).get("source_scale")
return map_path_for(BUNDLE / "assets" / "30fps-0703", scale).name
def test_the_shipped_package_looks_at_the_sensing_side_of_the_surface():
"""The default camera must be in front of the plate, not behind it.
The shipped ``default`` preset used to be a fixed vector, which is only correct
if the export's frame happens to equal the sensor frame. The design export's does
not: that preset ended up 122.7° from the sensing surface's own normal — behind
the surface — where the frame hides essentially all of it. Both the package's
baked preset and the presets the loader derives are checked here.
"""
_require_package()
from orisys_app_shared.scene3d.contracts import DisplaySettings
from orisys_app_shared.scene3d.legacy import (
default_finger_presets,
sensing_surface_normal,
)
from orisys_app_shared.scene3d.package import read_package, scene_from_contents
scene = scene_from_contents(read_package(PACKAGE))
sensing = scene.sensing_part()
normal = sensing_surface_normal(
np.asarray(sensing.positions_sensor_m, dtype=np.float64),
np.asarray(sensing.triangles, dtype=np.int64),
)
frame = _package_arrays(PACKAGE)[0]
baked = next(item for item in frame["camera_presets"] if item["name"] == "default")
centre = np.asarray(scene.bounds_sensor_m, dtype=np.float64).mean(axis=0)
def angle(preset) -> float:
eye = np.asarray(preset.eye_sensor_m, dtype=np.float64)
offset = eye - centre
offset = offset / np.linalg.norm(offset)
return float(np.degrees(np.arccos(np.clip(offset @ normal, -1.0, 1.0))))
baked_angle = angle(
type(scene.camera_presets[0])(
name="default",
eye_sensor_m=tuple(baked["eye"]),
target_sensor_m=tuple(baked["target"]),
up_sensor=tuple(baked["up"]),
vertical_fov_rad=float(baked["fov"]),
)
)
assert baked_angle < 60.0, (
f"the baked default preset is {baked_angle:.1f}° from the sensing surface's "
"normal: it points at the back of the plate"
)
loaded_angle = angle(next(p for p in scene.camera_presets if p.name == "default"))
assert loaded_angle < 60.0, f"the loaded default preset is {loaded_angle:.1f}° off"
derived = default_finger_presets(
scene.bounds_sensor_m,
positions_sensor_m=np.asarray(sensing.positions_sensor_m, dtype=np.float64),
triangles=np.asarray(sensing.triangles, dtype=np.int64),
)
assert angle(derived[0]) < 40.0
assert all(abs(angle(preset)) <= 90.0 + 1e-6 for preset in derived)
# The housing stays hidden by default: the operator turns it on for inspection.
assert DisplaySettings().housing_visible is False
def test_a_preset_behind_the_surface_is_replaced_on_load(tmp_path):
"""The repair path, driven with a deliberately wrong preset.
A package whose ``default`` preset points at the back of the plate is loaded
through the real container, and the loader replaces the whole set with one
derived from the mesh. The point is that the guard is on the *consuming* side:
a package baked by an older compiler is corrected without being rebuilt.
"""
_require_package()
from dataclasses import replace
from orisys_app_shared.scene3d.legacy import sensing_surface_normal
from orisys_app_shared.scene3d.package import (
PackagedAssetLoader,
read_package,
scene_from_contents,
write_package,
)
scene = scene_from_contents(read_package(PACKAGE))
normal = sensing_surface_normal(
np.asarray(scene.sensing_part().positions_sensor_m, dtype=np.float64),
np.asarray(scene.sensing_part().triangles, dtype=np.int64),
)
centre = np.asarray(scene.bounds_sensor_m, dtype=np.float64).mean(axis=0)
backwards = tuple(centre - normal * 0.02)
broken = replace(
scene.camera_presets[0],
eye_sensor_m=backwards,
target_sensor_m=tuple(centre),
)
assert float(np.dot(np.asarray(backwards) - centre, normal)) < 0.0, "fixture is not behind"
path = tmp_path / "backwards.oryscene"
write_package(replace(scene, camera_presets=(broken,) + tuple(scene.camera_presets[1:])), path)
# The file keeps what was written...
assert _package_arrays(path)[0]["camera_presets"][0]["eye"] == list(backwards)
# ...and the loader does not present a view of the back of the sensor.
from orisys_app_shared.scene3d.contracts import ExpectedGeometry
expected = ExpectedGeometry(
"finger", "*", scene.geometry_id, scene.field_domain_id, scene.shape_hw,
scene.source_geometry_id,
)
loaded = PackagedAssetLoader().load(path, expected)
fixed = next(preset for preset in loaded.camera_presets if preset.name == "default")
offset = np.asarray(fixed.eye_sensor_m, dtype=np.float64) - centre
offset = offset / np.linalg.norm(offset)
angle = float(np.degrees(np.arccos(np.clip(offset @ normal, -1.0, 1.0))))
assert angle < 60.0, f"the loader kept a preset {angle:.1f}° off the surface normal"
def test_the_shipped_package_is_at_sensor_scale_not_design_scale():
"""The scale guard that was missing.
Every other check in this file is about *identity*: the geometry id, the domain
id and the source-geometry id are digests of unit-free data, so a package whose
geometry is 54.38x too large passes all of them — the ids are correct and only
the scale is wrong (measured: ``sensing/positions.npy`` at 0.8157 x 0.4079 x
0.8598 m in a package that claims millimetres-as-metres). This asserts the
package agrees with the SDK asset mesh's own physical envelope.
"""
_require_package()
assert_package_geometry_is_sensor_scale(PACKAGE)
def test_the_guard_itself_discriminates():
"""A guard that cannot fail is not a guard: 54.38x must be rejected."""
_require_package()
expected = sensor_frame_envelope_m()
too_large = expected * 54.381958120648946
with pytest.raises(AssertionError):
np.testing.assert_allclose(
too_large, expected, rtol=ENVELOPE_RELATIVE_TOLERANCE
)
assert float(np.max(too_large / expected)) == pytest.approx(54.38196, rel=1e-4)
def test_the_expected_envelope_comes_from_the_sdk_asset_not_the_package():
"""The expectation is external, so a wrong package cannot define it as right."""
if not SDK_MESH.is_file():
pytest.skip("the SDK asset mesh is not present on this machine")
with np.load(SDK_MESH, allow_pickle=False) as mesh:
extent_source_units = np.ptp(
np.asarray(mesh["vertices"], dtype=np.float64), axis=0
)
assert np.allclose(np.sort(extent_source_units), [7.5, 15.0, 15.81], atol=0.02), (
extent_source_units
)
assert np.allclose(
np.sort(sensor_frame_envelope_m()), [0.0075, 0.015, 0.01581], atol=2e-5
)
if PACKAGE.is_file():
# And it really is a different statement from the package's own numbers.
assert not np.allclose(
sensing_envelope_m(PACKAGE), extent_source_units
), "the SDK asset and the package are in the same units: this guard would be vacuous"
def test_the_map_name_follows_the_scale_the_sdk_would_use(tmp_path):
assert map_path_for(tmp_path, 0.4).name == "unroll_map_uv_s0.4.npz"
assert map_path_for(tmp_path, 1.0).name == "unroll_map_uv.npz"
@@ -67,7 +341,7 @@ def test_the_device_description_comes_from_the_files_the_runtime_loads():
assert device.expected.field_domain_id
assert device.expected.source_geometry_id
assert tuple(device.expected.shape_hw) == (300, 280)
assert Path(device.detail["map"]).name == "unroll_map_uv_s0.4.npz"
assert Path(device.detail["map"]).name == _bundle_map_name()
assert device.detail["shape_hw"] == "300x280"
@@ -84,7 +358,7 @@ def test_a_same_size_different_mapping_is_refused(tmp_path):
_require_package()
fake = tmp_path / "finger"
shutil.copytree(BUNDLE, fake)
map_path = fake / "assets" / "30fps-0703" / "unroll_map_uv_s0.4.npz"
map_path = fake / "assets" / "30fps-0703" / _bundle_map_name()
with np.load(map_path, allow_pickle=False) as handle:
data = {key: handle[key] for key in handle.files}
# The same grid, the same valid area — a different mapping.
@@ -149,7 +423,7 @@ def test_a_missing_map_names_the_file_it_looked_for(tmp_path):
with pytest.raises(ThreeDError) as excinfo:
describe_sensor_geometry("finger", fake / "sensor.json")
assert excinfo.value.diagnostic.code == "asset_rejected"
assert "unroll_map_uv_s0.4.npz" in excinfo.value.diagnostic.message
assert _bundle_map_name() in excinfo.value.diagnostic.message
def test_a_bundle_without_an_assets_dir_is_unverified(tmp_path):
+17 -5
View File
@@ -527,18 +527,30 @@ def _tool(name: str):
def _shipped_asset_dirs() -> tuple[Path, Path] | None:
"""The Blender export and its unroll map, when the repository carries them."""
base = (
"""The Blender export and its unroll map, when the repository carries them.
The map's name follows ``camera.source_scale`` (the SDK's ``scaled_map_cache_path``), so it is
derived from the bundle's own ``sensor.json`` rather than pinned: a scale change renames the
file, and a hard-coded name turns this acceptance test into a silent skip.
"""
bundle = (
Path(__file__).resolve().parents[2]
/ "apps"
/ "studio"
/ "config"
/ "sensors"
/ "finger"
/ "assets"
/ "30fps-0703"
)
mapping = base / "unroll_map_uv_s0.4.npz"
base = bundle / "assets" / "30fps-0703"
config_path = bundle / "sensor.json"
if not config_path.is_file():
return None
from orisys_app_shared.acquisition.geometry import map_path_for
scale = (json.loads(config_path.read_text(encoding="utf-8")).get("camera") or {}).get(
"source_scale"
)
mapping = map_path_for(base, scale)
if (base / "mesh.npz").is_file() and mapping.is_file():
return base, mapping
return None
+80
View File
@@ -410,6 +410,86 @@ def test_a_file_that_yields_no_frames_ends_the_session_with_an_error(monkeypatch
_restore(saved)
def test_a_paused_worker_stops_producing_but_keeps_the_device_open():
"""Pause is not stop: compute stops, the capture keeps running.
It lives in this file because the fake device that can answer the question (does the
source keep publishing while the loop takes no frame?) is the harness above.
"""
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
CREATED.clear()
saved = _install()
worker = SdkAcquisitionWorker()
packets: list = []
lock = threading.Lock()
def sink(packet) -> None:
with lock:
packets.append(packet)
try:
worker.add_sink(sink)
worker.start(0, config_ref=BOX_CONFIG)
time.sleep(0.4)
before = len(packets)
assert before > 0, "the fake device produced nothing to pause"
assert not worker.paused
worker.pause()
assert worker.paused
time.sleep(0.4)
during = len(packets)
# The frames the source published across the pause: the device is demonstrably
# alive, which is what makes this different from ``stop``.
source_seq_at_pause = CREATED[-1].capture_source.status.last_seq
assert during == before, (
f"a paused worker emitted {during - before} packet(s): the computation was "
"supposed to stop"
)
worker.resume()
assert not worker.paused
time.sleep(0.4)
after = len(packets)
assert after > during, "the worker did not resume producing packets"
assert CREATED[-1].capture_source.status.last_seq > source_seq_at_pause, (
"the capture source stopped publishing while paused — the device was released "
"or the reader was starved, and neither is what pause means"
)
# Resuming continues from the newest frame rather than replaying the pause: the
# packet that follows the pause is dated after it, not before.
assert float(packets[after - 1].mono_t) > 0.0
stop_requests = getattr(worker, "_stop").is_set()
assert not stop_requests, "pausing must not set the stop flag"
worker.stop(wait=True)
finally:
_restore(saved)
def test_a_new_session_starts_unpaused():
"""Pausing is a statement about one session, not a sticky property of the device."""
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
CREATED.clear()
saved = _install()
worker = SdkAcquisitionWorker()
try:
worker.add_sink(lambda _packet: None)
worker.start(0, config_ref=BOX_CONFIG)
time.sleep(0.3)
worker.pause()
assert worker.paused
worker.stop(wait=True)
assert not worker.paused, "stop cleared the pause flag"
worker.start(0, config_ref=BOX_CONFIG)
assert not worker.paused, "the next session started paused"
worker.stop(wait=True)
finally:
_restore(saved)
if __name__ == "__main__": # pragma: no cover - manual runner
failures = 0
for name, function in sorted(globals().items()):
+109 -1
View File
@@ -5,7 +5,9 @@ the app loads a **compiled** package (`.oryscene`) and nothing else.
| tool | what it is for |
|---|---|
| `compile_scene.py` | a Blender export (+ optional housing GLB) → one `.oryscene` package |
| `compile_scene.py` | a legacy NPZ export (+ optional housing npz) → one `.oryscene` package |
| `align_housing.py` | a housing exported in another frame → a sensor-frame `housing.npz`, with the fit checked |
| `fit_design_frame.py` | the design export's *(unit scale, rigid transform)* pair, split out of the fitted similarity |
| `validate_scene.py` | the checks the loader and the compiler must agree on; the release gate |
| `probe_gl.py` | what this machine's OpenGL actually is (exit 0 GPU, 3 software, 4 no context) |
| `bench_scene.py` | per-stage p50/p95 of the running view (`--cpu-only` for the pure CPU cost) |
@@ -63,6 +65,112 @@ that the scene it is drawing was built from the same surface — and the same ma
via `field_domain_id` — that the device is actually running. A package that predates
this record is still accepted, and reported as *unverified* rather than as a match.
## The housing (the mounting frame)
Non-sensing geometry is a **separate part** with `part_id="housing"`, `role="housing"`: it is
drawn in a flat graphite grey (`unlit`-free, never coloured by the measurement) and the operator
can hide it. It ships **hidden by default** (`DisplaySettings.housing_visible`), because on the
finger sensor the frame is ~33 % wider than the sensing surface and dominated the picture.
`compile_scene.py --housing <npz>` takes a `vertices`/`faces` file **in the same source frame and
units as the export's `mesh.npz`**. Omitted, `<legacy-dir>/housing.npz` is used when present.
The finger sensor's design export lives in a **different frame**: it is at
`orisys-sensor-design/build/export/finger/r01/compute` and holds both `mesh.npz` and
`housing.npz` (`part_names = ['frame']`) in a camera/world frame. That export's mesh and the SDK
asset's mesh are the same surface with **identical `faces`**, so the frame relationship can be
fitted from the 1032 shared vertices instead of transcribed by hand:
```powershell
$design = 'D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute'
& $py tools\scene_assets\align_housing.py `
--design $design `
--sensor $sdk `
--output tools\scene_assets\housing\finger_r01_housing.npz `
--matrix tools\scene_assets\housing\finger_r01_design_to_sensor.npy
& $py tools\scene_assets\compile_scene.py `
--legacy-dir $sdk `
--map "$sdk\unroll_map_uv_s0.4.npz" `
--meters-per-source-unit 0.001 `
--housing tools\scene_assets\housing\finger_r01_housing.npz `
--output apps\studio\config\sensors\finger\scene\finger-display-1.0.0.oryscene
```
`align_housing.py` **refuses to write** when the fitted transform does not hold to
`--tolerance` of the mesh diagonal, so a housing from an unrelated mesh cannot be dropped in
silently (a wrong transform still produces a plausible-looking picture). Re-running it also
regenerates `finger_r01_design_to_sensor.npy`, the 4×4 (`sensor = 0.018388452·R·design + t`)
that the fit found.
`geometry_id` and `source_geometry_id` cover the **sensing surface only** — the housing is in the
asset hash but not in the identities the acquisition side verifies, so adding a frame does not
make every device look unverified.
## Rebuilding the shipped package from the design export (r01)
The committed `apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene` is compiled
from the **design export** — the authoritative surface — and from the unroll map that export
produces (`unroll_map_uv_s0.42.npz`, coverage 39.211 %, domain `dfac267f…`). The SDK asset is
neither the source of it nor promoted into it; the *filename* stays `1.0.0` because
`asset_source.COMPILE_HINT`, `shared/tests/test_scene3d_device_geometry.py` and the stage
documents all name that exact path, and because `find_package()` takes
`sorted(*.oryscene)[-1]` — a second, higher-named package would shadow this one while the
hard-coded references kept validating the stale one. Bumping `asset_version` is a release
decision, not a rebuild side effect.
The export is in the design repo's camera/world frame and in *design* units, so the pair the
compiler needs is **derived**, never transcribed:
```powershell
$design = 'D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute'
$sdk = 'D:\orisys_ws\orisys-sdk\sensors\finger\assets\30fps-0703'
& $py tools\scene_assets\fit_design_frame.py `
--design $design --sensor $sdk `
--rigid-output tools\scene_assets\housing\finger_r01_design_to_sensor_rigid_m.npy `
--report tools\scene_assets\housing\finger_r01_design_frame.json
& $py tools\scene_assets\compile_scene.py `
--legacy-dir $design `
--map "$design\unroll_map_uv_s0.42.npz" `
--meters-per-source-unit 1.8388451511463653e-05 `
--transform tools\scene_assets\housing\finger_r01_design_to_sensor_rigid_m.npy `
--housing "$design\housing.npz" `
--output apps\studio\config\sensors\finger\scene\finger-display-1.0.0.oryscene
```
- The fit over the 1032 shared vertices (identical `faces`/`loop_to_vertex`, so vertex *i* is
vertex *i*) is exact to float noise: rms **5.97e-07 sensor-mm**, 2.59e-08 of the 23.048-mm
diagonal. One design unit is therefore 1/54.38195812 of a sensor millimetre, and the unit
statement is `0.001 × 0.0183884515115`.
- `--transform` must be **rigid** (`compile_scene.py` validates the rotation block) and must be
in the compiler's **column** convention, `p_sensor = T @ [p; 1]`. `align_housing.fit_similarity`
fits the **row** form `p_sensor = s·(p @ R) + t`, so the block is `R.T` and the translation is
`0.001·t` metres. Feeding `housing/finger_r01_design_to_sensor.npy` straight in — it already
carries the scale *and* is in the row convention — is refused outright:
`geometry_mismatch: transform is not a rigid rotation`.
- Hence the trap the two files exist to prevent: the unit statement already contains the fit
scale, so a matrix that contains it too would apply it twice. Calling a design unit a
millimetre (`0.001`) with the correct rigid transform is worse, because it *compiles* and is
54.38196× too large.
- The housing comes from the export's own `housing.npz` (`part_names = ['frame', 'rubber-b']`,
2716 vertices / 5424 triangles). It is already in the frame and units `mesh.npz` is stated in,
which is exactly what `--housing` documents, so the sensor-frame
`housing/finger_r01_housing.npz` that `align_housing.py` regenerates is provenance and an
independent cross-check (the two routes agree to 9.5e-10 m), not a compile input.
- `housing_visible` keeps its shipped default `False` (`DisplaySettings.housing_visible`); there
is no compile flag for it.
The identities this package records are **not** the SDK-route ones, and that is the honest
result rather than a defect to paper over: `geometry_id` and `source_geometry_id` are digests of
positions and of the map's `world_xyz` surface, which the design frame and its differing UVs
state differently, and `field_domain.id` is the new map's. Consequence to expect: the device
description the app derives from `config/sensors/finger/assets/30fps-0703` (its `s0.4` map,
domain `feb7f71e…`) no longer matches, so the pane refuses the package with
`field_domain_mismatch` until that bundle carries the matching `unroll_map_uv_s0.42.npz` and
`camera.source_scale` `0.42`.
## Where the application looks for a package
1. `preference.json` → `"scene3d_package": "<path>"` (an absolute path; also the way
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Put a housing exported in *another* frame into the sensor frame, once, reproducibly.
The finger sensor's design export (``orisys-sensor-design/build/export/finger/r01/compute``)
holds ``mesh.npz`` and ``housing.npz`` (the mounting frame, ``part_names = ['frame']``) in a
**camera/world frame**, while ``compile_scene.py`` compiles from the SDK's asset directory,
whose ``mesh.npz`` is already in the sensor frame (millimetres, the unit stated by
``--meters-per-source-unit``).
The two meshes are the same surface, and their ``faces`` arrays are identical — so vertex *i*
corresponds to vertex *i* and the frame relationship is a similarity transform that can be
**fitted** rather than guessed. This tool:
1. loads both ``mesh.npz`` files,
2. fits the similarity (rotation + uniform scale + translation) from design to sensor vertices,
3. refuses to write anything if the fit does not hold to ``--tolerance`` of the mesh diagonal,
4. applies that transform to the housing vertices and writes a sensor-frame ``housing.npz``
(millimetres, same as the SDK's mesh) ready for ``compile_scene.py --housing``.
Why fit instead of hand-transcribing a matrix: a wrong matrix still produces a plausible
picture, and this one is checked against 1032 known correspondences every time it runs.
python tools/scene_assets/align_housing.py \
--design D:/orisys_ws/orisys-sensor-design/build/export/finger/r01/compute \
--sensor D:/orisys_ws/orisys-sdk/sensors/finger/assets/30fps-0703 \
--output tools/scene_assets/housing/finger_r01_housing.npz
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import numpy as np
#: Residual budget, as a fraction of the sensor-frame mesh diagonal. The fit is exact in
#: practice (float noise); anything above this means the two meshes are not the same shape and
#: the housing must not be transformed as if they were.
DEFAULT_TOLERANCE = 1e-4
def load_vertices(path: Path) -> np.ndarray:
with np.load(path, allow_pickle=False) as data:
if "vertices" not in data.files:
raise SystemExit(f"{path} has no 'vertices' array (found {list(data.files)})")
return np.asarray(data["vertices"], dtype=np.float64)
def load_faces(path: Path) -> np.ndarray:
with np.load(path, allow_pickle=False) as data:
return np.asarray(data["faces"])
def fit_similarity(source: np.ndarray, target: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]:
"""Rotation, translation and uniform scale mapping ``source`` onto ``target``.
``target = scale * (source @ rotation) + translation``. Uses the closed-form Kabsch
solution with a scale factor; a reflection is never the answer here.
"""
source_centroid = source.mean(axis=0)
target_centroid = target.mean(axis=0)
source_centered = source - source_centroid
target_centered = target - target_centroid
u, singular, vt = np.linalg.svd(source_centered.T @ target_centered)
rotation = u @ vt
if np.linalg.det(rotation) < 0.0:
u = u.copy()
u[:, -1] *= -1.0
rotation = u @ vt
scale = float(np.sum(singular) / np.sum(source_centered**2))
translation = target_centroid - scale * (source_centroid @ rotation)
return rotation, translation, scale
def apply_similarity(
points: np.ndarray, rotation: np.ndarray, translation: np.ndarray, scale: float
) -> np.ndarray:
return scale * (points @ rotation) + translation
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--design", type=Path, required=True, help="export dir with mesh+housing")
parser.add_argument("--sensor", type=Path, required=True, help="export dir whose mesh is sensor-frame")
parser.add_argument("--output", type=Path, required=True, help="sensor-frame housing npz to write")
parser.add_argument("--tolerance", type=float, default=DEFAULT_TOLERANCE)
parser.add_argument(
"--matrix",
type=Path,
default=None,
help="also write the fitted 4x4 (source -> sensor) as a .npy, for the record",
)
args = parser.parse_args(argv)
design_mesh = args.design / "mesh.npz"
sensor_mesh = args.sensor / "mesh.npz"
housing_path = args.design / "housing.npz"
for path in (design_mesh, sensor_mesh, housing_path):
if not path.is_file():
raise SystemExit(f"missing {path}")
source = load_vertices(design_mesh)
target = load_vertices(sensor_mesh)
if source.shape != target.shape:
raise SystemExit(
f"the meshes disagree: {source.shape} vs {target.shape} — the correspondence the "
"fit relies on does not exist"
)
if not np.array_equal(load_faces(design_mesh), load_faces(sensor_mesh)):
raise SystemExit(
"the faces arrays differ, so vertex i is not vertex i; refusing to fit a transform"
)
rotation, translation, scale = fit_similarity(source, target)
residual = apply_similarity(source, rotation, translation, scale) - target
rms = float(np.sqrt((residual**2).sum(axis=1).mean()))
diagonal = float(np.linalg.norm(target.max(axis=0) - target.min(axis=0)))
relative = rms / max(diagonal, 1e-12)
print(f"fit over {len(source)} vertices: scale={scale:.8g} rms={rms:.3g} "
f"({relative:.3g} of the {diagonal:.3f}-unit diagonal)")
if relative > float(args.tolerance):
raise SystemExit(
f"the fit is off by {relative:.3g} of the mesh diagonal (budget {args.tolerance:g}): "
"these two meshes are not the same shape, so transforming the housing with this "
"matrix would put it in the wrong place"
)
with np.load(housing_path, allow_pickle=False) as data:
housing_vertices = np.asarray(data["vertices"], dtype=np.float64)
housing_faces = np.asarray(data["faces"])
part_names = data["part_names"] if "part_names" in data.files else None
aligned = apply_similarity(housing_vertices, rotation, translation, scale)
args.output.parent.mkdir(parents=True, exist_ok=True)
np.savez(
args.output,
vertices=aligned.astype(np.float32),
faces=housing_faces.astype(np.int32),
# Provenance, so a later reader can see this was fitted rather than exported.
source_frame=np.array(str(args.design)),
sensor_frame=np.array(str(args.sensor)),
fitted_scale=np.array(scale, dtype=np.float64),
fitted_rms=np.array(rms, dtype=np.float64),
part_names=(part_names if part_names is not None else np.array(["housing"])),
)
print(f"wrote {args.output}")
print(f" housing in the sensor frame: low={np.round(aligned.min(axis=0), 3)} "
f"high={np.round(aligned.max(axis=0), 3)}"
f" ({len(housing_faces)} triangles)")
if args.matrix is not None:
matrix = np.eye(4)
matrix[:3, :3] = scale * rotation
matrix[:3, 3] = translation
args.matrix.parent.mkdir(parents=True, exist_ok=True)
np.save(args.matrix, matrix)
print(f"wrote {args.matrix}")
return 0
if __name__ == "__main__":
sys.exit(main())
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Measure a UV/unroll map — and compare it with the shipped one and the built package.
This is the tool that answers "is the ~40 % of the finger mesh that renders grey *supposed* to be
non-sensing?", with numbers rather than by eye:
* the island's coverage and its bounding box in UV pixels;
* the island in **sensor units** (from ``world_xyz``), so it can be compared with the mesh extent
the app reports;
* how much of the compiled package's surface has no mapping (that is the grey the operator sees);
* the map's ``field_domain_id`` against the one recorded in the package — a mismatch means the pane
would refuse live frames (``coordinator.py`` drops a frame whose domain differs from the scene);
* with ``--against``, a second map (normally the shipped one): coverage/bbox deltas and the share of
pixels whose validity differs.
Offline only; it never runs in the application. Needs only numpy plus the app's own shared package
(the same ``load_legacy_domain`` the compiler uses, so the id is the compiler's id):
python tools/scene_assets/compare_uv_map.py --map <map.npz> \
--against <sdk>/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz \
--package apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import zipfile
from pathlib import Path
import numpy as np
_MM_PER_SOURCE_UNIT = 1.0 # the finger export states 1 source unit = 1 mm
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def load_map(path: Path) -> dict:
with np.load(path, allow_pickle=False) as data:
arrays = {key: data[key] for key in data.files}
return arrays
def _scalar(arrays: dict, key: str):
value = arrays.get(key)
if value is None:
return None
return np.asarray(value).reshape(-1)[0].item() if np.asarray(value).size else None
def describe_map(path: Path, arrays: dict) -> dict:
"""Everything worth knowing about one map, as plain JSON-able values."""
mask = np.asarray(arrays["valid_mask"]).astype(bool)
height, width = mask.shape
rows, cols = np.nonzero(mask)
report: dict = {
"path": str(path),
"sha256": _sha256(path),
"shape_hw": [int(height), int(width)],
"valid_pixels": int(mask.sum()),
"coverage_pct": round(float(mask.mean()) * 100.0, 3),
"recorded": {
key: _scalar(arrays, key)
for key in ("out_width", "out_height", "source_width", "source_height",
"flip_v", "edge_band", "edge_expand", "source_scale", "method")
},
}
if rows.size:
report["island_bbox_px"] = {
"rows": [int(rows.min()), int(rows.max())],
"cols": [int(cols.min()), int(cols.max())],
}
else:
report["island_bbox_px"] = None
world = arrays.get("world_xyz")
if world is not None and rows.size:
points = np.asarray(world)[mask]
finite = np.isfinite(points).all(axis=1)
if finite.any():
points = points[finite] * _MM_PER_SOURCE_UNIT
low, high = points.min(axis=0), points.max(axis=0)
report["island_sensor_units"] = {
"low": [round(float(v), 4) for v in low],
"high": [round(float(v), 4) for v in high],
"size": [round(float(v), 4) for v in (high - low)],
"note": "source units; the finger export states 1 unit = 1 mm",
}
return report
def field_domain_id(map_path: Path) -> str:
"""The compiler's own domain identity for this map (same code path as the compile)."""
from orisys_app_shared.scene3d.legacy import load_legacy_domain
return str(load_legacy_domain(map_path).domain_id)
def package_facts(package: Path) -> dict:
"""What the built package says about the same surface."""
facts: dict = {"path": str(package)}
with zipfile.ZipFile(package) as archive:
names = set(archive.namelist())
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
facts["manifest_keys"] = sorted(manifest.keys())
for key, value in manifest.items():
if isinstance(value, dict) and "id" in value:
facts[f"manifest.{key}.id"] = value["id"]
elif isinstance(value, str) and "domain" in key:
facts[f"manifest.{key}"] = value
for member in ("sensing/surface_valid.npy", "sensing/surface_positions.npy"):
if member not in names:
continue
with archive.open(member) as handle:
array = np.load(handle, allow_pickle=False)
facts[member] = list(array.shape)
if member.endswith("surface_valid.npy"):
valid = np.asarray(array).astype(bool)
facts["surface_valid_true"] = int(valid.sum())
facts["surface_valid_false"] = int((~valid).sum())
facts["surface_unmapped_pct"] = round(
float((~valid).mean()) * 100.0, 3
)
return facts
def compare(left: dict, right: dict) -> dict:
"""How two maps differ: coverage, island movement, and which pixels changed."""
left_mask = np.asarray(left["valid_mask"]).astype(bool)
right_mask = np.asarray(right["valid_mask"]).astype(bool)
result: dict = {}
if left_mask.shape != right_mask.shape:
result["shape_mismatch"] = [list(left_mask.shape), list(right_mask.shape)]
return result
changed = left_mask ^ right_mask
result["coverage_pct"] = {
"left": round(float(left_mask.mean()) * 100.0, 3),
"right": round(float(right_mask.mean()) * 100.0, 3),
"delta": round(float((left_mask.mean() - right_mask.mean()) * 100.0), 3),
}
result["pixels_only_in_left"] = int((left_mask & ~right_mask).sum())
result["pixels_only_in_right"] = int((right_mask & ~left_mask).sum())
result["pixels_differing_pct"] = round(float(changed.mean()) * 100.0, 4)
for name, mask in (("left", left_mask), ("right", right_mask)):
rows, cols = np.nonzero(mask)
result[f"{name}_bbox_px"] = (
[int(rows.min()), int(rows.max()), int(cols.min()), int(cols.max())]
if rows.size
else None
)
result["identical"] = bool(not changed.any())
return result
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--map", type=Path, required=True, help="the map to measure")
parser.add_argument("--against", type=Path, default=None, help="a second map to diff against")
parser.add_argument("--package", type=Path, default=None, help="the built .oryscene to compare")
parser.add_argument("--json", action="store_true", help="machine-readable output")
args = parser.parse_args(argv)
if not args.map.is_file():
print(f"no such map: {args.map}", file=sys.stderr)
return 2
report: dict = {}
arrays = load_map(args.map)
report["map"] = describe_map(args.map, arrays)
print(f"map {args.map}")
info = report["map"]
print(f" shape {info['shape_hw'][1]} x {info['shape_hw'][0]} (W x H)")
print(f" coverage {info['coverage_pct']} % of the domain "
f"({info['valid_pixels']} pixels)")
if info["island_bbox_px"]:
rows, cols = info["island_bbox_px"]["rows"], info["island_bbox_px"]["cols"]
print(f" island bbox rows {rows[0]}..{rows[1]}, cols {cols[0]}..{cols[1]}")
if "island_sensor_units" in info:
print(f" island size {info['island_sensor_units']['size']} (source units)")
print(f" island span {info['island_sensor_units']['low']} .. "
f"{info['island_sensor_units']['high']}")
recorded = {k: v for k, v in info["recorded"].items() if v is not None}
print(f" recorded {recorded}")
try:
report["field_domain_id"] = field_domain_id(args.map)
print(f" domain id {report['field_domain_id']}")
except Exception as exc: # noqa: BLE001 - the tool reports, it does not crash
report["field_domain_id_error"] = f"{type(exc).__name__}: {exc}"
print(f" domain id (could not compute: {type(exc).__name__}: {exc})")
if args.against is not None:
if not args.against.is_file():
print(f"no such map to compare against: {args.against}", file=sys.stderr)
return 2
other = load_map(args.against)
report["against"] = describe_map(args.against, other)
report["comparison"] = compare(arrays, other)
print(f"\nagainst {args.against}")
print(f" coverage {report['against']['coverage_pct']} %")
diff = report["comparison"]
if "shape_mismatch" in diff:
print(f" SHAPE MISMATCH {diff['shape_mismatch']}")
else:
print(f" delta {diff['coverage_pct']['delta']:+.3f} pp, "
f"{diff['pixels_differing_pct']} % of pixels differ")
print(f" only here {diff['pixels_only_in_left']} px; "
f"only there {diff['pixels_only_in_right']} px")
print(f" verdict {'IDENTICAL island' if diff['identical'] else 'different island'}")
if args.package is not None:
if not args.package.is_file():
print(f"no such package: {args.package}", file=sys.stderr)
return 2
facts = package_facts(args.package)
report["package"] = facts
print(f"\npackage {args.package}")
if "surface_unmapped_pct" in facts:
print(f" surface {facts['surface_valid_false']} of "
f"{facts['surface_valid_true'] + facts['surface_valid_false']} render vertices "
f"have no mapping ({facts['surface_unmapped_pct']} %)")
recorded_ids = {k: v for k, v in facts.items() if k.endswith(".id")}
if recorded_ids:
print(f" recorded ids {recorded_ids}")
if report.get("field_domain_id"):
matches = [
key for key, value in recorded_ids.items()
if str(value) == report["field_domain_id"]
]
report["package_domain_matches_map"] = bool(matches)
print(f" domain match {'yes' if matches else 'NO — the pane would refuse live frames'}")
if args.json:
print(json.dumps(report, indent=2, default=str))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+12
View File
@@ -144,6 +144,17 @@ def main(argv: list[str] | None = None) -> int:
default=None,
help="optional 4x4 rigid transform (npy) from source to sensor coordinates",
)
parser.add_argument(
"--housing",
type=Path,
default=None,
help=(
"optional housing/frame npz (vertices, faces) in the same source frame and "
"units as the export's mesh.npz; omitted, <legacy-dir>/housing.npz is used when "
"present. A housing exported in a different frame must be aligned first with "
"tools/scene_assets/align_housing.py"
),
)
protection = parser.add_argument_group(
"release protection (stage 5)", "all optional; a plain package is the default"
)
@@ -205,6 +216,7 @@ def main(argv: list[str] | None = None) -> int:
asset_id=args.asset_id,
asset_version=args.version,
lod_id=args.lod_id,
housing_path=args.housing,
)
if lock is not None:
from dataclasses import replace
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""Derive the *(unit scale, rigid transform)* pair that compiles the **design export**.
``compile_scene.py`` splits two jobs that must not be confused (design document §8.1):
* ``--meters-per-source-unit`` changes *lengths*: one stated source unit, in metres;
* ``--transform`` is **rigid** (orthonormal rotation + translation, no scale), and the
importer validates that before it compiles anything.
The finger design export (``orisys-sensor-design/build/export/finger/r01/compute``) is the
same surface as the SDK asset (``orisys-sdk/sensors/finger/assets/30fps-0703``) — identical
``faces`` and ``loop_to_vertex``, so vertex *i* is vertex *i* — but it lives in the design
repo's camera/world frame and in *design* units. ``align_housing.py`` fits that relationship
as a similarity ``p_sensor_mm = s·R·p_design + t``; the matrix it writes
(``housing/finger_r01_design_to_sensor.npy``) therefore **already carries the scale**: it is
not a legal ``--transform``, and pairing it with ``--meters-per-source-unit 1.8388e-5`` would
apply the scale twice.
This tool does the split, once and reproducibly:
design frame, design units --(R, t_m)--> sensor frame, metres
p_sensor_m = (0.001·s)·(R·p_design) + 0.001·t
so the compiler can be given ``--meters-per-source-unit 0.001·s`` and a rigid 4x4 whose
rotation block is exactly ``R`` and whose translation is ``0.001·t`` (metres). The metre
translation matters: ``t`` from the fit is in the sensor frame's *millimetres*, because that is
the frame the SDK asset is stated in.
It also checks, numerically, the wrong pairings — all three of which are live mistakes here:
* ``--transform`` = the scale-bearing matrix is **refused** by the rigid-transform validator
(it is not a legal rigid transform at all, whatever the unit says), so the "scale applied
twice" trap cannot reach the compiler through that door;
* what the double scale *would* produce is computed directly from the importer's own
``_apply_rigid``, so the number is on the record rather than asserted;
* ``--meters-per-source-unit 0.001`` (calling a design unit a millimetre) with the correct
rigid transform does compile, and is 54.38x too large — a package that looks entirely
plausible.
python tools/scene_assets/fit_design_frame.py \\
--design D:/orisys_ws/orisys-sensor-design/build/export/finger/r01/compute \\
--sensor D:/orisys_ws/orisys-sdk/sensors/finger/assets/30fps-0703 \\
--rigid-output tools/scene_assets/housing/finger_r01_design_to_sensor_rigid_m.npy \\
--report tools/scene_assets/housing/finger_r01_design_frame.json
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
if str(here.parent) not in sys.path:
# The fit itself stays in one place: ``align_housing.fit_similarity``, which
# checks the 1032 correspondences before it writes anything.
sys.path.insert(0, str(here.parent))
#: The SDK asset's frame is millimetres; the compiled package is metres.
MM_PER_SENSOR_UNIT = 0.001
def derive(design_dir: Path, sensor_dir: Path, *, tolerance: float) -> dict:
from align_housing import apply_similarity, fit_similarity, load_faces, load_vertices
design_mesh = design_dir / "mesh.npz"
sensor_mesh = sensor_dir / "mesh.npz"
source = load_vertices(design_mesh)
target = load_vertices(sensor_mesh)
if source.shape != target.shape:
raise SystemExit(
f"the meshes disagree: {source.shape} vs {target.shape} — the correspondence the "
"fit relies on does not exist"
)
if not np.array_equal(load_faces(design_mesh), load_faces(sensor_mesh)):
raise SystemExit(
"the faces arrays differ, so vertex i is not vertex i; refusing to fit a transform"
)
rotation, translation_mm, scale_mm = fit_similarity(source, target)
residual_mm = apply_similarity(source, rotation, translation_mm, scale_mm) - target
rms_mm = float(np.sqrt((residual_mm**2).sum(axis=1).mean()))
diagonal_mm = float(np.linalg.norm(target.max(axis=0) - target.min(axis=0)))
relative = rms_mm / max(diagonal_mm, 1e-12)
if relative > float(tolerance):
raise SystemExit(
f"the fit is off by {relative:.3g} of the mesh diagonal (budget {tolerance:g}): "
"these two meshes are not the same shape"
)
# The split. ``scale_mm`` maps design units -> sensor millimetres, so the unit
# statement is that many millimetres, and the translation is carried into metres
# with the same factor the rotation gets.
#
# **Matrix convention.** ``align_housing.fit_similarity`` fits the *row-vector* form
# ``p_sensor = s·(p_design @ R) + t``, while ``compile_scene.py`` hands ``--transform``
# to ``legacy._apply_rigid``, which is the *column* form ``p_sensor = T @ [p_design; 1]``.
# So the block the compiler needs is ``R.T``, not ``R``: pasting ``R`` in would rotate the
# surface the wrong way by twice the fitted angle while leaving every extent plausible
# (a 90-degree rotation's transpose is its inverse). ``verify()`` is what proves this.
meters_per_source_unit = MM_PER_SENSOR_UNIT * scale_mm
rigid = np.eye(4, dtype=np.float64)
rigid[:3, :3] = rotation.T
rigid[:3, 3] = MM_PER_SENSOR_UNIT * translation_mm
scale_bearing = np.eye(4, dtype=np.float64)
scale_bearing[:3, :3] = scale_mm * rotation
scale_bearing[:3, 3] = translation_mm
extent_design = np.ptp(source, axis=0)
extent_sensor_mm = np.ptp(target, axis=0)
return {
"design_dir": str(design_dir),
"sensor_dir": str(sensor_dir),
"vertices": int(len(source)),
"fit": {
"scale_design_unit_to_sensor_mm": scale_mm,
"rotation_row_convention": rotation.tolist(),
"rotation_transposed_column_convention": rotation.T.tolist(),
"translation_sensor_mm": translation_mm.tolist(),
"rms_sensor_mm": rms_mm,
"rms_sensor_m": rms_mm * MM_PER_SENSOR_UNIT,
"diagonal_sensor_mm": diagonal_mm,
"relative_of_diagonal": relative,
"tolerance": float(tolerance),
},
"unit_statement": {
"meters_per_source_unit": meters_per_source_unit,
"one_design_unit_is_mm": scale_mm,
"one_sensor_mm_is_design_units": 1.0 / scale_mm,
},
"rigid_transform_sensor_from_design_m": rigid.tolist(),
"scale_bearing_matrix_sensor_mm_from_design": scale_bearing.tolist(),
"extent_design_units": extent_design.tolist(),
"extent_sensor_mm": extent_sensor_mm.tolist(),
"extent_compiled_m": (extent_sensor_mm * MM_PER_SENSOR_UNIT).tolist(),
"rotation_orthonormality_error": float(
np.abs(rotation @ rotation.T - np.eye(3)).max()
),
"rotation_determinant": float(np.linalg.det(rotation)),
}
def verify(design_dir: Path, sensor_dir: Path, rigid: np.ndarray, units: float) -> dict:
"""Compile in memory through the real importer and compare with the SDK-frame route.
The reference is the *shipped* route: the SDK asset with ``0.001`` and no transform.
If the design route is right, both land on the same sensor-metre positions — order
included, because identical ``faces``/``loop_to_vertex`` and no UV seam means the
render vertices come out in the same order.
"""
from orisys_app_shared.scene3d.contracts import ThreeDError
from orisys_app_shared.scene3d.legacy import LegacyFingerImporter, validate_rigid_transform
design_map = design_dir / "unroll_map_uv_s0.42.npz"
sensor_map = sensor_dir / "unroll_map_uv_s0.4.npz"
importer = LegacyFingerImporter()
reference = importer.convert(
sensor_dir, sensor_map,
transform_sensor_from_source=np.eye(4),
meters_per_source_unit=MM_PER_SENSOR_UNIT,
).sensing_part().positions_sensor_m
result: dict = {}
compiled = importer.convert(
design_dir, design_map,
transform_sensor_from_source=rigid,
meters_per_source_unit=units,
).sensing_part().positions_sensor_m
difference = np.asarray(compiled, dtype=np.float64) - np.asarray(reference, dtype=np.float64)
result["design_route"] = {
"extent_m": np.ptp(compiled, axis=0).tolist(),
"low_m": compiled.min(axis=0).tolist(),
"high_m": compiled.max(axis=0).tolist(),
"max_abs_difference_from_sdk_route_m": float(np.abs(difference).max()),
"rms_difference_from_sdk_route_m": float(np.sqrt((difference**2).sum(axis=1).mean())),
"bitwise_identical_to_sdk_route": bool(
np.array_equal(np.asarray(compiled, dtype=np.float32),
np.asarray(reference, dtype=np.float32))
),
}
result["sdk_route_extent_m"] = np.ptp(reference, axis=0).tolist()
from orisys_app_shared.scene3d.legacy import _apply_rigid, split_at_uv_seams
stored = np.load(
Path(__file__).resolve().parent / "housing" / "finger_r01_design_to_sensor.npy"
)
# Trap 1: the scale-bearing matrix as ``--transform``. Refused, whatever the unit is.
try:
validate_rigid_transform(stored)
result["trap_stored_matrix_as_transform"] = "ACCEPTED (the validator missed it)"
except ThreeDError as exc:
result["trap_stored_matrix_as_transform"] = (
f"refused: {exc.diagnostic.code}: {exc.diagnostic.message}"
)
# Trap 2: what the double scale *would* do, computed through the importer's own
# rigid application — the stored matrix already holds 1/54.38, and the derived unit
# holds it too, so the rotation block scales twice and ``t`` (sensor-mm) is added to
# a metre-scale result.
with np.load(design_dir / "mesh.npz", allow_pickle=False) as handle:
positions, _, _, _ = split_at_uv_seams(
handle["vertices"], handle["faces"], handle["uv_data"], handle["loop_to_vertex"]
)
doubled = _apply_rigid(positions * units, stored)
result["trap_double_scale_if_forced"] = {
"extent_m": np.ptp(doubled, axis=0).tolist(),
"low_m": doubled.min(axis=0).tolist(),
"high_m": doubled.max(axis=0).tolist(),
"note": (
"the stated unit already carries the 1/54.38 fit scale and the matrix carries it "
"again; the translation is sensor-mm added to a metre-scale result, so the extent "
"collapses to the fit residual and the surface sits ~4.5 m from the origin"
),
}
# Trap 3: a *legal* rigid transform with the wrong unit statement (a design unit is not
# a millimetre). This one compiles; nothing downstream can tell it is 54.38x too big.
milli = importer.convert(
design_dir, design_map,
transform_sensor_from_source=rigid,
meters_per_source_unit=MM_PER_SENSOR_UNIT,
).sensing_part().positions_sensor_m
result["trap_design_unit_called_a_millimetre"] = {
"extent_m": np.ptp(milli, axis=0).tolist(),
"low_m": milli.min(axis=0).tolist(),
"high_m": milli.max(axis=0).tolist(),
"factor_too_large": float(
np.ptp(milli, axis=0).max() / max(np.ptp(compiled, axis=0).max(), 1e-30)
),
"note": "compiles cleanly and is wrong by the fit scale; the validator cannot see it",
}
return result
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--design", type=Path, required=True, help="design export dir")
parser.add_argument("--sensor", type=Path, required=True, help="sensor-frame export dir")
parser.add_argument("--rigid-output", type=Path, required=True, help="rigid 4x4 to write")
parser.add_argument("--report", type=Path, default=None, help="JSON record of the derivation")
parser.add_argument("--tolerance", type=float, default=1e-4)
parser.add_argument(
"--no-verify",
action="store_true",
help="skip the in-memory compile checks (they need the app's shared package)",
)
args = parser.parse_args(argv)
_bootstrap_paths()
record = derive(args.design, args.sensor, tolerance=args.tolerance)
rigid = np.asarray(record["rigid_transform_sensor_from_design_m"], dtype=np.float64)
units = float(record["unit_statement"]["meters_per_source_unit"])
fit = record["fit"]
print(f"fit over {record['vertices']} design->sensor correspondences "
f"(identical faces, so vertex i is vertex i)")
print(f" scale {fit['scale_design_unit_to_sensor_mm']:.12g} "
f"sensor-mm per design unit (= 1/{(1.0 / fit['scale_design_unit_to_sensor_mm']):.8f})")
print(f" residual rms {fit['rms_sensor_mm']:.3g} mm "
f"({fit['rms_sensor_m']:.3g} m) = {fit['relative_of_diagonal']:.3g} of the "
f"{fit['diagonal_sensor_mm']:.3f}-mm diagonal (budget {fit['tolerance']:g})")
print(f" rotation orthonormality error {record['rotation_orthonormality_error']:.3g}, "
f"det {record['rotation_determinant']:.12f}")
print(f" translation {[round(v, 12) for v in fit['translation_sensor_mm']]} sensor-mm")
print()
print("the split the compiler needs:")
print(f" --meters-per-source-unit {units:.12g} (one design unit is "
f"{(1.0 / fit['scale_design_unit_to_sensor_mm']):.8f} sensor-mm)")
print(f" --transform <rigid 4x4> rotation = R, translation = 0.001*t = "
f"{[round(float(v), 12) for v in rigid[:3, 3]]} m")
print(f" design extent {np.round(record['extent_design_units'], 4)} -> sensor "
f"{np.round(record['extent_sensor_mm'], 4)} mm = "
f"{np.round(record['extent_compiled_m'], 6)} m")
if not args.no_verify:
print()
print("in-memory compile checks through the real importer:")
checks = verify(args.design, args.sensor, rigid, units)
design = checks["design_route"]
print(f" design route extent {np.round(design['extent_m'], 8)} m "
f"low {np.round(design['low_m'], 8)} high {np.round(design['high_m'], 8)}")
print(f" sdk route extent {np.round(checks['sdk_route_extent_m'], 8)} m")
print(f" agreement max|diff| {design['max_abs_difference_from_sdk_route_m']:.3g} m, "
f"rms {design['rms_difference_from_sdk_route_m']:.3g} m, "
f"bitwise identical {design['bitwise_identical_to_sdk_route']}")
print(f" trap (stored scale-bearing matrix as --transform): "
f"{checks['trap_stored_matrix_as_transform']}")
forced = checks["trap_double_scale_if_forced"]
print(f" trap (derived unit + scale-bearing matrix, forced): extent "
f"{np.round(forced['extent_m'], 9)} m, low {np.round(forced['low_m'], 6)}, "
f"high {np.round(forced['high_m'], 6)}")
milli = checks["trap_design_unit_called_a_millimetre"]
print(f" trap (design unit called a millimetre): extent "
f"{np.round(milli['extent_m'], 6)} m — "
f"{milli['factor_too_large']:.5f}x too large, and accepted")
record["verification"] = checks
args.rigid_output.parent.mkdir(parents=True, exist_ok=True)
np.save(args.rigid_output, rigid)
print()
print(f"wrote {args.rigid_output}")
if args.report is not None:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(
json.dumps(record, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
print(f"wrote {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,191 @@
{
"design_dir": "D:\\orisys_ws\\orisys-sensor-design\\build\\export\\finger\\r01\\compute",
"sensor_dir": "D:\\orisys_ws\\orisys-sdk\\sensors\\finger\\assets\\30fps-0703",
"vertices": 1032,
"fit": {
"scale_design_unit_to_sensor_mm": 0.01838845151146365,
"rotation_row_convention": [
[
8.005154792182337e-08,
0.9999999999999964,
-4.310626667257856e-08
],
[
4.169177018082366e-08,
-4.310626975245822e-08,
-0.999999999999999
],
[
-0.9999999999999958,
8.005154632977046e-08,
-4.169177398834113e-08
]
],
"rotation_transposed_column_convention": [
[
8.005154792182337e-08,
4.169177018082366e-08,
-0.9999999999999958
],
[
0.9999999999999964,
-4.310626975245822e-08,
8.005154632977046e-08
],
[
-4.310626667257856e-08,
-0.999999999999999,
-4.169177398834113e-08
]
],
"translation_sensor_mm": [
4.555480057606443,
-0.18626088406372004,
8.49151750616984
],
"rms_sensor_mm": 5.97442703083736e-07,
"rms_sensor_m": 5.97442703083736e-10,
"diagonal_sensor_mm": 23.047642302706198,
"relative_of_diagonal": 2.5922074598215443e-08,
"tolerance": 0.0001
},
"unit_statement": {
"meters_per_source_unit": 1.8388451511463648e-05,
"one_design_unit_is_mm": 0.01838845151146365,
"one_sensor_mm_is_design_units": 54.381958120648946
},
"rigid_transform_sensor_from_design_m": [
[
8.005154792182337e-08,
4.169177018082366e-08,
-0.9999999999999958,
0.004555480057606444
],
[
0.9999999999999964,
-4.310626975245822e-08,
8.005154632977046e-08,
-0.00018626088406372004
],
[
-4.310626667257856e-08,
-0.999999999999999,
-4.169177398834113e-08,
0.008491517506169841
],
[
0.0,
0.0,
0.0,
1.0
]
],
"scale_bearing_matrix_sensor_mm_from_design": [
[
1.4720240073780576e-09,
0.018388451511463583,
-7.926574945489324e-10,
4.555480057606443
],
[
7.66647094397162e-10,
-7.926575511831501e-10,
-0.018388451511463632,
-0.18626088406372004
],
[
-0.018388451511463573,
1.47202397810267e-09,
-7.666471644115123e-10,
8.49151750616984
],
[
0.0,
0.0,
0.0,
1.0
]
],
"extent_design_units": [
815.7294616699219,
407.8647003173828,
859.7575836181641
],
"extent_sensor_mm": [
15.809610366821289,
15.000001430511475,
7.499999523162842
],
"extent_compiled_m": [
0.01580961036682129,
0.015000001430511474,
0.007499999523162842
],
"rotation_orthonormality_error": 1.5543122344752192e-15,
"rotation_determinant": 1.0000000000000013,
"verification": {
"design_route": {
"extent_m": [
0.01580961048603058,
0.015000001527369022,
0.007499999832361937
],
"low_m": [
-0.01580961048603058,
-0.007500000763684511,
-2.6377949824407665e-10
],
"high_m": [
-1.61737203491219e-11,
0.007500000763684511,
0.007499999366700649
],
"max_abs_difference_from_sdk_route_m": 2.7939677238464355e-09,
"rms_difference_from_sdk_route_m": 7.126150500418507e-10,
"bitwise_identical_to_sdk_route": false
},
"sdk_route_extent_m": [
0.01580961048603058,
0.015000000596046448,
0.007499999366700649
],
"trap_stored_matrix_as_transform": "refused: geometry_mismatch: transform is not a rigid rotation (its rows are not orthonormal): it would scale the geometry as well",
"trap_double_scale_if_forced": {
"extent_m": [
0.00013791339792046386,
0.0002907142510321925,
0.00027582680075788346
],
"low_m": [
4.5554982900546195,
-0.1866353665546318,
8.49137616772656
],
"high_m": [
4.55563620345254,
-0.18634465230359962,
8.491651994527318
],
"note": "the stated unit already carries the 1/54.38 fit scale and the matrix carries it again; the translation is sensor-mm added to a metre-scale result, so the extent collapses to the fit residual and the surface sits ~4.5 m from the origin"
},
"trap_design_unit_called_a_millimetre": {
"extent_m": [
0.8597576022148132,
0.8157294988632202,
0.40786468982696533
],
"low_m": [
-1.1029380559921265,
-0.39792177081108093,
-0.4532938599586487
],
"high_m": [
-0.24318045377731323,
0.4178076982498169,
-0.04542917385697365
],
"factor_too_large": 54.3819580078125,
"note": "compiles cleanly and is wrong by the fit scale; the validator cannot see it"
}
}
}
Binary file not shown.